diff --git a/PROGRESS.md b/PROGRESS.md index b85f15c..e6c4f9b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -158,7 +158,7 @@ Restructure the 3-tab editor panel for better discoverability. Replace Settings | 1-4 | Data model + Site tab skeleton | 3h | done | | 5-7 | Announcement bar (editable text, link, styles) | 1.5h | done | | 8-9 | Social links editor (CRUD, reorder, platform detection, URL normalization) | 2h | done | -| 10-14 | Header & footer navigation editors | 3h | planned | +| 10-14 | Header & footer navigation editors | 3h | done | | 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done | | 17-18 | Move branding from Theme to Site | 1.5h | planned | | 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | planned | @@ -166,6 +166,8 @@ Restructure the 3-tab editor panel for better discoverability. Replace Settings Social links now support 40+ platforms (grouped by category), auto-detect platform from pasted URLs (including deep links like `tg://` and `spotify:`), normalize bare domains to https://, preserve custom protocols, and filter empty URLs from shop display. +Navigation editors support add/edit/delete/reorder for both header and footer nav items. Page picker dropdown allows linking to system pages (home, about, contact, etc.) or custom published pages. Items persist immediately on change. + ### Unified editor session ([plan](docs/plans/unified-editor-session.md)) — Complete Unified "optimistic preview with explicit save" across all editor tabs. Changes show immediately but only persist on Save. Free tab switching without warnings. Navigation blocking with Save/Discard/Cancel modal. diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index be6e87f..99d8e01 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -3295,21 +3295,47 @@ .site-editor-nav-item { display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0.75rem; + gap: 0.5rem; + align-items: flex-start; + padding: 0.5rem; background: var(--t-surface-sunken); border-radius: 0.375rem; font-size: 0.8125rem; } -.site-editor-nav-label { +.site-editor-nav-item-form { + display: flex; + flex-direction: column; + gap: 0.375rem; + flex: 1; + min-width: 0; +} + +.site-editor-nav-label-input { font-weight: 500; } -.site-editor-nav-url { - font-size: 0.75rem; - opacity: 0.6; +.site-editor-nav-url-row { + display: flex; + gap: 0.375rem; +} + +.site-editor-nav-type { + width: 6rem; + flex-shrink: 0; +} + +.site-editor-nav-page, +.site-editor-nav-url-input { + flex: 1; + min-width: 0; +} + +.site-editor-nav-item-actions { + display: flex; + gap: 0.125rem; + flex-shrink: 0; + padding-top: 0.25rem; } /* Social links editor item */ diff --git a/docs/plans/editor-reorganisation.md b/docs/plans/editor-reorganisation.md index 2719346..72df967 100644 --- a/docs/plans/editor-reorganisation.md +++ b/docs/plans/editor-reorganisation.md @@ -206,11 +206,11 @@ end | 7 | Announcement bar: style variants | 6 | 15m | done | | 8 | Social links: editor UI (list, add, remove, reorder) | 4 | 1.5h | done | | 9 | Social links: read from database | 8 | 30m | done | -| 10 | Header nav: editor UI | 4 | 1h | planned | -| 11 | Header nav: page picker component | 10 | 30m | planned | -| 12 | Header nav: read from database | 11 | 30m | planned | -| 13 | Footer nav: editor UI | 10 | 30m | planned | -| 14 | Footer nav: read from database | 13 | 30m | planned | +| 10 | Header nav: editor UI | 4 | 1h | done | +| 11 | Header nav: page picker component | 10 | 30m | done | +| 12 | Header nav: read from database | 11 | 30m | done | +| 13 | Footer nav: editor UI | 10 | 30m | done | +| 14 | Footer nav: read from database | 13 | 30m | done | | 15 | Footer content: about, copyright, newsletter toggle | 4 | 45m | done | | 16 | Footer: read new fields | 15 | 30m | done | | 17 | Move branding settings from Theme to Site | 4 | 1h | planned | @@ -250,10 +250,11 @@ The social links feature supports 40+ platforms grouped into 9 categories: - Returns "custom" for unknown domains **Data flow:** -- Database → `Site.list_social_links/0` → raw structs +- Database → `Site.list_social_links/0` → raw structs loaded into original and current state - Shop display → `Site.social_links_for_shop/0` → filtered (no empty URLs), formatted maps - Editor preview → `format_social_links_for_shop/1` in layout.ex → same filtering - Social links card block now uses database data via `assigns[:social_links]` +- Changes preview immediately, persist on Save button via `sync_social_links/2` **Files:** - `lib/berrypod/site.ex` - Site context with social links CRUD @@ -262,6 +263,43 @@ The social links feature supports 40+ platforms grouped into 9 categories: - `lib/berrypod_web/page_editor_hook.ex` - Event handlers - `test/berrypod/site_test.exs` - 35 tests covering all functionality +### Completed: Navigation Editors (Tasks 10-14) + +Header and footer navigation items are managed through a unified `nav_editor` component. + +**Features:** +- Add/edit/delete/reorder nav items +- Label input with live preview +- Link type selector: Page (dropdown) or Custom URL (text input) +- Page picker shows linkable system pages (home, about, delivery, privacy, terms, contact) and published custom pages +- Move up/down buttons with disabled state at boundaries +- Changes preview immediately, persist on Save button + +**Event handlers:** +- `site_add_nav_item` - Creates temporary item in local state +- `site_update_nav_item` - Updates label, URL, or page selection in local state based on `_target` +- `site_remove_nav_item` - Removes item from local state +- `site_move_nav_item` - Reorders items in local state + +**Save/revert behavior:** +- All nav item and social link changes participate in the unified dirty state system +- `compute_site_dirty/1` compares current state against original state loaded at editor open +- On Save: `sync_nav_items/2` and `sync_social_links/2` diff current vs original and create/update/delete as needed +- On Discard: reverts to original state from `site_editor_original` +- Navigation warning modal appears when navigating with unsaved changes + +**Data flow:** +- Database → `Site.list_nav_items/1` → raw structs loaded into original and current state +- Shop display → `Site.nav_items_for_shop/1` → formatted maps with computed href/slug +- Header/footer components already wired to use `@header_nav_items`/`@footer_nav_items` + +**Files:** +- `lib/berrypod/site.ex` - NavItem CRUD and reorder functions +- `lib/berrypod/site/nav_item.ex` - Schema with location, label, url, page_id, position +- `lib/berrypod_web/components/shop_components/site_editor.ex` - `nav_editor` component +- `lib/berrypod_web/page_editor_hook.ex` - Event handlers, sync functions, dirty state +- `assets/css/admin/components.css` - Styles for nav editor + ## UI Wireframes ### Site tab layout diff --git a/lib/berrypod/redirects/link_scanner.ex b/lib/berrypod/redirects/link_scanner.ex index 858d29d..a82eee8 100644 --- a/lib/berrypod/redirects/link_scanner.ex +++ b/lib/berrypod/redirects/link_scanner.ex @@ -6,8 +6,7 @@ defmodule Berrypod.Redirects.LinkScanner do and by the admin UI to show where a broken URL is used. """ - alias Berrypod.Pages - alias Berrypod.Settings + alias Berrypod.{Pages, Site} # Block settings keys that contain URLs @url_keys ~w(cta_href secondary_cta_href link_href href url) @@ -43,8 +42,8 @@ defmodule Berrypod.Redirects.LinkScanner do Returns a list of `%{url: string, type: :internal | :external, sources: [source]}`. """ def scan_nav do - header_items = load_nav_items("header_nav") - footer_items = load_nav_items("footer_nav") + header_items = Site.nav_items_for_shop("header") + footer_items = Site.nav_items_for_shop("footer") header_links = Enum.flat_map(header_items, fn item -> @@ -147,13 +146,13 @@ defmodule Berrypod.Redirects.LinkScanner do type: "nav_item", id: "#{location}_nav", label: "#{String.capitalize(location)} nav — #{label}", - edit_path: "/admin/navigation" + edit_path: "/?edit=site" } end defp find_nav_sources(url) do - header_items = load_nav_items("header_nav") - footer_items = load_nav_items("footer_nav") + header_items = Site.nav_items_for_shop("header") + footer_items = Site.nav_items_for_shop("footer") header = header_items @@ -168,13 +167,6 @@ defmodule Berrypod.Redirects.LinkScanner do header ++ footer end - defp load_nav_items(key) do - case Settings.get_setting(key) do - items when is_list(items) -> items - _ -> [] - end - end - defp group_by_url(links) do links |> Enum.group_by(& &1.url) diff --git a/lib/berrypod/site.ex b/lib/berrypod/site.ex index 80f24e2..ba5f895 100644 --- a/lib/berrypod/site.ex +++ b/lib/berrypod/site.ex @@ -175,7 +175,7 @@ defmodule Berrypod.Site do |> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end) |> Enum.map(fn link -> %{ - platform: String.to_existing_atom(link.platform), + platform: String.to_atom(link.platform), url: link.url, label: platform_label(link.platform) } @@ -352,7 +352,7 @@ defmodule Berrypod.Site do } end - # ── Seeding ──────────────────────────────────────────────────────── + # ── Defaults ────────────────────────────────────────────────────── @default_header_nav [ %{label: "Home", url: "/", position: 0}, @@ -373,6 +373,41 @@ defmodule Berrypod.Site do %{platform: "bluesky", url: "https://bsky.app", position: 1} ] + @doc """ + Returns the default header navigation items in shop format. + + Used for previews and error pages when database may be empty. + """ + def default_header_nav do + [ + %{"label" => "Home", "href" => "/", "slug" => "home"}, + %{ + "label" => "Shop", + "href" => "/collections/all", + "slug" => "collection", + "active_slugs" => ["collection", "pdp"] + }, + %{"label" => "About", "href" => "/about", "slug" => "about"}, + %{"label" => "Contact", "href" => "/contact", "slug" => "contact"} + ] + end + + @doc """ + Returns the default footer navigation items in shop format. + + Used for previews and error pages when database may be empty. + """ + def default_footer_nav do + [ + %{"label" => "Delivery & returns", "href" => "/delivery", "slug" => "delivery"}, + %{"label" => "Privacy policy", "href" => "/privacy", "slug" => "privacy"}, + %{"label" => "Terms of service", "href" => "/terms", "slug" => "terms"}, + %{"label" => "Contact", "href" => "/contact", "slug" => "contact"} + ] + end + + # ── Seeding ────────────────────────────────────────────────────────── + @doc """ Seeds default navigation items and social links if none exist. diff --git a/lib/berrypod/site/nav_item.ex b/lib/berrypod/site/nav_item.ex index 716b783..66fa0fb 100644 --- a/lib/berrypod/site/nav_item.ex +++ b/lib/berrypod/site/nav_item.ex @@ -24,9 +24,19 @@ defmodule Berrypod.Site.NavItem do def changeset(nav_item, attrs) do nav_item |> cast(attrs, [:location, :label, :url, :page_id, :position]) - |> validate_required([:location, :label, :url]) + |> validate_required([:location, :label]) |> validate_inclusion(:location, @locations) |> validate_length(:label, min: 1, max: 50) |> foreign_key_constraint(:page_id) + |> default_url() + end + + # Default to empty string if URL not provided (allows pending page selection) + defp default_url(changeset) do + if get_field(changeset, :url) == nil do + put_change(changeset, :url, "") + else + changeset + end end end diff --git a/lib/berrypod/site/social_link.ex b/lib/berrypod/site/social_link.ex index 4c4b61e..c0039a1 100644 --- a/lib/berrypod/site/social_link.ex +++ b/lib/berrypod/site/social_link.ex @@ -136,37 +136,63 @@ defmodule Berrypod.Site.SocialLink do # Host-to-platform mapping for domain-based detection @host_platforms %{ # Social - "instagram.com" => "instagram", "threads.net" => "threads", "tiktok.com" => "tiktok", - "facebook.com" => "facebook", "fb.com" => "facebook", - "twitter.com" => "twitter", "x.com" => "twitter", - "snapchat.com" => "snapchat", "linkedin.com" => "linkedin", + "instagram.com" => "instagram", + "threads.net" => "threads", + "tiktok.com" => "tiktok", + "facebook.com" => "facebook", + "fb.com" => "facebook", + "twitter.com" => "twitter", + "x.com" => "twitter", + "snapchat.com" => "snapchat", + "linkedin.com" => "linkedin", # Video & streaming - "youtube.com" => "youtube", "youtu.be" => "youtube", - "twitch.tv" => "twitch", "vimeo.com" => "vimeo", - "kick.com" => "kick", "rumble.com" => "rumble", + "youtube.com" => "youtube", + "youtu.be" => "youtube", + "twitch.tv" => "twitch", + "vimeo.com" => "vimeo", + "kick.com" => "kick", + "rumble.com" => "rumble", # Music & podcasts - "spotify.com" => "spotify", "open.spotify.com" => "spotify", - "soundcloud.com" => "soundcloud", "bandcamp.com" => "bandcamp", + "spotify.com" => "spotify", + "open.spotify.com" => "spotify", + "soundcloud.com" => "soundcloud", + "bandcamp.com" => "bandcamp", "podcasts.apple.com" => "applepodcasts", # Creative - "pinterest.com" => "pinterest", "pin.it" => "pinterest", - "behance.net" => "behance", "dribbble.com" => "dribbble", - "tumblr.com" => "tumblr", "medium.com" => "medium", + "pinterest.com" => "pinterest", + "pin.it" => "pinterest", + "behance.net" => "behance", + "dribbble.com" => "dribbble", + "tumblr.com" => "tumblr", + "medium.com" => "medium", # Support & sales - "patreon.com" => "patreon", "ko-fi.com" => "kofi", - "etsy.com" => "etsy", "gumroad.com" => "gumroad", "substack.com" => "substack", + "patreon.com" => "patreon", + "ko-fi.com" => "kofi", + "etsy.com" => "etsy", + "gumroad.com" => "gumroad", + "substack.com" => "substack", # Federated - "mastodon.social" => "mastodon", "pixelfed.social" => "pixelfed", - "bsky.app" => "bluesky", "lemmy.world" => "lemmy", "matrix.to" => "matrix", + "mastodon.social" => "mastodon", + "pixelfed.social" => "pixelfed", + "bsky.app" => "bluesky", + "lemmy.world" => "lemmy", + "matrix.to" => "matrix", # Developer - "github.com" => "github", "gitlab.com" => "gitlab", - "codeberg.org" => "codeberg", "sr.ht" => "sourcehut", - "reddit.com" => "reddit", "old.reddit.com" => "reddit", + "github.com" => "github", + "gitlab.com" => "gitlab", + "codeberg.org" => "codeberg", + "sr.ht" => "sourcehut", + "reddit.com" => "reddit", + "old.reddit.com" => "reddit", # Messaging - "discord.com" => "discord", "discord.gg" => "discord", - "t.me" => "telegram", "telegram.me" => "telegram", - "signal.me" => "signal", "signal.group" => "signal", - "wa.me" => "whatsapp", "whatsapp.com" => "whatsapp", + "discord.com" => "discord", + "discord.gg" => "discord", + "t.me" => "telegram", + "telegram.me" => "telegram", + "signal.me" => "signal", + "signal.group" => "signal", + "wa.me" => "whatsapp", + "whatsapp.com" => "whatsapp", # Other "linktr.ee" => "linktree" } diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 1fec834..16a4755 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -118,14 +118,6 @@ <.icon name="hero-document" class="size-5" /> Pages -
Loading site settings...
+No navigation items
-- Drag to reorder. Full editing coming in the next phase. +
+