feat: add Settings and Media contexts with theme settings schema
- Create settings table for site-wide key-value configuration - Create images table for BLOB storage of logo/header images - Add Setting schema with JSON/string/integer/boolean support - Add ThemeSettings embedded schema with all theme options - Add Settings context with get/put/update operations - Add Media context for image uploads and retrieval - Add Image schema with SVG detection and storage - Add 9 curated theme presets (gallery, studio, boutique, etc.) - Add comprehensive tests for Settings and Media contexts - Add seeds with default Studio preset - All tests passing (29 tests, 0 failures)
This commit is contained in:
92
lib/simpleshop_theme/media.ex
Normal file
92
lib/simpleshop_theme/media.ex
Normal file
@@ -0,0 +1,92 @@
|
||||
defmodule SimpleshopTheme.Media do
|
||||
@moduledoc """
|
||||
The Media context for managing images and file uploads.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias SimpleshopTheme.Repo
|
||||
alias SimpleshopTheme.Media.Image
|
||||
|
||||
@doc """
|
||||
Uploads an image and stores it in the database.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> upload_image(%{image_type: "logo", filename: "logo.png", ...})
|
||||
{:ok, %Image{}}
|
||||
|
||||
"""
|
||||
def upload_image(attrs) do
|
||||
%Image{}
|
||||
|> Image.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single image by ID.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_image(id)
|
||||
%Image{}
|
||||
|
||||
iex> get_image("nonexistent")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_image(id) do
|
||||
Repo.get(Image, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current logo image.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_logo()
|
||||
%Image{}
|
||||
|
||||
"""
|
||||
def get_logo do
|
||||
Repo.one(from i in Image, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current header image.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_header()
|
||||
%Image{}
|
||||
|
||||
"""
|
||||
def get_header do
|
||||
Repo.one(from i in Image, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes an image.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_image(image)
|
||||
{:ok, %Image{}}
|
||||
|
||||
"""
|
||||
def delete_image(%Image{} = image) do
|
||||
Repo.delete(image)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all images of a specific type.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_images_by_type("logo")
|
||||
[%Image{}, ...]
|
||||
|
||||
"""
|
||||
def list_images_by_type(type) do
|
||||
Repo.all(from i in Image, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
|
||||
end
|
||||
end
|
||||
54
lib/simpleshop_theme/media/image.ex
Normal file
54
lib/simpleshop_theme/media/image.ex
Normal file
@@ -0,0 +1,54 @@
|
||||
defmodule SimpleshopTheme.Media.Image do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "images" do
|
||||
field :image_type, :string
|
||||
field :filename, :string
|
||||
field :content_type, :string
|
||||
field :file_size, :integer
|
||||
field :data, :binary
|
||||
field :is_svg, :boolean, default: false
|
||||
field :svg_content, :string
|
||||
field :thumbnail_data, :binary
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@max_file_size 5_000_000
|
||||
|
||||
@doc false
|
||||
def changeset(image, attrs) do
|
||||
image
|
||||
|> cast(attrs, [:image_type, :filename, :content_type, :file_size, :data, :is_svg, :svg_content])
|
||||
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
||||
|> validate_inclusion(:image_type, ~w(logo header product))
|
||||
|> validate_number(:file_size, less_than: @max_file_size)
|
||||
|> detect_svg()
|
||||
end
|
||||
|
||||
defp detect_svg(changeset) do
|
||||
content_type = get_change(changeset, :content_type)
|
||||
|
||||
if content_type == "image/svg+xml" or String.ends_with?(get_change(changeset, :filename) || "", ".svg") do
|
||||
changeset
|
||||
|> put_change(:is_svg, true)
|
||||
|> maybe_store_svg_content()
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_svg_content(changeset) do
|
||||
case get_change(changeset, :data) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
svg_binary when is_binary(svg_binary) ->
|
||||
put_change(changeset, :svg_content, svg_binary)
|
||||
end
|
||||
end
|
||||
end
|
||||
133
lib/simpleshop_theme/settings.ex
Normal file
133
lib/simpleshop_theme/settings.ex
Normal file
@@ -0,0 +1,133 @@
|
||||
defmodule SimpleshopTheme.Settings do
|
||||
@moduledoc """
|
||||
The Settings context for managing site-wide configuration.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias SimpleshopTheme.Repo
|
||||
alias SimpleshopTheme.Settings.{Setting, ThemeSettings}
|
||||
|
||||
@doc """
|
||||
Gets a setting by key with an optional default value.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_setting("site_name", "My Shop")
|
||||
"My Shop"
|
||||
|
||||
"""
|
||||
def get_setting(key, default \\ nil) do
|
||||
case Repo.get_by(Setting, key: key) do
|
||||
nil -> default
|
||||
setting -> decode_value(setting)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a setting value by key.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> put_setting("site_name", "My Awesome Shop")
|
||||
{:ok, %Setting{}}
|
||||
|
||||
"""
|
||||
def put_setting(key, value, value_type \\ "string") do
|
||||
encoded_value = encode_value(value, value_type)
|
||||
|
||||
%Setting{key: key}
|
||||
|> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type})
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace, [:value, :value_type, :updated_at]},
|
||||
conflict_target: :key
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the theme settings as a ThemeSettings struct.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_theme_settings()
|
||||
%ThemeSettings{mood: "neutral", typography: "clean", ...}
|
||||
|
||||
"""
|
||||
def get_theme_settings do
|
||||
case get_setting("theme_settings") do
|
||||
nil ->
|
||||
# Return defaults
|
||||
%ThemeSettings{}
|
||||
|
||||
settings_map when is_map(settings_map) ->
|
||||
settings_map
|
||||
|> atomize_keys()
|
||||
|> then(&struct(ThemeSettings, &1))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the theme settings.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_theme_settings(%{mood: "dark", typography: "modern"})
|
||||
{:ok, %ThemeSettings{}}
|
||||
|
||||
"""
|
||||
def update_theme_settings(attrs) when is_map(attrs) do
|
||||
current = get_theme_settings()
|
||||
|
||||
changeset = ThemeSettings.changeset(current, attrs)
|
||||
|
||||
if changeset.valid? do
|
||||
settings = Ecto.Changeset.apply_changes(changeset)
|
||||
json = Jason.encode!(settings)
|
||||
put_setting("theme_settings", json, "json")
|
||||
{:ok, settings}
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies a preset to theme settings.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> apply_preset(:gallery)
|
||||
{:ok, %ThemeSettings{}}
|
||||
|
||||
"""
|
||||
def apply_preset(preset_name) when is_atom(preset_name) do
|
||||
preset = SimpleshopTheme.Theme.Presets.get(preset_name)
|
||||
|
||||
if preset do
|
||||
update_theme_settings(preset)
|
||||
else
|
||||
{:error, :preset_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value)
|
||||
defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)
|
||||
|
||||
defp decode_value(%Setting{value: value, value_type: "boolean"}),
|
||||
do: value == "true"
|
||||
|
||||
defp decode_value(%Setting{value: value, value_type: "string"}), do: value
|
||||
|
||||
defp encode_value(value, "json") when is_binary(value), do: value
|
||||
defp encode_value(value, "json"), do: Jason.encode!(value)
|
||||
defp encode_value(value, "integer") when is_integer(value), do: Integer.to_string(value)
|
||||
defp encode_value(value, "boolean") when is_boolean(value), do: Atom.to_string(value)
|
||||
defp encode_value(value, "string") when is_binary(value), do: value
|
||||
|
||||
defp atomize_keys(map) when is_map(map) do
|
||||
Map.new(map, fn
|
||||
{key, value} when is_binary(key) -> {String.to_atom(key), value}
|
||||
{key, value} -> {key, value}
|
||||
end)
|
||||
end
|
||||
end
|
||||
24
lib/simpleshop_theme/settings/setting.ex
Normal file
24
lib/simpleshop_theme/settings/setting.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule SimpleshopTheme.Settings.Setting do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "settings" do
|
||||
field :key, :string
|
||||
field :value, :string
|
||||
field :value_type, :string, default: "string"
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(setting, attrs) do
|
||||
setting
|
||||
|> cast(attrs, [:key, :value, :value_type])
|
||||
|> validate_required([:key, :value, :value_type])
|
||||
|> validate_inclusion(:value_type, ~w(string json integer boolean))
|
||||
|> unique_constraint(:key)
|
||||
end
|
||||
end
|
||||
101
lib/simpleshop_theme/settings/theme_settings.ex
Normal file
101
lib/simpleshop_theme/settings/theme_settings.ex
Normal file
@@ -0,0 +1,101 @@
|
||||
defmodule SimpleshopTheme.Settings.ThemeSettings do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@derive Jason.Encoder
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
# Core theme tokens
|
||||
field :mood, :string, default: "neutral"
|
||||
field :typography, :string, default: "clean"
|
||||
field :shape, :string, default: "soft"
|
||||
field :density, :string, default: "balanced"
|
||||
field :grid_columns, :string, default: "4"
|
||||
field :header_layout, :string, default: "standard"
|
||||
field :accent_color, :string, default: "#f97316"
|
||||
|
||||
# Branding
|
||||
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"
|
||||
field :header_zoom, :integer, default: 100
|
||||
field :header_position_x, :integer, default: 50
|
||||
field :header_position_y, :integer, default: 50
|
||||
|
||||
# Advanced customization
|
||||
field :secondary_accent_color, :string, default: "#ea580c"
|
||||
field :sale_color, :string, default: "#dc2626"
|
||||
field :font_size, :string, default: "medium"
|
||||
field :heading_weight, :string, default: "bold"
|
||||
field :layout_width, :string, default: "wide"
|
||||
field :button_style, :string, default: "filled"
|
||||
field :card_shadow, :string, default: "none"
|
||||
field :product_text_align, :string, default: "left"
|
||||
field :image_aspect_ratio, :string, default: "square"
|
||||
|
||||
# Feature toggles
|
||||
field :announcement_bar, :boolean, default: true
|
||||
field :sticky_header, :boolean, default: false
|
||||
field :hover_image, :boolean, default: true
|
||||
field :quick_add, :boolean, default: true
|
||||
field :show_prices, :boolean, default: true
|
||||
field :pdp_trust_badges, :boolean, default: true
|
||||
field :pdp_reviews, :boolean, default: true
|
||||
field :pdp_related_products, :boolean, default: true
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(settings, attrs) do
|
||||
settings
|
||||
|> cast(attrs, [
|
||||
:mood,
|
||||
:typography,
|
||||
:shape,
|
||||
:density,
|
||||
:grid_columns,
|
||||
:header_layout,
|
||||
:accent_color,
|
||||
:logo_mode,
|
||||
:logo_image_id,
|
||||
:header_image_id,
|
||||
:logo_size,
|
||||
:logo_recolor,
|
||||
:logo_color,
|
||||
:header_zoom,
|
||||
:header_position_x,
|
||||
:header_position_y,
|
||||
:secondary_accent_color,
|
||||
:sale_color,
|
||||
:font_size,
|
||||
:heading_weight,
|
||||
:layout_width,
|
||||
:button_style,
|
||||
:card_shadow,
|
||||
:product_text_align,
|
||||
:image_aspect_ratio,
|
||||
:announcement_bar,
|
||||
:sticky_header,
|
||||
:hover_image,
|
||||
:quick_add,
|
||||
:show_prices,
|
||||
:pdp_trust_badges,
|
||||
:pdp_reviews,
|
||||
:pdp_related_products
|
||||
])
|
||||
|> validate_required([:mood, :typography, :shape, :density])
|
||||
|> validate_inclusion(:mood, ~w(neutral warm cool dark))
|
||||
|> validate_inclusion(:typography, ~w(clean editorial modern classic friendly minimal impulse))
|
||||
|> validate_inclusion(:shape, ~w(sharp soft round pill))
|
||||
|> 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_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)
|
||||
|> validate_number(:header_position_y, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|
||||
end
|
||||
end
|
||||
135
lib/simpleshop_theme/theme/presets.ex
Normal file
135
lib/simpleshop_theme/theme/presets.ex
Normal file
@@ -0,0 +1,135 @@
|
||||
defmodule SimpleshopTheme.Theme.Presets do
|
||||
@moduledoc """
|
||||
Defines the 9 curated theme presets for SimpleShop.
|
||||
"""
|
||||
|
||||
@presets %{
|
||||
gallery: %{
|
||||
mood: "warm",
|
||||
typography: "editorial",
|
||||
shape: "soft",
|
||||
density: "spacious",
|
||||
grid_columns: "3",
|
||||
header_layout: "centered",
|
||||
accent_color: "#e85d04"
|
||||
},
|
||||
studio: %{
|
||||
mood: "neutral",
|
||||
typography: "clean",
|
||||
shape: "soft",
|
||||
density: "balanced",
|
||||
grid_columns: "4",
|
||||
header_layout: "standard",
|
||||
accent_color: "#3b82f6"
|
||||
},
|
||||
boutique: %{
|
||||
mood: "warm",
|
||||
typography: "classic",
|
||||
shape: "soft",
|
||||
density: "balanced",
|
||||
grid_columns: "3",
|
||||
header_layout: "centered",
|
||||
accent_color: "#b45309"
|
||||
},
|
||||
bold: %{
|
||||
mood: "neutral",
|
||||
typography: "modern",
|
||||
shape: "sharp",
|
||||
density: "compact",
|
||||
grid_columns: "4",
|
||||
header_layout: "standard",
|
||||
accent_color: "#dc2626"
|
||||
},
|
||||
playful: %{
|
||||
mood: "neutral",
|
||||
typography: "friendly",
|
||||
shape: "pill",
|
||||
density: "balanced",
|
||||
grid_columns: "4",
|
||||
header_layout: "standard",
|
||||
accent_color: "#8b5cf6"
|
||||
},
|
||||
minimal: %{
|
||||
mood: "cool",
|
||||
typography: "minimal",
|
||||
shape: "sharp",
|
||||
density: "spacious",
|
||||
grid_columns: "2",
|
||||
header_layout: "minimal",
|
||||
accent_color: "#171717"
|
||||
},
|
||||
night: %{
|
||||
mood: "dark",
|
||||
typography: "modern",
|
||||
shape: "soft",
|
||||
density: "balanced",
|
||||
grid_columns: "4",
|
||||
header_layout: "standard",
|
||||
accent_color: "#f97316"
|
||||
},
|
||||
classic: %{
|
||||
mood: "warm",
|
||||
typography: "classic",
|
||||
shape: "soft",
|
||||
density: "spacious",
|
||||
grid_columns: "3",
|
||||
header_layout: "standard",
|
||||
accent_color: "#166534"
|
||||
},
|
||||
impulse: %{
|
||||
mood: "neutral",
|
||||
typography: "impulse",
|
||||
shape: "sharp",
|
||||
density: "spacious",
|
||||
grid_columns: "3",
|
||||
header_layout: "centered",
|
||||
accent_color: "#000000",
|
||||
font_size: "medium",
|
||||
heading_weight: "regular",
|
||||
layout_width: "full",
|
||||
button_style: "filled",
|
||||
card_shadow: "none",
|
||||
product_text_align: "center"
|
||||
}
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns all available presets.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> all()
|
||||
%{gallery: %{...}, studio: %{...}, ...}
|
||||
|
||||
"""
|
||||
def all, do: @presets
|
||||
|
||||
@doc """
|
||||
Gets a preset by name.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get(:gallery)
|
||||
%{mood: "warm", typography: "editorial", ...}
|
||||
|
||||
iex> get(:nonexistent)
|
||||
nil
|
||||
|
||||
"""
|
||||
def get(preset_name) when is_atom(preset_name) do
|
||||
Map.get(@presets, preset_name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all preset names.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_names()
|
||||
[:gallery, :studio, :boutique, :bold, :playful, :minimal, :night, :classic, :impulse]
|
||||
|
||||
"""
|
||||
def list_names do
|
||||
Map.keys(@presets)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user