diff --git a/PROGRESS.md b/PROGRESS.md index feb9d23..08bc40a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -89,8 +89,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme |---|---|---|---| | 1 | Add theme editing state to PageEditorHook | 2h | done | | 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | done | -| 3 | Extract theme editor into reusable component | 3h | planned | -| 3b | Create settings editor component | 2h | planned | +| 3 | Extract theme editor into reusable component | 3h | done | +| 3b | Create settings editor component | 2h | done | | 4 | Image upload handling in hook context | 2h | planned | | 5 | URL-based mode activation (?edit=theme) | 1h | planned | | 6 | Admin routing redirect | 30m | planned | diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 54f882b..e687f61 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -4600,6 +4600,72 @@ /* Last group in a customise section (no border, tighter margin than theme-group) */ .theme-group-flush { margin-bottom: 1rem; } +/* ── Settings editor (on-site editor Settings tab) ── */ + +.settings-editor-form { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.settings-slug-preview { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.settings-slug-preview .admin-input { + flex: 1; +} + +.settings-nav-options { + padding-left: 1rem; + border-left: 2px solid var(--admin-border); +} + +.settings-nav-row { + display: flex; + gap: 0.75rem; +} + +.settings-nav-field { + flex: 1; +} + +.settings-nav-field-sm { + flex: 0 0 5rem; +} + +.settings-actions { + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.5rem; +} + +.settings-save-indicator { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: var(--admin-success); +} + +.settings-save-error { + color: var(--admin-error); +} + +.settings-admin-link { + padding-top: 0.5rem; + border-top: 1px solid var(--admin-border); +} + +.settings-info-view { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + /* ── Content width containers ── */ .admin-content-narrow { max-width: 32rem; } diff --git a/lib/berrypod_web/components/shop_components.ex b/lib/berrypod_web/components/shop_components.ex index 4215125..f9fa0d2 100644 --- a/lib/berrypod_web/components/shop_components.ex +++ b/lib/berrypod_web/components/shop_components.ex @@ -10,6 +10,7 @@ defmodule BerrypodWeb.ShopComponents do - `Product` — product cards, gallery, variant selector, hero sections - `Content` — rich text, responsive images, contact form, reviews - `ThemeEditor` — shared theme editor components for admin and on-site editing + - `SettingsEditor` — shared settings editor components for on-site editing """ defmacro __using__(_opts \\ []) do @@ -19,6 +20,7 @@ defmodule BerrypodWeb.ShopComponents do import BerrypodWeb.ShopComponents.Content import BerrypodWeb.ShopComponents.Layout import BerrypodWeb.ShopComponents.Product + import BerrypodWeb.ShopComponents.SettingsEditor import BerrypodWeb.ShopComponents.ThemeEditor end end diff --git a/lib/berrypod_web/components/shop_components/settings_editor.ex b/lib/berrypod_web/components/shop_components/settings_editor.ex new file mode 100644 index 0000000..e8a7f01 --- /dev/null +++ b/lib/berrypod_web/components/shop_components/settings_editor.ex @@ -0,0 +1,340 @@ +defmodule BerrypodWeb.ShopComponents.SettingsEditor do + @moduledoc """ + Shared settings editor components used in the on-site editor panel. + + Shows context-specific settings based on the current page type: + - Custom CMS pages: Title, slug, visibility, SEO meta, navigation + - System pages (home, about, etc.): Page info only (editing done in admin) + - Product pages: Read-only product info, link to admin + - Collection pages: Read-only collection info, link to admin + + Components emit events with a configurable prefix: + - `settings_update_page` (phx-change/phx-submit) + + The event prefix is controlled by `@event_prefix`: + - `"settings_"` (default) for on-site editor context + """ + + use Phoenix.Component + + import BerrypodWeb.CoreComponents, only: [icon: 1] + + # ── Main Editor Component ──────────────────────────────────────────── + + @doc """ + Renders the settings editor panel. + + Content varies based on the page type (custom, system, product, collection). + """ + attr :page, :map, default: nil + attr :product, :map, default: nil + attr :collection_title, :string, default: nil + attr :live_action, :atom, default: nil + attr :settings_form, :any, default: nil + attr :settings_dirty, :boolean, default: false + attr :settings_save_status, :atom, default: :idle + attr :event_prefix, :string, default: "settings_" + + def settings_editor(assigns) do + ~H""" +
+ <%= cond do %> + <% @live_action == :product and @product -> %> + <.product_settings product={@product} /> + <% @live_action == :collection and @collection_title -> %> + <.collection_settings collection_title={@collection_title} /> + <% @page && @page[:type] == "custom" -> %> + <.custom_page_settings + page={@page} + form={@settings_form} + dirty={@settings_dirty} + save_status={@settings_save_status} + event_prefix={@event_prefix} + /> + <% @page -> %> + <.system_page_settings page={@page} /> + <% true -> %> + <.no_settings_view /> + <% end %> +
+ """ + end + + # ── Custom Page Settings ───────────────────────────────────────────── + + attr :page, :map, required: true + attr :form, :any, default: nil + attr :dirty, :boolean, default: false + attr :save_status, :atom, default: :idle + attr :event_prefix, :string, default: "settings_" + + defp custom_page_settings(assigns) do + # Use form values if available, otherwise fall back to page values + form = assigns.form || %{} + + assigns = + assigns + |> assign(:form_title, form["title"] || assigns.page.title || "") + |> assign(:form_slug, form["slug"] || assigns.page.slug || "") + |> assign(:form_meta, form["meta_description"] || assigns.page.meta_description || "") + |> assign(:form_published, form_checked?(form, "published", assigns.page.published)) + |> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", assigns.page.show_in_nav)) + |> assign(:form_nav_label, form["nav_label"] || assigns.page.nav_label || "") + |> assign( + :form_nav_position, + form["nav_position"] || to_string(assigns.page.nav_position || 0) + ) + + ~H""" +
+
"validate_page"} + phx-submit={@event_prefix <> "save_page"} + > +
+ + +
+ +
+ +
+ / + +
+
+ +
+ + +

Shown in search results. Keep under 160 characters.

+
+ +
+ +

Unpublished pages are only visible to admins.

+
+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ + <.save_status_indicator status={@save_status} /> +
+
+ + +
+ """ + end + + # ── System Page Settings ───────────────────────────────────────────── + + attr :page, :map, required: true + + defp system_page_settings(assigns) do + ~H""" +
+
+ +

{@page.title}

+
+ +
+ +

System page

+
+ +
+

+ This is a built-in page. Edit its content using the Page tab, + or manage pages in admin. +

+
+
+ """ + end + + # ── Product Page Settings ──────────────────────────────────────────── + + attr :product, :map, required: true + + defp product_settings(assigns) do + ~H""" +
+
+ +

{@product.title}

+
+ +
+ +

{provider_label(@product)}

+
+ +
+

+ Product details are synced from your print provider. + View in admin + to see pricing, variants, and images. +

+
+
+ """ + end + + defp provider_label(product) do + case product.provider_type do + "printify" -> "Printify" + "printful" -> "Printful" + _ -> "Unknown" + end + end + + # ── Collection Page Settings ───────────────────────────────────────── + + attr :collection_title, :string, required: true + + defp collection_settings(assigns) do + ~H""" +
+
+ +

{@collection_title}

+
+ +
+

+ Collection pages show products filtered by category. + Edit the page layout using the Page tab. +

+
+
+ """ + end + + # ── No Settings View ───────────────────────────────────────────────── + + defp no_settings_view(assigns) do + ~H""" +
+
+

+ This page doesn't have editable settings. + Shop settings + can be changed in admin. +

+
+
+ """ + end + + # ── Helper Functions ───────────────────────────────────────────────── + + # Check if a form checkbox should be checked + # Handles both form params (strings) and page values (booleans) + defp form_checked?(form, key, page_value) when is_map(form) do + case form[key] do + "true" -> true + "false" -> false + nil -> page_value == true + _ -> page_value == true + end + end + + # ── Helper Components ──────────────────────────────────────────────── + + attr :status, :atom, required: true + + defp save_status_indicator(assigns) do + ~H""" + + <.icon name="hero-check-mini" class="size-4" /> Saved + + + <.icon name="hero-exclamation-circle-mini" class="size-4" /> Error + + """ + end +end diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index ab37712..21964c9 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -61,6 +61,9 @@ defmodule BerrypodWeb.PageEditorHook do |> assign(:theme_editor_contrast_warning, :ok) |> assign(:theme_editor_customise_open, false) |> assign(:theme_editor_presets, Presets.all_with_descriptions()) + # Settings editing state + |> assign(:settings_dirty, false) + |> assign(:settings_save_status, :idle) |> attach_hook(:editor_params, :handle_params, &handle_editor_params/3) |> attach_hook(:editor_events, :handle_event, &handle_editor_event/3) |> attach_hook(:editor_info, :handle_info, &handle_editor_info/2) @@ -212,6 +215,16 @@ defmodule BerrypodWeb.PageEditorHook do load_theme_state(socket) end + # Initialize settings form from current page if not already set + # Only custom pages have editable settings (meta_description, published, etc.) + socket = + if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] && + socket.assigns.page[:type] == "custom" do + init_settings_form(socket) + else + socket + end + assign(socket, :editor_active_tab, :settings) end @@ -251,6 +264,15 @@ defmodule BerrypodWeb.PageEditorHook do end end + # Settings editing events (custom page settings) + defp handle_editor_event("settings_" <> action, params, socket) do + if socket.assigns.is_admin do + handle_settings_action(action, params, socket) + else + {:cont, socket} + end + end + defp handle_editor_event(_event, _params, socket), do: {:cont, socket} # ── Block manipulation actions ─────────────────────────────────── @@ -721,6 +743,106 @@ defmodule BerrypodWeb.PageEditorHook do # Catch-all for unknown theme actions defp handle_theme_action(_action, _params, socket), do: {:halt, socket} + # ── Settings editing actions (custom page settings) ──────────────── + + # Validate page settings form (live as-you-type) + defp handle_settings_action("validate_page", %{"page" => params}, socket) do + page = socket.assigns.page + + # Only allow editing custom pages + if page && page.type == "custom" do + socket = + socket + |> assign(:settings_form, params) + |> assign(:settings_dirty, has_settings_changed?(page, params)) + |> assign(:settings_save_status, :idle) + + {:halt, socket} + else + {:halt, socket} + end + end + + # Save page settings + defp handle_settings_action("save_page", %{"page" => params}, socket) do + page = socket.assigns.page + + # Only allow editing custom pages + if page && page.type == "custom" do + # Normalize checkbox fields (unchecked checkboxes aren't sent) + params = + params + |> Map.put_new("published", "false") + |> Map.put_new("show_in_nav", "false") + + old_slug = page.slug + + # Fetch the Page struct from DB (assigns.page may be a map from cache) + page_struct = Pages.get_page_struct(page.slug) + + case Pages.update_custom_page(page_struct, params) do + {:ok, updated_page} -> + # Reinitialize form from saved page + socket = + socket + |> assign(:page, updated_page) + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + |> assign(:settings_save_status, :saved) + + # Reinit form with new page values + socket = init_settings_form(socket) + + # If slug changed, redirect to new URL + socket = + if updated_page.slug != old_slug do + push_navigate(socket, to: "/#{updated_page.slug}") + else + socket + end + + {:halt, socket} + + {:error, _changeset} -> + socket = assign(socket, :settings_save_status, :error) + {:halt, socket} + end + else + {:halt, socket} + end + end + + # Catch-all for unknown settings actions + defp handle_settings_action(_action, _params, socket), do: {:halt, socket} + + # Check if settings have changed from current page values + defp has_settings_changed?(page, params) do + page.title != (params["title"] || "") or + page.slug != (params["slug"] || "") or + (page.meta_description || "") != (params["meta_description"] || "") or + to_string(page.published) != (params["published"] || "false") or + to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or + (page.nav_label || "") != (params["nav_label"] || "") or + to_string(page.nav_position || 0) != (params["nav_position"] || "0") + end + + # Initialize settings form from page values + defp init_settings_form(socket) do + page = socket.assigns.page + + form = %{ + "title" => page.title || "", + "slug" => page.slug || "", + "meta_description" => page.meta_description || "", + "published" => to_string(page.published), + "show_in_nav" => to_string(page.show_in_nav), + "nav_label" => page.nav_label || "", + "nav_position" => to_string(page.nav_position || 0) + } + + assign(socket, :settings_form, form) + end + # Helper to update a theme setting and regenerate CSS defp update_theme_setting(socket, attrs, field) do case Settings.update_theme_settings(attrs) do diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index 7d19c6d..ba172fe 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -112,6 +112,12 @@ defmodule BerrypodWeb.PageRenderer do theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])} theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)} site_name={Map.get(assigns, :site_name, "")} + product={assigns[:product]} + collection_title={assigns[:collection_title]} + live_action={assigns[:live_action]} + settings_form={Map.get(assigns, :settings_form)} + settings_dirty={Map.get(assigns, :settings_dirty, false)} + settings_save_status={Map.get(assigns, :settings_save_status, :idle)} /> """ @@ -138,6 +144,12 @@ defmodule BerrypodWeb.PageRenderer do attr :theme_editor_presets, :list, default: [] attr :theme_editor_customise_open, :boolean, default: false attr :site_name, :string, default: "" + attr :product, :map, default: nil + attr :collection_title, :string, default: nil + attr :live_action, :atom, default: nil + attr :settings_form, :map, default: nil + attr :settings_dirty, :boolean, default: false + attr :settings_save_status, :atom, default: :idle defp editor_panel_content(%{editor_active_tab: :page} = assigns) do ~H""" @@ -174,7 +186,15 @@ defmodule BerrypodWeb.PageRenderer do defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do ~H""" - <.settings_editor_content page={@page} site_name={@site_name} /> + """ end @@ -198,37 +218,6 @@ defmodule BerrypodWeb.PageRenderer do """ end - # Settings editor content - shows page/shop settings - attr :page, :map, default: nil - attr :site_name, :string, default: "" - - defp settings_editor_content(assigns) do - ~H""" -
- <%= if @page do %> -
- -

{@page.title}

-
-
-

- Page settings like SEO, visibility, and slug editing coming soon. - For now, manage pages in admin. -

-
- <% else %> -
-

- This page doesn't have editable settings. - Shop settings - can be changed in admin. -

-
- <% end %> -
- """ - end - # Editor sheet content - the block list and editing controls attr :page, :map, default: nil attr :editing_blocks, :list, default: nil