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:
parent
0dada968aa
commit
1ca703e548
@ -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
|
||||
|
||||
93
lib/simpleshop_theme_web/controllers/image_controller.ex
Normal file
93
lib/simpleshop_theme_web/controllers/image_controller.ex
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -1,234 +1,611 @@
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<div class="navbar bg-base-100 shadow-sm">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold px-4">Theme Editor</h1>
|
||||
</div>
|
||||
<div class="flex-none gap-2">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="save_theme"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Save Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<div class="flex flex-col lg:flex-row lg:h-screen">
|
||||
<!-- Controls Sidebar -->
|
||||
<div class="w-full lg:w-80 bg-base-100 p-6 shadow-lg overflow-y-auto lg:h-[calc(100vh-64px)]">
|
||||
<div class="space-y-6">
|
||||
<!-- Presets Section -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4">Presets</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<%= for preset_name <- @preset_names do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="apply_preset"
|
||||
phx-value-preset={preset_name}
|
||||
class="btn btn-sm btn-outline capitalize"
|
||||
>
|
||||
<%= preset_name %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-[380px] bg-base-100 border-r border-base-300 p-6 overflow-y-auto lg:h-screen flex-shrink-0">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold tracking-tight mb-2 text-base-content">Theme Studio</h1>
|
||||
<p class="text-sm text-base-content/60 leading-relaxed">One theme, infinite possibilities. Every combination is designed to work beautifully.</p>
|
||||
</div>
|
||||
|
||||
<!-- Customization Controls -->
|
||||
<div class="divider"></div>
|
||||
<!-- Site Name -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Shop name</label>
|
||||
<form phx-change="update_setting" phx-value-field="site_name">
|
||||
<input
|
||||
type="text"
|
||||
name="site_name"
|
||||
value={@theme_settings.site_name}
|
||||
placeholder="Your shop name"
|
||||
class="w-full px-4 py-3 border border-base-300 rounded-lg text-base bg-base-100 text-base-content focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Mood -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Mood</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<%= for mood <- ["neutral", "warm", "cool", "dark"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="mood"
|
||||
phx-value-setting_value={mood}
|
||||
class={"btn btn-sm #{if @theme_settings.mood == mood, do: "btn-primary", else: "btn-outline"} capitalize"}
|
||||
>
|
||||
<%= mood %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Branding Section (styled background box) -->
|
||||
<div class="bg-base-200 rounded-xl p-4 mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-4">Logo & header</label>
|
||||
|
||||
<!-- Typography -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Typography</h3>
|
||||
<form phx-change="update_setting" phx-value-field="typography">
|
||||
<select
|
||||
name="typography"
|
||||
class="select select-bordered select-sm w-full capitalize"
|
||||
<!-- Logo Mode Radio Cards -->
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<%= for {value, title, desc} <- [
|
||||
{"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={[
|
||||
"flex items-center gap-3 p-3 bg-base-100 border-2 rounded-lg cursor-pointer transition-all",
|
||||
if(@theme_settings.logo_mode == value,
|
||||
do: "border-base-content",
|
||||
else: "border-transparent hover:border-base-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal", "impulse"] do %>
|
||||
<option value={typo} selected={@theme_settings.typography == typo}>
|
||||
<%= typo %>
|
||||
</option>
|
||||
<input
|
||||
type="radio"
|
||||
name="logo_mode"
|
||||
value={value}
|
||||
checked={@theme_settings.logo_mode == value}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="logo_mode"
|
||||
phx-value-setting_value={value}
|
||||
class="hidden"
|
||||
/>
|
||||
<span class={[
|
||||
"w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all",
|
||||
if(@theme_settings.logo_mode == value,
|
||||
do: "border-base-content",
|
||||
else: "border-base-300"
|
||||
)
|
||||
]}>
|
||||
<span class={[
|
||||
"w-2 h-2 rounded-full bg-base-content transition-all",
|
||||
if(@theme_settings.logo_mode == value, do: "scale-100 opacity-100", else: "scale-0 opacity-0")
|
||||
]}></span>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-base-content"><%= title %></div>
|
||||
<div class="text-xs text-base-content/60"><%= desc %></div>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Logo Upload (for logo-text and logo-only modes) -->
|
||||
<%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %>
|
||||
<div class="mt-4 pt-4 border-t border-base-300">
|
||||
<span class="block text-xs font-medium text-base-content/70 mb-2">Upload logo (SVG or PNG)</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<form phx-change="noop" phx-submit="noop" class="flex-1">
|
||||
<label class="flex-1 bg-base-100 border border-dashed border-base-300 rounded-lg p-3 text-sm text-base-content/60 text-center cursor-pointer hover:border-base-content/40 hover:text-base-content/80 transition-all">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.logo_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
<%= if @logo_image do %>
|
||||
<div class="relative w-16 h-10 bg-base-100 border border-base-300 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={"/images/#{@logo_image.id}"}
|
||||
alt="Current logo"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_logo"
|
||||
class="absolute -top-1.5 -right-1.5 w-[18px] h-[18px] bg-base-content text-base-100 rounded-full text-xs flex items-center justify-center leading-none"
|
||||
title="Remove logo"
|
||||
>×</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Shape -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Shape</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="shape"
|
||||
phx-value-setting_value={shape}
|
||||
class={"btn btn-sm #{if @theme_settings.shape == shape, do: "btn-primary", else: "btn-outline"} capitalize"}
|
||||
>
|
||||
<%= shape %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Density -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Density</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<%= for density <- ["spacious", "balanced", "compact"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="density"
|
||||
phx-value-setting_value={density}
|
||||
class={"btn btn-sm #{if @theme_settings.density == density, do: "btn-primary", else: "btn-outline"} capitalize text-xs"}
|
||||
>
|
||||
<%= density %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Columns -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Grid Columns</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<%= for cols <- ["2", "3", "4"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="grid_columns"
|
||||
phx-value-setting_value={cols}
|
||||
class={"btn btn-sm #{if @theme_settings.grid_columns == cols, do: "btn-primary", else: "btn-outline"}"}
|
||||
>
|
||||
<%= cols %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colors -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Accent Color</h3>
|
||||
<form id="accent-color-form" phx-change="update_color" phx-value-field="accent_color" phx-hook="ColorSync">
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
id="accent-color-picker"
|
||||
name="value"
|
||||
value={@theme_settings.accent_color}
|
||||
class="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="accent-color-text"
|
||||
name="value"
|
||||
value={@theme_settings.accent_color}
|
||||
class="input input-bordered input-sm flex-1 font-mono text-xs"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Header Layout -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Header Layout</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<%= for layout <- ["standard", "centered", "minimal"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="header_layout"
|
||||
phx-value-setting_value={layout}
|
||||
class={"btn btn-sm #{if @theme_settings.header_layout == layout, do: "btn-primary", else: "btn-outline"} capitalize text-xs"}
|
||||
>
|
||||
<%= layout %>
|
||||
</button>
|
||||
<%= for entry <- @uploads.logo_upload.entries do %>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<div class="flex-1 h-1.5 bg-base-300 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all" style={"width: #{entry.progress}%"}></div>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60"><%= entry.progress %>%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="logo_upload"
|
||||
class="text-base-content/40 hover:text-base-content/70"
|
||||
>×</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.logo_upload, entry) do %>
|
||||
<p class="text-error text-xs mt-1"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.logo_upload) do %>
|
||||
<p class="text-error text-xs mt-1"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Logo Size Slider -->
|
||||
<%= if @logo_image do %>
|
||||
<form phx-change="update_setting" phx-value-field="logo_size" class="mt-3">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-medium text-base-content/70">Logo size</span>
|
||||
<span class="text-xs font-mono text-base-content/60"><%= @theme_settings.logo_size %>px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="24"
|
||||
max="120"
|
||||
value={@theme_settings.logo_size}
|
||||
name="logo_size"
|
||||
class="range range-xs range-primary w-full"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- SVG Recolor Toggle (only for SVG logos) -->
|
||||
<%= if @logo_image.is_svg do %>
|
||||
<div class="mt-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.logo_recolor}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="logo_recolor"
|
||||
phx-value-setting_value={if @theme_settings.logo_recolor, do: "false", else: "true"}
|
||||
class="toggle toggle-sm toggle-primary"
|
||||
/>
|
||||
<span class="text-sm text-base-content/70">Recolour logo</span>
|
||||
</label>
|
||||
|
||||
<%= if @theme_settings.logo_recolor do %>
|
||||
<form id="logo-color-form" phx-change="update_color" phx-value-field="logo_color" phx-hook="ColorSync" class="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="color"
|
||||
name="value"
|
||||
value={@theme_settings.logo_color}
|
||||
class="w-9 h-9 rounded-lg cursor-pointer border-0 p-0"
|
||||
/>
|
||||
<span class="font-mono text-sm text-base-content/70"><%= @theme_settings.logo_color %></span>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Header Background Toggle -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.header_background_enabled}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="header_background_enabled"
|
||||
phx-value-setting_value={if @theme_settings.header_background_enabled, do: "false", else: "true"}
|
||||
class="toggle toggle-sm toggle-primary"
|
||||
/>
|
||||
<span class="text-sm text-base-content/80">Header background image</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Image Upload (only when enabled) -->
|
||||
<%= if @theme_settings.header_background_enabled do %>
|
||||
<div class="bg-base-200 rounded-xl p-4 mb-6">
|
||||
<span class="block text-xs font-medium text-base-content/70 mb-2">Upload header image</span>
|
||||
<form phx-change="noop" phx-submit="noop">
|
||||
<label class="block bg-base-100 border border-dashed border-base-300 rounded-lg p-3 text-sm text-base-content/60 text-center cursor-pointer hover:border-base-content/40 hover:text-base-content/80 transition-all">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.header_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<%= if @header_image do %>
|
||||
<div class="relative w-full h-[60px] bg-base-100 border border-base-300 rounded-lg mt-2 overflow-hidden">
|
||||
<img
|
||||
src={"/images/#{@header_image.id}"}
|
||||
alt="Current header background"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_header"
|
||||
class="absolute -top-1.5 -right-1.5 w-[18px] h-[18px] bg-base-content text-base-100 rounded-full text-xs flex items-center justify-center leading-none"
|
||||
title="Remove header background"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<!-- Header Image Controls -->
|
||||
<div class="mt-3 space-y-3">
|
||||
<form phx-change="update_setting" phx-value-field="header_zoom">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-medium text-base-content/70">Zoom</span>
|
||||
<span class="text-xs font-mono text-base-content/60"><%= @theme_settings.header_zoom %>%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="200"
|
||||
value={@theme_settings.header_zoom}
|
||||
name="header_zoom"
|
||||
class="range range-xs range-primary w-full"
|
||||
/>
|
||||
</form>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_x">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-medium text-base-content/70">Horizontal position</span>
|
||||
<span class="text-xs font-mono text-base-content/60"><%= @theme_settings.header_position_x %>%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_x}
|
||||
name="header_position_x"
|
||||
class="range range-xs range-primary w-full"
|
||||
/>
|
||||
</form>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_y">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-medium text-base-content/70">Vertical position</span>
|
||||
<span class="text-xs font-mono text-base-content/60"><%= @theme_settings.header_position_y %>%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_y}
|
||||
name="header_position_y"
|
||||
class="range range-xs range-primary w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for entry <- @uploads.header_upload.entries do %>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<div class="flex-1 h-1.5 bg-base-300 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all" style={"width: #{entry.progress}%"}></div>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60"><%= entry.progress %>%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="header_upload"
|
||||
class="text-base-content/40 hover:text-base-content/70"
|
||||
>×</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.header_upload, entry) do %>
|
||||
<p class="text-error text-xs mt-1"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.header_upload) do %>
|
||||
<p class="text-error text-xs mt-1"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Presets Section -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Start with a preset</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<%= for {preset_name, description} <- @presets_with_descriptions do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="apply_preset"
|
||||
phx-value-preset={preset_name}
|
||||
class={[
|
||||
"p-3 rounded-lg text-left transition-all border-2",
|
||||
if(@active_preset == preset_name,
|
||||
do: "border-base-content bg-base-100",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<div class="font-semibold text-sm capitalize text-base-content"><%= preset_name %></div>
|
||||
<div class="text-xs text-base-content/60"><%= description %></div>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accent Color (stays in essentials) -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Accent colour</label>
|
||||
<form id="accent-color-form" phx-change="update_color" phx-value-field="accent_color" phx-hook="ColorSync">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="accent-color-picker"
|
||||
name="value"
|
||||
value={@theme_settings.accent_color}
|
||||
class="w-12 h-12 rounded-lg cursor-pointer border-0 p-0"
|
||||
/>
|
||||
<span class="font-mono text-sm text-base-content/70"><%= @theme_settings.accent_color %></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Customise Section (collapsible accordion using native details/summary) -->
|
||||
<details class="border-t border-base-300 mt-6 pt-4 group" id="customise-section" open={@customise_open}>
|
||||
<summary class="flex items-center justify-between w-full py-3 cursor-pointer list-none [&::-webkit-details-marker]:hidden" phx-click="toggle_customise">
|
||||
<span class="text-sm font-semibold text-base-content/70 group-hover:text-base-content transition-colors">Customise</span>
|
||||
<svg class="w-5 h-5 text-base-content/50 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="pt-4">
|
||||
<!-- Typography Group -->
|
||||
<div class="mb-6 pb-6 border-b border-base-200">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-4 h-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
||||
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-base-content">Typography</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Font style</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="typography"
|
||||
phx-value-setting_value={typo}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all capitalize",
|
||||
if(@theme_settings.typography == typo,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= typo %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colours Group -->
|
||||
<div class="mb-6 pb-6 border-b border-base-200">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-4 h-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-base-content">Colours</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Colour mood</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="mood"
|
||||
phx-value-setting_value={mood}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all capitalize",
|
||||
if(@theme_settings.mood == mood,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= mood %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Group -->
|
||||
<div class="mb-6 pb-6 border-b border-base-200">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-4 h-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-base-content">Layout</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Product grid</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for cols <- ["2", "3", "4"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="grid_columns"
|
||||
phx-value-setting_value={cols}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all",
|
||||
if(@theme_settings.grid_columns == cols,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= cols %> columns
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Density</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for density <- ["spacious", "balanced", "compact"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="density"
|
||||
phx-value-setting_value={density}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all capitalize",
|
||||
if(@theme_settings.density == density,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= density %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Header layout</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for layout <- ["standard", "centered", "minimal"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="header_layout"
|
||||
phx-value-setting_value={layout}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all capitalize",
|
||||
if(@theme_settings.header_layout == layout,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= layout %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shape Group -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-4 h-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-base-content">Shape</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-base-content/60 mb-3">Corner style</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="update_setting"
|
||||
phx-value-field="shape"
|
||||
phx-value-setting_value={shape}
|
||||
class={[
|
||||
"px-3 py-2 text-sm rounded-lg border-2 transition-all capitalize",
|
||||
if(@theme_settings.shape == shape,
|
||||
do: "border-base-content bg-base-100 text-base-content",
|
||||
else: "border-transparent bg-base-200 hover:bg-base-300 text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= shape %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Current Combination Display -->
|
||||
<div class="bg-base-200 rounded-xl p-4 mt-6">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Current combination</div>
|
||||
<div class="text-sm text-base-content leading-relaxed">
|
||||
<%= 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="text-xs text-base-content/40 mt-2">One of 100,000+ possible combinations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Area -->
|
||||
<div class="flex-1 p-6">
|
||||
<div class="bg-base-100 rounded-lg shadow-xl overflow-hidden">
|
||||
<div class="flex-1 p-6 flex flex-col overflow-hidden bg-base-200">
|
||||
<div class="max-w-[1200px] mx-auto w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Preview Page Switcher -->
|
||||
<div class="bg-base-200 p-4 border-b border-base-300">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for {page_name, label} <- [
|
||||
{:home, "Home"},
|
||||
{:collection, "Collection"},
|
||||
{:pdp, "Product"},
|
||||
{:cart, "Cart"},
|
||||
{:about, "About"},
|
||||
{:contact, "Contact"},
|
||||
{:error, "404"}
|
||||
] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={page_name}
|
||||
class={[
|
||||
"btn btn-sm",
|
||||
if(@preview_page == page_name, do: "btn-primary", else: "btn-ghost")
|
||||
]}
|
||||
>
|
||||
<%= label %>
|
||||
</button>
|
||||
<% end %>
|
||||
<div class="flex gap-1 mb-3 bg-base-300 p-1 rounded-lg w-fit flex-shrink-0">
|
||||
<%= for {page_name, label} <- [
|
||||
{:home, "Home"},
|
||||
{:collection, "Collection"},
|
||||
{:pdp, "Product"},
|
||||
{:cart, "Cart"},
|
||||
{:about, "About"},
|
||||
{:contact, "Contact"},
|
||||
{:error, "404"}
|
||||
] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={page_name}
|
||||
class={[
|
||||
"px-4 py-2 text-sm font-medium rounded transition-all",
|
||||
if(@preview_page == page_name,
|
||||
do: "bg-base-100 text-base-content shadow-sm",
|
||||
else: "text-base-content/70 hover:text-base-content"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= label %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Browser Chrome -->
|
||||
<div class="flex items-center bg-gradient-to-b from-base-300 to-base-300/80 border border-base-content/20 border-b-base-content/30 rounded-t-[10px] px-[14px] py-[10px] gap-[14px] flex-shrink-0">
|
||||
<div class="flex gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-[#ff5f57] border border-[#e14640]"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-[#ffbd2e] border border-[#dfa123]"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-[#28c940] border border-[#1aab29]"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-2 bg-base-100 border border-base-content/20 rounded-md px-3 py-[5px]">
|
||||
<svg class="w-[14px] h-[14px] text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-base-content/60 truncate"><%= @theme_settings.site_name |> String.downcase() |> String.replace(" ", "") %>.myshopify.com</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Frame -->
|
||||
<div class="preview-frame bg-white overflow-auto"
|
||||
<div class="preview-frame bg-white overflow-auto flex-1 rounded-b-lg border border-t-0 border-base-content/20"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
style="min-height: 600px; max-height: calc(100vh - 200px);">
|
||||
data-header={@theme_settings.header_layout}>
|
||||
<style>
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
|
||||
<%= case @preview_page do %>
|
||||
<% :home -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.home preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.home preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :collection -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.collection preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.collection preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :pdp -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.pdp preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.pdp preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :cart -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.cart preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.cart preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :about -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.about preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.about preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :contact -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.contact preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.contact preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% :error -> %>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.error preview_data={@preview_data} theme_settings={@theme_settings} />
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.error preview_data={@preview_data} theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"""
|
||||
<header
|
||||
class="shop-header"
|
||||
style={"position: relative; background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; #{header_justify(@theme_settings.header_layout)};"}
|
||||
>
|
||||
<%= if @theme_settings.header_background_enabled && @header_image do %>
|
||||
<div style={header_background_style(@theme_settings, @header_image)} />
|
||||
<% end %>
|
||||
|
||||
<%= if @theme_settings.header_layout == "centered" do %>
|
||||
<nav class="shop-nav hidden md:flex" style="gap: 1.5rem; position: relative; z-index: 1;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div class="shop-logo" style="display: flex; align-items: center; position: relative; z-index: 1;">
|
||||
<%= case @theme_settings.logo_mode do %>
|
||||
<% "text-only" -> %>
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
<%= @theme_settings.site_name %>
|
||||
</span>
|
||||
|
||||
<% "logo-text" -> %>
|
||||
<%= if @logo_image do %>
|
||||
<img
|
||||
src={logo_url(@logo_image, @theme_settings)}
|
||||
alt={@theme_settings.site_name}
|
||||
style={"height: #{@theme_settings.logo_size}px; width: auto; margin-right: 0.5rem;"}
|
||||
/>
|
||||
<% end %>
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
<%= @theme_settings.site_name %>
|
||||
</span>
|
||||
|
||||
<% "logo-only" -> %>
|
||||
<%= if @logo_image do %>
|
||||
<img
|
||||
src={logo_url(@logo_image, @theme_settings)}
|
||||
alt={@theme_settings.site_name}
|
||||
style={"height: #{@theme_settings.logo_size}px; width: auto;"}
|
||||
/>
|
||||
<% else %>
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
<%= @theme_settings.site_name %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% _ -> %>
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
<%= @theme_settings.site_name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @theme_settings.header_layout != "minimal" do %>
|
||||
<nav class={"shop-nav #{if @theme_settings.header_layout == "centered", do: "hidden md:flex", else: "hidden md:flex"}"} style="gap: 1.5rem; position: relative; z-index: 1;">
|
||||
<%= unless @theme_settings.header_layout == "centered" do %>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<% end %>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div class="shop-cart" style="position: relative; z-index: 1;">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
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
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-6 text-center" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-8" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="border-b" style="background-color: var(--t-surface-raised); border-color: var(--t-border-default);">
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-6 text-center" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);">
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<div class="flex items-center justify-center" style="min-height: calc(100vh - 4rem);">
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="relative" style="background-color: var(--t-surface-raised);">
|
||||
|
||||
@ -3,22 +3,7 @@
|
||||
%>
|
||||
<div class="min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
|
||||
<!-- Header -->
|
||||
<header class="shop-header" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="shop-logo">
|
||||
<span class="shop-logo-text" style="font-family: var(--t-font-heading); font-size: 1.25rem; font-weight: var(--t-heading-weight); color: var(--t-text-primary);">
|
||||
Store Name
|
||||
</span>
|
||||
</div>
|
||||
<nav class="shop-nav" style="gap: 1.5rem;">
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">About</a>
|
||||
<a href="#" style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
|
||||
</nav>
|
||||
<div class="shop-cart">
|
||||
<button style="color: var(--t-text-primary); background: none; border: none; cursor: pointer;">Cart (0)</button>
|
||||
</div>
|
||||
</header>
|
||||
<SimpleshopThemeWeb.ThemeLive.PreviewPages.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} />
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
|
||||
@ -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
|
||||
|
||||
3
mix.exs
3
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
|
||||
|
||||
|
||||
3
mix.lock
3
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"},
|
||||
}
|
||||
|
||||
144
test/simpleshop_theme/media/svg_recolorer_test.exs
Normal file
144
test/simpleshop_theme/media/svg_recolorer_test.exs
Normal file
@ -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(<svg><path fill="#000000" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ ~s(fill="#ff6600")
|
||||
refute result =~ "#000000"
|
||||
end
|
||||
|
||||
test "replaces stroke attributes" do
|
||||
svg = ~s(<svg><path stroke="#000000" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ ~s(stroke="#ff6600")
|
||||
refute result =~ "#000000"
|
||||
end
|
||||
|
||||
test "preserves fill=none" do
|
||||
svg = ~s(<svg><path fill="none" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ ~s(fill="none")
|
||||
end
|
||||
|
||||
test "preserves stroke=none" do
|
||||
svg = ~s(<svg><path stroke="none" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ ~s(stroke="none")
|
||||
end
|
||||
|
||||
test "replaces currentColor" do
|
||||
svg = ~s(<svg><path fill="currentColor" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ "#ff6600"
|
||||
refute result =~ "currentColor"
|
||||
end
|
||||
|
||||
test "handles multiple elements" do
|
||||
svg = ~s(<svg><circle fill="#000" r="10"/><rect fill="#fff" width="20"/></svg>)
|
||||
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(<svg><path fill="black" d="M0 0"/></svg>)
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ ~s(fill="#ff6600")
|
||||
refute result =~ "black"
|
||||
end
|
||||
|
||||
test "handles inline styles with fill" do
|
||||
svg = ~s(<svg><path style="fill: #000000; opacity: 1" d="M0 0"/></svg>)
|
||||
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(<svg><path fill="#000" stroke="#fff" d="M0 0"/></svg>)
|
||||
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 = """
|
||||
<svg><style>.st0{fill:#FFFFFF;}.st1{fill:#EF1D1D;stroke:#000000;}</style><path class="st0"/></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 = """
|
||||
<svg><style>.st1{stroke:#000000;stroke-miterlimit:10;}</style><path class="st1"/></svg>
|
||||
"""
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ "stroke:#ff6600"
|
||||
refute result =~ "#000000"
|
||||
end
|
||||
|
||||
test "preserves fill:none in CSS" do
|
||||
svg = """
|
||||
<svg><style>.st0{fill:none;stroke:#000;}</style><path class="st0"/></svg>
|
||||
"""
|
||||
result = SVGRecolorer.recolor(svg, "#ff6600")
|
||||
assert result =~ "fill:none"
|
||||
assert result =~ "stroke:#ff6600"
|
||||
end
|
||||
|
||||
test "handles CSS with color names" do
|
||||
svg = """
|
||||
<svg><style>.icon{fill:black;stroke:white;}</style><path class="icon"/></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
|
||||
124
test/simpleshop_theme_web/controllers/image_controller_test.exs
Normal file
124
test/simpleshop_theme_web/controllers/image_controller_test.exs
Normal file
@ -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(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>)
|
||||
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user