From 378b3fdb6b345543b25bb9115a15c577f1a7b184 Mon Sep 17 00:00:00 2001 From: jamey Date: Mon, 9 Mar 2026 19:45:43 +0000 Subject: [PATCH] add image uploads to on-site theme editor and fix scroll on navigation Phase 4 of unified editing: image upload handling in hook context. - Configure uploads in Shop.Page mount for logo, header, icon - Add upload UI components to theme_editor compact_editor - Pass uploads through page_renderer to theme editor - Add cancel_upload handler to PageEditorHook Also fixes scroll position not resetting on patch navigation: - Push scroll-top event when path changes in handle_params - JS listener scrolls window to top instantly Co-Authored-By: Claude Opus 4.5 --- PROGRESS.md | 2 +- assets/js/app.js | 5 + .../shop_components/theme_editor.ex | 351 +++++++++++++++++- lib/berrypod_web/live/shop/page.ex | 159 +++++++- lib/berrypod_web/page_editor_hook.ex | 9 + lib/berrypod_web/page_renderer.ex | 25 ++ 6 files changed, 543 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 08bc40a..9d994fc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -91,7 +91,7 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme | 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | done | | 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 | +| 4 | Image upload handling in hook context | 2h | done | | 5 | URL-based mode activation (?edit=theme) | 1h | planned | | 6 | Admin routing redirect | 30m | planned | | 7 | Polish and testing | 2h | planned | diff --git a/assets/js/app.js b/assets/js/app.js index 1ae8f07..87b535b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -951,6 +951,11 @@ window.addEventListener("phx:scroll-preview-top", (e) => { } }) +// Scroll to top on page navigation (patch navigation within LiveView) +window.addEventListener("phx:scroll-top", () => { + window.scrollTo({top: 0, behavior: 'instant'}) +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/lib/berrypod_web/components/shop_components/theme_editor.ex b/lib/berrypod_web/components/shop_components/theme_editor.ex index be39a8a..769981c 100644 --- a/lib/berrypod_web/components/shop_components/theme_editor.ex +++ b/lib/berrypod_web/components/shop_components/theme_editor.ex @@ -203,12 +203,29 @@ defmodule BerrypodWeb.ShopComponents.ThemeEditor do attr :site_name, :string, default: "" attr :customise_open, :boolean, default: false attr :event_prefix, :string, default: "theme_" + attr :uploads, :map, default: nil + attr :logo_image, :map, default: nil + attr :header_image, :map, default: nil + attr :icon_image, :map, default: nil + attr :contrast_warning, :atom, default: :ok def compact_editor(assigns) do ~H"""
<%= if @theme_settings do %> <.shop_name_input site_name={@site_name} event_prefix={@event_prefix} /> + + <.branding_section + theme_settings={@theme_settings} + uploads={@uploads} + logo_image={@logo_image} + header_image={@header_image} + icon_image={@icon_image} + contrast_warning={@contrast_warning} + site_name={@site_name} + event_prefix={@event_prefix} + /> + <.preset_grid presets={@presets} active_preset={@active_preset} event_prefix={@event_prefix} /> <.color_picker @@ -235,12 +252,6 @@ defmodule BerrypodWeb.ShopComponents.ThemeEditor do customise_open={@customise_open} event_prefix={@event_prefix} /> - -
-

- For logo and header image uploads, visit the full theme editor. -

-
<% else %>

Loading theme settings...

<% end %> @@ -248,6 +259,334 @@ defmodule BerrypodWeb.ShopComponents.ThemeEditor do """ end + # ── Branding Section (Logo, Header, Icon) ─────────────────────────── + + attr :theme_settings, :map, required: true + attr :uploads, :map, default: nil + attr :logo_image, :map, default: nil + attr :header_image, :map, default: nil + attr :icon_image, :map, default: nil + attr :contrast_warning, :atom, default: :ok + attr :site_name, :string, default: "" + attr :event_prefix, :string, default: "theme_" + + defp branding_section(assigns) do + ~H""" +
+ + +
+ + + +
+ + <%= if @theme_settings.show_logo && @uploads do %> + <.logo_upload_section + uploads={@uploads} + logo_image={@logo_image} + theme_settings={@theme_settings} + site_name={@site_name} + event_prefix={@event_prefix} + /> + <% end %> + + + + <%= if @theme_settings.header_background_enabled && @uploads do %> + <.header_upload_section + uploads={@uploads} + header_image={@header_image} + theme_settings={@theme_settings} + contrast_warning={@contrast_warning} + event_prefix={@event_prefix} + /> + <% end %> +
+ """ + end + + # Logo upload sub-section + attr :uploads, :map, required: true + attr :logo_image, :map, default: nil + attr :theme_settings, :map, required: true + attr :site_name, :string, default: "" + attr :event_prefix, :string, default: "theme_" + + defp logo_upload_section(assigns) do + ~H""" +
+ Upload logo (SVG or PNG) +
+
+ +
+ <%= if @logo_image do %> + + <% end %> +
+ + <%= for entry <- @uploads.theme_logo_upload.entries do %> + <.upload_progress entry={entry} upload_name="theme_logo_upload" /> + <%= for err <- upload_errors(@uploads.theme_logo_upload, entry) do %> +

{error_to_string(err)}

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

{error_to_string(err)}

+ <% end %> + + <%= if @logo_image do %> +
"update_setting"} + phx-value-field="logo_size" + class="theme-subfield" + > +
+ Logo size + {@theme_settings.logo_size}px +
+ +
+ + <%= if @logo_image.is_svg do %> +
+ + + <%= if @theme_settings.logo_recolor do %> +
"update_color"} + phx-value-field="logo_color" + phx-hook="ColorSync" + class="theme-color-row theme-subfield-sm" + > + + {@theme_settings.logo_color} +
+ <% end %> +
+ <% end %> + <% end %> +
+ """ + end + + # Header upload sub-section + attr :uploads, :map, required: true + attr :header_image, :map, default: nil + attr :theme_settings, :map, required: true + attr :contrast_warning, :atom, default: :ok + attr :event_prefix, :string, default: "theme_" + + defp header_upload_section(assigns) do + ~H""" +
+ Upload header image +
+ +
+ + <%= if @header_image do %> +
+ + +
+ + <%= if @contrast_warning != :ok do %> +
+ + <%= if @contrast_warning == :poor do %> + Text may be hard to read + <% else %> + Text contrast could be better + <% end %> + +

+ Try switching to a + <%= if @theme_settings.mood == "dark" do %> + lighter mood + <% else %> + dark mood + <% end %> + or choosing a different image. +

+
+ <% end %> + +
+
"update_setting"} phx-value-field="header_zoom"> +
+ Zoom + {@theme_settings.header_zoom}% +
+ +
+ <%= if @theme_settings.header_zoom > 100 do %> +
"update_setting"} + phx-value-field="header_position_x" + > +
+ Horizontal position + {@theme_settings.header_position_x}% +
+ +
+
"update_setting"} + phx-value-field="header_position_y" + > +
+ Vertical position + {@theme_settings.header_position_y}% +
+ +
+ <% end %> +
+ <% end %> + + <%= for entry <- @uploads.theme_header_upload.entries do %> + <.upload_progress entry={entry} upload_name="theme_header_upload" /> + <%= for err <- upload_errors(@uploads.theme_header_upload, entry) do %> +

{error_to_string(err)}

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

{error_to_string(err)}

+ <% end %> +
+ """ + end + + # Shared upload progress component + attr :entry, :map, required: true + attr :upload_name, :string, required: true + + defp upload_progress(assigns) do + ~H""" +
+
+
+
+ {@entry.progress}% + +
+ """ + end + + defp error_to_string(:too_large), do: "File is too large" + defp error_to_string(:too_many_files), do: "Too many files" + defp error_to_string(:not_accepted), do: "File type not accepted" + defp error_to_string(err), do: inspect(err) + # ── Full Customise Accordion ─────────────────────────────────────── # Advanced settings groups for admin theme page. diff --git a/lib/berrypod_web/live/shop/page.ex b/lib/berrypod_web/live/shop/page.ex index f2e2579..05e719f 100644 --- a/lib/berrypod_web/live/shop/page.ex +++ b/lib/berrypod_web/live/shop/page.ex @@ -9,6 +9,8 @@ defmodule BerrypodWeb.Shop.Page do use BerrypodWeb, :live_view alias BerrypodWeb.Shop.Pages + alias Berrypod.{Media, Settings} + alias Berrypod.Workers.FaviconGeneratorWorker # Map live_action atoms to page handler modules @page_modules %{ @@ -35,14 +37,159 @@ defmodule BerrypodWeb.Shop.Page do @impl true def mount(_params, session, socket) do # Store session for pages that need it (orders, order_detail) - {:ok, assign(socket, :_session, session)} + socket = assign(socket, :_session, session) + + # Configure uploads only for admin users (theme editor image uploads) + socket = + if socket.assigns[:is_admin] do + socket + |> allow_upload(:theme_logo_upload, + accept: ~w(.png .jpg .jpeg .webp .svg), + max_entries: 1, + max_file_size: 2_000_000, + auto_upload: true, + progress: &handle_theme_upload_progress/3 + ) + |> allow_upload(:theme_header_upload, + accept: ~w(.png .jpg .jpeg .webp), + max_entries: 1, + max_file_size: 5_000_000, + auto_upload: true, + progress: &handle_theme_upload_progress/3 + ) + |> allow_upload(:theme_icon_upload, + accept: ~w(.png .jpg .jpeg .webp .svg), + max_entries: 1, + max_file_size: 5_000_000, + auto_upload: true, + progress: &handle_theme_upload_progress/3 + ) + else + socket + end + + {:ok, socket} + end + + # Handle theme image upload progress (logo, header, icon) + defp handle_theme_upload_progress(:theme_logo_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :theme_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 | _] -> + # Trigger favicon generation if using logo as icon + if socket.assigns[:theme_editor_settings] && + socket.assigns.theme_editor_settings.use_logo_as_icon do + enqueue_favicon_generation(image.id) + end + + {:noreply, + assign(socket, :theme_editor_logo_image, image) |> assign(:logo_image, image)} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + defp handle_theme_upload_progress(:theme_header_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :theme_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 | _] -> + socket = + socket + |> assign(:theme_editor_header_image, image) + |> assign(:header_image, image) + |> recompute_header_contrast() + + {:noreply, socket} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + defp handle_theme_upload_progress(:theme_icon_upload, entry, socket) do + if entry.done? do + consume_uploaded_entries(socket, :theme_icon_upload, fn %{path: path}, entry -> + case Media.upload_from_entry(path, entry, "icon") do + {:ok, image} -> + Settings.update_theme_settings(%{icon_image_id: image.id}) + {:ok, image} + + {:error, _} = error -> + error + end + end) + |> case do + [image | _] -> + enqueue_favicon_generation(image.id) + + {:noreply, + assign(socket, :theme_editor_icon_image, image) |> assign(:icon_image, image)} + + _ -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + defp enqueue_favicon_generation(source_image_id) do + %{source_image_id: source_image_id} + |> FaviconGeneratorWorker.new() + |> Oban.insert() + end + + defp recompute_header_contrast(socket) do + header_image = socket.assigns[:theme_editor_header_image] + theme_settings = socket.assigns[:theme_editor_settings] + + warning = + if theme_settings && theme_settings.header_background_enabled && header_image do + text_color = Berrypod.Theme.Contrast.text_color_for_mood(theme_settings.mood) + colors = Berrypod.Theme.Contrast.parse_dominant_colors(header_image.dominant_colors) + Berrypod.Theme.Contrast.analyze_header_contrast(colors, text_color) + else + :ok + end + + assign(socket, :theme_editor_contrast_warning, warning) end @impl true def handle_params(params, uri, socket) do action = socket.assigns.live_action prev_action = socket.assigns[:_current_page_action] + prev_path = socket.assigns[:_current_path] module = @page_modules[action] + parsed_uri = URI.parse(uri) + current_path = parsed_uri.path # Clean up previous page if needed (e.g., unsubscribe from PubSub) socket = maybe_cleanup_previous_page(socket, prev_action) @@ -73,6 +220,16 @@ defmodule BerrypodWeb.Shop.Page do # After page init, sync editor state if editing and page changed socket = maybe_sync_editing_blocks(socket) + # Scroll to top on navigation (different path, not just query param changes) + socket = + if prev_path && prev_path != current_path do + Phoenix.LiveView.push_event(socket, "scroll-top", %{}) + else + socket + end + + socket = assign(socket, :_current_path, current_path) + # Always call handle_params for URL changes case module.handle_params(params, uri, socket) do {:noreply, socket} -> {:noreply, socket} diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index 21964c9..36cc96b 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -740,6 +740,15 @@ defmodule BerrypodWeb.PageEditorHook do {:halt, socket} end + defp handle_theme_action( + "cancel_upload", + %{"ref" => ref, "upload" => upload_name}, + socket + ) do + upload_atom = String.to_existing_atom(upload_name) + {:halt, Phoenix.LiveView.cancel_upload(socket, upload_atom, ref)} + end + # Catch-all for unknown theme actions defp handle_theme_action(_action, _params, socket), do: {:halt, socket} diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index ba172fe..5f7e159 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -111,6 +111,11 @@ defmodule BerrypodWeb.PageRenderer do theme_editor_active_preset={Map.get(assigns, :theme_editor_active_preset)} theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])} theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)} + theme_editor_logo_image={Map.get(assigns, :theme_editor_logo_image)} + theme_editor_header_image={Map.get(assigns, :theme_editor_header_image)} + theme_editor_icon_image={Map.get(assigns, :theme_editor_icon_image)} + theme_editor_contrast_warning={Map.get(assigns, :theme_editor_contrast_warning, :ok)} + uploads={Map.get(assigns, :uploads)} site_name={Map.get(assigns, :site_name, "")} product={assigns[:product]} collection_title={assigns[:collection_title]} @@ -143,6 +148,11 @@ defmodule BerrypodWeb.PageRenderer do attr :theme_editor_active_preset, :atom, default: nil attr :theme_editor_presets, :list, default: [] attr :theme_editor_customise_open, :boolean, default: false + attr :theme_editor_logo_image, :map, default: nil + attr :theme_editor_header_image, :map, default: nil + attr :theme_editor_icon_image, :map, default: nil + attr :theme_editor_contrast_warning, :atom, default: :ok + attr :uploads, :map, default: nil attr :site_name, :string, default: "" attr :product, :map, default: nil attr :collection_title, :string, default: nil @@ -179,6 +189,11 @@ defmodule BerrypodWeb.PageRenderer do theme_editor_active_preset={@theme_editor_active_preset} theme_editor_presets={@theme_editor_presets} theme_editor_customise_open={@theme_editor_customise_open} + theme_editor_logo_image={@theme_editor_logo_image} + theme_editor_header_image={@theme_editor_header_image} + theme_editor_icon_image={@theme_editor_icon_image} + theme_editor_contrast_warning={@theme_editor_contrast_warning} + uploads={@uploads} site_name={@site_name} /> """ @@ -203,6 +218,11 @@ defmodule BerrypodWeb.PageRenderer do attr :theme_editor_active_preset, :atom, default: nil attr :theme_editor_presets, :list, default: [] attr :theme_editor_customise_open, :boolean, default: false + attr :theme_editor_logo_image, :map, default: nil + attr :theme_editor_header_image, :map, default: nil + attr :theme_editor_icon_image, :map, default: nil + attr :theme_editor_contrast_warning, :atom, default: :ok + attr :uploads, :map, default: nil attr :site_name, :string, default: "" defp theme_editor_content(assigns) do @@ -213,6 +233,11 @@ defmodule BerrypodWeb.PageRenderer do presets={@theme_editor_presets} site_name={@site_name} customise_open={@theme_editor_customise_open} + uploads={@uploads} + logo_image={@theme_editor_logo_image} + header_image={@theme_editor_header_image} + icon_image={@theme_editor_icon_image} + contrast_warning={@theme_editor_contrast_warning} event_prefix="theme_" /> """