feat: add Theme LiveView with preset switching

Implement basic theme editor interface with live preview:
- ThemeLive.Index LiveView with mount and event handlers
- Two-column layout: controls sidebar + preview area
- Display all 9 presets as clickable buttons
- Apply preset and regenerate CSS on click
- Show current theme settings (mood, typography, shape, density, color)
- Preview page switcher (7 pages: home, collection, product, cart, about, contact, 404)
- Inline <style> tag with generated CSS for instant preview
- Basic preview frame showing theme variables in action
- Authentication required via :require_authenticated_user pipeline
- Theme navigation link added to user menu
- 9 comprehensive LiveView tests

All tests passing (197 total).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 21:53:52 +00:00
parent 41f488c2b6
commit da770f121f
5 changed files with 336 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
defmodule SimpleshopThemeWeb.ThemeLive.Index do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Theme.{CSSGenerator, Presets}
@impl true
def mount(_params, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:preview_page, :home)
|> assign(:preset_names, Presets.list_names())
{:ok, socket}
end
@impl true
def handle_event("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> put_flash(:info, "Applied #{preset_name} preset")
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to apply preset")}
end
end
@impl true
def handle_event("change_preview_page", %{"page" => page_name}, socket) do
page_atom = String.to_existing_atom(page_name)
{:noreply, assign(socket, :preview_page, page_atom)}
end
@impl true
def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully")
{:noreply, socket}
end
end

View File

@@ -0,0 +1,160 @@
<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">
<!-- 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>
<!-- Current Settings Display -->
<div class="divider"></div>
<div>
<h2 class="text-lg font-semibold mb-4">Current Settings</h2>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">Mood:</span>
<span class="capitalize"><%= @theme_settings.mood %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Typography:</span>
<span class="capitalize"><%= @theme_settings.typography %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Shape:</span>
<span class="capitalize"><%= @theme_settings.shape %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Density:</span>
<span class="capitalize"><%= @theme_settings.density %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Accent Color:</span>
<span>
<span
class="inline-block w-4 h-4 rounded-sm border border-base-300"
style={"background-color: #{@theme_settings.accent_color}"}
>
</span>
<%= @theme_settings.accent_color %>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Area -->
<div class="flex-1 p-6">
<div class="bg-base-100 rounded-lg shadow-xl 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>
</div>
<!-- Preview Frame -->
<div class="preview-frame bg-white" style="min-height: 600px;">
<style>
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
<div class="p-8 text-center">
<h2 class="text-2xl font-bold mb-4" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
Preview: <%= String.capitalize(to_string(@preview_page)) %>
</h2>
<div class="max-w-2xl mx-auto space-y-4">
<p style="color: var(--t-text-secondary);">
This is a preview of the <strong><%= @preview_page %></strong> page with your current theme settings.
</p>
<div class="flex gap-2 justify-center">
<button
class="px-4 py-2 rounded text-white font-medium"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); border-radius: var(--t-radius-button);"
>
Primary Button
</button>
<button
class="px-4 py-2 rounded font-medium"
style="border: 2px solid var(--t-border-default); border-radius: var(--t-radius-button); color: var(--t-text-primary); background-color: var(--t-surface-base);"
>
Secondary Button
</button>
</div>
<div
class="p-6 rounded shadow-sm"
style="background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); border: 1px solid var(--t-border-default);"
>
<h3
class="text-lg font-semibold mb-2"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Card Example
</h3>
<p style="font-family: var(--t-font-body); color: var(--t-text-secondary);">
This card demonstrates the current surface, border, and text colors with the selected shape style.
</p>
</div>
<div class="text-sm" style="color: var(--t-text-tertiary);">
Detailed preview pages coming in Phase 5
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>