replace admin rail with unified bottom sheet editor
All checks were successful
deploy / deploy (push) Successful in 1m30s
All checks were successful
deploy / deploy (push) Successful in 1m30s
- add editor sheet component anchored bottom (mobile) / right (desktop) - admin cog moves to header, always visible for admins - remove Done button from editor header, keep only Save - add editor_at_defaults tracking to disable Reset when at defaults - sheet collapses on click outside or Escape, stays in edit mode - dirty indicator + beforeunload warning for unsaved changes - keyboard shortcuts: Ctrl+Z undo, Ctrl+Shift+Z redo - WCAG compliant: aria-expanded, live region, focus management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="admin-toast"
|
||||
class="admin-banner"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
@@ -70,6 +70,39 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders inline status feedback next to a button or form section.
|
||||
|
||||
## Examples
|
||||
|
||||
<.inline_feedback status={@save_status} />
|
||||
<.inline_feedback status={@save_status} message={@save_error} />
|
||||
"""
|
||||
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
|
||||
attr :message, :string, default: nil
|
||||
|
||||
def inline_feedback(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
:if={@status != :idle}
|
||||
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
|
||||
role={@status == :error && "alert"}
|
||||
>
|
||||
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
|
||||
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
|
||||
<.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
|
||||
<span>{feedback_text(@status, @message)}</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp feedback_text(:saving, _), do: "Saving..."
|
||||
defp feedback_text(:saved, nil), do: "Saved"
|
||||
defp feedback_text(:saved, msg), do: msg
|
||||
defp feedback_text(:error, nil), do: "Something went wrong"
|
||||
defp feedback_text(:error, msg), do: msg
|
||||
defp feedback_text(:idle, _), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ defmodule BerrypodWeb.Layouts do
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<.flash_group flash={@flash} />
|
||||
|
||||
<main class="app-main">
|
||||
<div class="app-container">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
</.link>
|
||||
</header>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
|
||||
<%!-- page content --%>
|
||||
<main class="admin-main">
|
||||
<.flash_group flash={@flash} />
|
||||
<div class="admin-container">
|
||||
{@inner_content}
|
||||
</div>
|
||||
|
||||
@@ -128,9 +128,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
mode={@mode}
|
||||
cart_count={@cart_count}
|
||||
is_admin={@is_admin}
|
||||
editing={@editing}
|
||||
editor_current_path={@editor_current_path}
|
||||
editor_sidebar_open={@editor_sidebar_open}
|
||||
header_nav_items={@header_nav_items}
|
||||
/>
|
||||
|
||||
@@ -167,11 +164,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
search_open={@search_open}
|
||||
/>
|
||||
|
||||
<.mobile_bottom_nav
|
||||
<.mobile_nav_drawer
|
||||
:if={!@error_page}
|
||||
active_page={@active_page}
|
||||
mode={@mode}
|
||||
items={@header_nav_items}
|
||||
categories={assigns[:categories] || []}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
@@ -513,6 +511,111 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the mobile navigation drawer.
|
||||
|
||||
A slide-out drawer containing the main navigation links for mobile users.
|
||||
Triggered by the hamburger menu button in the header.
|
||||
"""
|
||||
attr :active_page, :string, required: true
|
||||
attr :mode, :atom, default: :live
|
||||
attr :items, :list, default: []
|
||||
attr :categories, :list, default: []
|
||||
|
||||
def mobile_nav_drawer(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="mobile-nav-drawer"
|
||||
class="mobile-nav-drawer"
|
||||
phx-hook="MobileNavDrawer"
|
||||
>
|
||||
<div
|
||||
class="mobile-nav-backdrop"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
|
||||
>
|
||||
</div>
|
||||
<nav class="mobile-nav-panel" aria-label="Main navigation">
|
||||
<div class="mobile-nav-header">
|
||||
<span class="mobile-nav-title">Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-nav-close"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="mobile-nav-links">
|
||||
<li :for={item <- @items}>
|
||||
<%= if @mode == :preview do %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={item["slug"]}
|
||||
class="mobile-nav-link"
|
||||
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
|
||||
>
|
||||
{item["label"]}
|
||||
</a>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={item["href"]}
|
||||
class="mobile-nav-link"
|
||||
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
|
||||
>
|
||||
{item["label"]}
|
||||
</.link>
|
||||
<% end %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<%= if @categories != [] do %>
|
||||
<div class="mobile-nav-section">
|
||||
<span class="mobile-nav-section-title">Shop by category</span>
|
||||
<ul class="mobile-nav-links">
|
||||
<li :for={category <- @categories}>
|
||||
<%= if @mode == :preview do %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="collection"
|
||||
class="mobile-nav-link"
|
||||
>
|
||||
{category.name}
|
||||
</a>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={"/collections/#{category.slug}"}
|
||||
class="mobile-nav-link"
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
|
||||
}
|
||||
>
|
||||
{category.name}
|
||||
</.link>
|
||||
<% end %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the shop footer with newsletter signup and links.
|
||||
|
||||
@@ -662,9 +765,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :mode, :atom, default: :live
|
||||
attr :cart_count, :integer, default: 0
|
||||
attr :is_admin, :boolean, default: false
|
||||
attr :editing, :boolean, default: false
|
||||
attr :editor_current_path, :string, default: nil
|
||||
attr :editor_sidebar_open, :boolean, default: true
|
||||
attr :header_nav_items, :list, default: []
|
||||
|
||||
def shop_header(assigns) do
|
||||
@@ -674,6 +774,27 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<div style={header_background_style(@theme_settings, @header_image)} />
|
||||
<% end %>
|
||||
|
||||
<%!-- Hamburger menu button (mobile only) --%>
|
||||
<button
|
||||
type="button"
|
||||
class="header-hamburger"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("open-mobile-nav", to: "#mobile-nav-drawer")}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="shop-logo">
|
||||
<.logo_content
|
||||
theme_settings={@theme_settings}
|
||||
@@ -697,48 +818,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
</nav>
|
||||
|
||||
<div class="shop-actions">
|
||||
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
|
||||
<.link
|
||||
:if={@is_admin && !@editing && @editor_current_path}
|
||||
patch={"#{@editor_current_path}?edit=true"}
|
||||
class="header-icon-btn"
|
||||
aria-label="Edit page"
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
</.link>
|
||||
<button
|
||||
:if={@is_admin && @editing && !@editor_sidebar_open}
|
||||
phx-click="editor_toggle_sidebar"
|
||||
class="header-icon-btn"
|
||||
aria-label="Show editor sidebar"
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
</button>
|
||||
<%!-- Admin cog: always visible for admins, links to admin dashboard --%>
|
||||
<.link
|
||||
:if={@is_admin}
|
||||
href="/admin"
|
||||
class="header-icon-btn"
|
||||
aria-label="Admin"
|
||||
aria-label="Admin dashboard"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<.admin_cog_svg />
|
||||
</.link>
|
||||
<a
|
||||
href="/search"
|
||||
@@ -950,4 +1037,197 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
defp open_cart_drawer_js do
|
||||
Phoenix.LiveView.JS.push("open_cart_drawer")
|
||||
end
|
||||
|
||||
# ── Editor sheet ────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Renders the unified editor sheet for page editing.
|
||||
|
||||
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
|
||||
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
|
||||
and two states on desktop (collapsed, open).
|
||||
|
||||
## Attributes
|
||||
|
||||
* `editing` - Whether edit mode is active.
|
||||
* `editor_dirty` - Whether there are unsaved changes.
|
||||
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
|
||||
|
||||
## Slots
|
||||
|
||||
* `inner_block` - The editor content (block list, settings, etc.).
|
||||
"""
|
||||
attr :editing, :boolean, default: false
|
||||
attr :editor_dirty, :boolean, default: false
|
||||
attr :editor_sheet_state, :atom, default: :collapsed
|
||||
attr :editor_save_status, :atom, default: :idle
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def editor_sheet(assigns) do
|
||||
~H"""
|
||||
<aside
|
||||
id="editor-sheet"
|
||||
class="editor-sheet"
|
||||
role="region"
|
||||
aria-label="Page editor"
|
||||
aria-expanded={to_string(@editor_sheet_state != :collapsed)}
|
||||
data-state={@editor_sheet_state}
|
||||
data-editing={to_string(@editing)}
|
||||
phx-hook="EditorSheet"
|
||||
>
|
||||
<%!-- Header: content varies by state and editing mode --%>
|
||||
<div class="editor-sheet-header">
|
||||
<%= if @editor_sheet_state == :collapsed and not @editing do %>
|
||||
<%!-- Not editing, collapsed: show Edit button to enter edit mode --%>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_toggle_editing"
|
||||
class="editor-sheet-edit-btn"
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<span>Edit page</span>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @editor_sheet_state == :collapsed and @editing do %>
|
||||
<%!-- Editing but collapsed: show button to expand sheet (for previewing) --%>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_set_sheet_state"
|
||||
phx-value-state="open"
|
||||
class="editor-sheet-edit-btn"
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<span>Show editor</span>
|
||||
</button>
|
||||
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
|
||||
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
|
||||
<span>Unsaved</span>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @editor_sheet_state != :collapsed do %>
|
||||
<div class="editor-sheet-header-left">
|
||||
<span class="editor-sheet-title">Page editor</span>
|
||||
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
|
||||
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
|
||||
<span>Unsaved</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-sheet-header-actions">
|
||||
<button
|
||||
:if={@editor_save_status == :saved}
|
||||
type="button"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
disabled
|
||||
>
|
||||
Saved ✓
|
||||
</button>
|
||||
<button
|
||||
:if={@editor_save_status != :saved}
|
||||
type="button"
|
||||
phx-click="editor_save"
|
||||
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
|
||||
disabled={!@editor_dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Content area (hidden when collapsed) --%>
|
||||
<div class="editor-sheet-content">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<%!-- Live region for screen reader announcements --%>
|
||||
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Admin rail (deprecated) ────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Renders the admin rail with edit and admin icons.
|
||||
|
||||
This thin vertical bar appears on the left edge of the page for logged-in admins.
|
||||
The edit button toggles the page editor, and the cog links to the admin dashboard.
|
||||
"""
|
||||
attr :editing, :boolean, default: false
|
||||
attr :editor_dirty, :boolean, default: false
|
||||
attr :editor_sidebar_open, :boolean, default: true
|
||||
slot :editor_sidebar
|
||||
slot :inner_block, required: true
|
||||
|
||||
def admin_rail(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="admin-rail-layout"
|
||||
data-editing={to_string(@editing)}
|
||||
data-sidebar-open={to_string(@editor_sidebar_open)}
|
||||
>
|
||||
<aside class="admin-rail" aria-label="Admin tools">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_toggle_editing"
|
||||
class={["admin-rail-btn", @editing && "admin-rail-btn-active"]}
|
||||
aria-label={if @editing, do: "Close editor", else: "Edit page"}
|
||||
aria-pressed={to_string(@editing)}
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<span
|
||||
:if={@editing && @editor_dirty}
|
||||
class="admin-rail-dirty-dot"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<.link href="/admin" class="admin-rail-btn" aria-label="Admin dashboard">
|
||||
<.admin_cog_svg />
|
||||
</.link>
|
||||
</aside>
|
||||
|
||||
<aside :if={@editing} class="admin-rail-sidebar" aria-label="Page editor">
|
||||
{render_slot(@editor_sidebar)}
|
||||
</aside>
|
||||
|
||||
<%!-- Backdrop to close sidebar on mobile --%>
|
||||
<div
|
||||
:if={@editing && @editor_sidebar_open}
|
||||
class="admin-rail-backdrop"
|
||||
phx-click="editor_toggle_sidebar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="admin-rail-content">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def admin_cog_svg(assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,10 +16,10 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, put_flash: 3, push_navigate: 2]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||
|
||||
def on_mount(:mount_page_editor, _params, _session, socket) do
|
||||
socket =
|
||||
@@ -27,6 +27,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, true)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
@@ -36,10 +37,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_current_path, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_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)
|
||||
@@ -47,50 +50,48 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
# ── handle_params: detect ?edit=true ─────────────────────────────
|
||||
# ── handle_params: track current path ────────────────────────────
|
||||
|
||||
defp handle_editor_params(_params, uri, socket) do
|
||||
parsed = URI.parse(uri)
|
||||
query = URI.decode_query(parsed.query || "")
|
||||
wants_edit = query["edit"] == "true"
|
||||
|
||||
# Always store the current path for the edit button and "done" navigation
|
||||
socket = assign(socket, :editor_current_path, parsed.path)
|
||||
|
||||
cond do
|
||||
wants_edit and socket.assigns.is_admin and socket.assigns[:page] ->
|
||||
# Page already loaded — enter edit mode and halt (no need for module handle_params)
|
||||
{:halt, enter_edit_mode(socket)}
|
||||
|
||||
wants_edit and socket.assigns.is_admin ->
|
||||
# Page not loaded yet (e.g. Shop.Content loads in handle_params),
|
||||
# defer initialisation until after the LiveView sets @page
|
||||
send(self(), :editor_deferred_init)
|
||||
{:cont, assign(socket, :editing, true)}
|
||||
|
||||
socket.assigns.editing and not wants_edit ->
|
||||
# Exiting edit mode — halt since we've handled the transition
|
||||
{:halt, exit_edit_mode(socket)}
|
||||
|
||||
true ->
|
||||
{:cont, socket}
|
||||
end
|
||||
# Store the current path for reference (e.g. the Done button)
|
||||
{:cont, assign(socket, :editor_current_path, parsed.path)}
|
||||
end
|
||||
|
||||
# ── handle_info: deferred init ───────────────────────────────────
|
||||
# ── handle_info ─────────────────────────────────────────────────
|
||||
|
||||
defp handle_editor_info(:editor_deferred_init, socket) do
|
||||
if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
|
||||
{:halt, enter_edit_mode(socket)}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
defp handle_editor_info(:editor_clear_save_status, socket) do
|
||||
{:halt, assign(socket, :editor_save_status, :idle)}
|
||||
end
|
||||
|
||||
defp handle_editor_info(_msg, socket), do: {:cont, socket}
|
||||
|
||||
# ── handle_event: editor_* events ────────────────────────────────
|
||||
|
||||
# toggle_editing can be called even when not editing (to enter edit mode)
|
||||
defp handle_editor_event("editor_toggle_editing", _params, socket) do
|
||||
if socket.assigns.is_admin and socket.assigns[:page] do
|
||||
if socket.assigns.editing do
|
||||
{:halt, exit_edit_mode(socket)}
|
||||
else
|
||||
{:halt, enter_edit_mode(socket)}
|
||||
end
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# set_sheet_state can be called even when not editing (from JS click-outside)
|
||||
defp handle_editor_event("editor_set_sheet_state", %{"state" => state_str}, socket) do
|
||||
if socket.assigns.is_admin and socket.assigns[:page] do
|
||||
state = if state_str == "open", do: :open, else: :collapsed
|
||||
{:halt, assign(socket, :editor_sheet_state, state)}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_event("editor_" <> action, params, socket) do
|
||||
if socket.assigns.editing do
|
||||
handle_editor_action(action, params, socket)
|
||||
@@ -330,6 +331,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case socket.assigns.editor_history do
|
||||
[prev | rest] ->
|
||||
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
|
||||
|
||||
socket =
|
||||
socket
|
||||
@@ -337,6 +339,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_history, rest)
|
||||
|> assign(:editor_future, future)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, "Undone")
|
||||
|> reload_block_data(prev)
|
||||
|
||||
@@ -351,6 +354,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case socket.assigns.editor_future do
|
||||
[next | rest] ->
|
||||
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
|
||||
|
||||
socket =
|
||||
socket
|
||||
@@ -358,6 +362,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, rest)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, "Redone")
|
||||
|> reload_block_data(next)
|
||||
|
||||
@@ -376,39 +381,32 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
|
||||
{:ok, _saved_page} ->
|
||||
updated_page = Pages.get_page(page.slug)
|
||||
at_defaults = Defaults.matches_defaults?(page.slug, updated_page.blocks)
|
||||
Process.send_after(self(), :editor_clear_save_status, 2500)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page, updated_page)
|
||||
|> assign(:editing_blocks, updated_page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> put_flash(:info, "Page saved")
|
||||
|> assign(:editor_save_status, :saved)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:halt, put_flash(socket, :error, "Failed to save page")}
|
||||
{:halt, assign(socket, :editor_save_status, :error)}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_action("reset_defaults", _params, socket) do
|
||||
slug = socket.assigns.page.slug
|
||||
:ok = Pages.reset_page(slug)
|
||||
page = Pages.get_page(slug)
|
||||
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page, page)
|
||||
|> assign(:editing_blocks, page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> reload_block_data(page.blocks)
|
||||
|> put_flash(:info, "Page reset to defaults")
|
||||
|
||||
{:halt, socket}
|
||||
# Treat reset like any other mutation: push to history, mark dirty
|
||||
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
|
||||
end
|
||||
|
||||
defp handle_editor_action("done", _params, socket) do
|
||||
@@ -424,11 +422,13 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
defp enter_edit_mode(socket) do
|
||||
page = socket.assigns.page
|
||||
allowed = BlockTypes.allowed_for(page.slug)
|
||||
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
||||
|
||||
socket
|
||||
|> assign(:editing, true)
|
||||
|> assign(:editing_blocks, page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
@@ -437,10 +437,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, allowed)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :open)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
end
|
||||
|
||||
defp exit_edit_mode(socket) do
|
||||
@@ -456,22 +458,27 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, nil)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
end
|
||||
|
||||
defp apply_mutation(socket, new_blocks, message, type) do
|
||||
history =
|
||||
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
||||
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, message)
|
||||
|
||||
case type do
|
||||
|
||||
@@ -32,16 +32,16 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
live page editor), wraps the page in a sidebar + content layout.
|
||||
"""
|
||||
def render_page(assigns) do
|
||||
if assigns[:editing] && assigns[:editing_blocks] do
|
||||
render_page_with_editor(assigns)
|
||||
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
||||
|
||||
if assigns[:is_admin] do
|
||||
render_page_with_rail(assigns)
|
||||
else
|
||||
render_page_normal(assigns)
|
||||
end
|
||||
end
|
||||
|
||||
defp render_page_normal(assigns) do
|
||||
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
||||
|
||||
~H"""
|
||||
<.shop_layout
|
||||
{layout_assigns(assigns)}
|
||||
@@ -57,146 +57,156 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_page_with_editor(assigns) do
|
||||
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
||||
|
||||
defp render_page_with_rail(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="page-editor-live"
|
||||
class="page-editor-live"
|
||||
phx-hook="EditorKeyboard"
|
||||
data-dirty={to_string(@editor_dirty)}
|
||||
data-event-prefix="editor_"
|
||||
data-sidebar-open={to_string(@editor_sidebar_open)}
|
||||
<.shop_layout
|
||||
{layout_assigns(assigns)}
|
||||
active_page={@page.slug}
|
||||
error_page={@page.slug == "error"}
|
||||
>
|
||||
<aside class="page-editor-sidebar" aria-label="Page editor">
|
||||
<div class="page-editor-sidebar-header">
|
||||
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
|
||||
<div class="page-editor-sidebar-actions">
|
||||
<button
|
||||
phx-click="editor_undo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_history == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_history == []}
|
||||
aria-label="Undo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_redo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_future == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_future == []}
|
||||
aria-label="Redo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_save"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-primary",
|
||||
!@editor_dirty && "opacity-50"
|
||||
]}
|
||||
disabled={!@editor_dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_reset_defaults"
|
||||
data-confirm="Reset this page to its default layout? Your changes will be lost."
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_done"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
data-confirm={@editor_dirty && "You have unsaved changes. Leave without saving?"}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<main id="main-content" class={page_main_class(@page.slug)}>
|
||||
<%= if @editing && @editing_blocks do %>
|
||||
<div
|
||||
:for={block <- @editing_blocks}
|
||||
:key={block["id"]}
|
||||
data-block-type={block["type"]}
|
||||
>
|
||||
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- ARIA live region for screen reader announcements --%>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{if @editor_live_region_message, do: @editor_live_region_message}
|
||||
</div>
|
||||
|
||||
<%!-- Unsaved changes indicator --%>
|
||||
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
|
||||
Unsaved changes
|
||||
</p>
|
||||
|
||||
<%!-- Block list --%>
|
||||
<div class="block-list" role="list" aria-label="Page blocks">
|
||||
<.block_card
|
||||
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
|
||||
block={block}
|
||||
idx={idx}
|
||||
total={length(@editing_blocks)}
|
||||
expanded={@editor_expanded}
|
||||
event_prefix="editor_"
|
||||
/>
|
||||
|
||||
<div :if={@editing_blocks == []} class="block-list-empty">
|
||||
<p>No blocks on this page yet.</p>
|
||||
<% else %>
|
||||
<div :for={block <- @page.blocks} :key={block["id"]} data-block-type={block["type"]}>
|
||||
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
|
||||
<%!-- Add block button --%>
|
||||
<div class="block-actions">
|
||||
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
||||
<.icon name="hero-plus" class="size-4" /> Add block
|
||||
<%!-- Editor sheet for page editing --%>
|
||||
<.editor_sheet
|
||||
editing={@editing}
|
||||
editor_dirty={@editor_dirty}
|
||||
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
||||
editor_save_status={@editor_save_status}
|
||||
>
|
||||
<.editor_sheet_content
|
||||
page={@page}
|
||||
editing_blocks={@editing_blocks}
|
||||
editor_history={@editor_history}
|
||||
editor_future={@editor_future}
|
||||
editor_dirty={@editor_dirty}
|
||||
editor_live_region_message={@editor_live_region_message}
|
||||
editor_expanded={@editor_expanded}
|
||||
editor_show_picker={@editor_show_picker}
|
||||
editor_picker_filter={@editor_picker_filter}
|
||||
editor_allowed_blocks={@editor_allowed_blocks}
|
||||
editor_image_picker_block_id={@editor_image_picker_block_id}
|
||||
editor_image_picker_images={@editor_image_picker_images}
|
||||
editor_image_picker_search={@editor_image_picker_search}
|
||||
editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)}
|
||||
/>
|
||||
</.editor_sheet>
|
||||
"""
|
||||
end
|
||||
|
||||
# Editor sheet content - the block list and editing controls
|
||||
attr :page, :map, required: true
|
||||
attr :editing_blocks, :list, default: nil
|
||||
attr :editor_history, :list, default: []
|
||||
attr :editor_future, :list, default: []
|
||||
attr :editor_dirty, :boolean, default: false
|
||||
attr :editor_live_region_message, :string, default: nil
|
||||
attr :editor_expanded, :any, default: nil
|
||||
attr :editor_show_picker, :boolean, default: false
|
||||
attr :editor_picker_filter, :string, default: ""
|
||||
attr :editor_allowed_blocks, :list, default: nil
|
||||
attr :editor_image_picker_block_id, :string, default: nil
|
||||
attr :editor_image_picker_images, :list, default: []
|
||||
attr :editor_image_picker_search, :string, default: ""
|
||||
attr :editor_at_defaults, :boolean, default: true
|
||||
|
||||
defp editor_sheet_content(assigns) do
|
||||
~H"""
|
||||
<div id="editor-sheet-inner" phx-hook="EditorKeyboard" data-dirty={to_string(@editor_dirty)}>
|
||||
<%!-- Page title and undo/redo --%>
|
||||
<div class="editor-sheet-page-header">
|
||||
<h3 class="editor-sheet-page-title">{@page.title}</h3>
|
||||
<div class="editor-sheet-undo-redo">
|
||||
<button
|
||||
phx-click="editor_undo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_history == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_history == []}
|
||||
aria-label="Undo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_redo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_future == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_future == []}
|
||||
aria-label="Redo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Block picker modal --%>
|
||||
<.block_picker
|
||||
:if={@editor_show_picker}
|
||||
allowed_blocks={@editor_allowed_blocks}
|
||||
filter={@editor_picker_filter}
|
||||
<%!-- ARIA live region for screen reader announcements --%>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{if @editor_live_region_message, do: @editor_live_region_message}
|
||||
</div>
|
||||
|
||||
<%!-- Block list --%>
|
||||
<div class="block-list" role="list" aria-label="Page blocks">
|
||||
<.block_card
|
||||
:for={{block, idx} <- Enum.with_index(@editing_blocks || [])}
|
||||
block={block}
|
||||
idx={idx}
|
||||
total={length(@editing_blocks || [])}
|
||||
expanded={@editor_expanded || MapSet.new()}
|
||||
event_prefix="editor_"
|
||||
/>
|
||||
|
||||
<%!-- Image picker modal --%>
|
||||
<.image_picker
|
||||
:if={@editor_image_picker_block_id}
|
||||
images={@editor_image_picker_images}
|
||||
search={@editor_image_picker_search}
|
||||
event_prefix="editor_"
|
||||
/>
|
||||
</aside>
|
||||
<div :if={(@editing_blocks || []) == []} class="block-list-empty">
|
||||
<p>No blocks on this page yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Backdrop: tapping the page dismisses the sidebar --%>
|
||||
<div
|
||||
:if={@editor_sidebar_open}
|
||||
class="page-editor-backdrop"
|
||||
phx-click="editor_toggle_sidebar"
|
||||
aria-hidden="true"
|
||||
<%!-- Add block button --%>
|
||||
<div class="block-actions">
|
||||
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
||||
<.icon name="hero-plus" class="size-4" /> Add block
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_reset_defaults"
|
||||
data-confirm="Reset this page to its default blocks? You can undo this."
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
disabled={@editor_at_defaults}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Block picker modal --%>
|
||||
<.block_picker
|
||||
:if={@editor_show_picker}
|
||||
allowed_blocks={@editor_allowed_blocks}
|
||||
filter={@editor_picker_filter}
|
||||
event_prefix="editor_"
|
||||
/>
|
||||
|
||||
<div class="page-editor-content">
|
||||
<.shop_layout
|
||||
{layout_assigns(assigns)}
|
||||
active_page={@page.slug}
|
||||
error_page={@page.slug == "error"}
|
||||
>
|
||||
<main id="main-content" class={page_main_class(@page.slug)}>
|
||||
<div
|
||||
:for={block <- @editing_blocks}
|
||||
:key={block["id"]}
|
||||
data-block-type={block["type"]}
|
||||
>
|
||||
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
|
||||
</div>
|
||||
</main>
|
||||
</.shop_layout>
|
||||
</div>
|
||||
<%!-- Image picker modal --%>
|
||||
<.image_picker
|
||||
:if={@editor_image_picker_block_id}
|
||||
images={@editor_image_picker_images}
|
||||
search={@editor_image_picker_search}
|
||||
event_prefix="editor_"
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user