add Site context with social links editor and site-wide settings
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
- Add Site context for managing site-wide content (social links, nav items, announcement bar, footer content) - Add SocialLink schema with URL normalization and platform auto-detection supporting 40+ platforms via host and 25+ via URI scheme - Add NavItem schema for header/footer navigation (editor UI coming next) - Add SiteEditor component with collapsible sections for each content type - Wire social links card block and footer to use database data - Filter empty URLs from display in shop components - Add DetailsPreserver hook to preserve collapsible section state - Add comprehensive tests for Site context and SocialLink functions - Remove unused helper functions from onboarding to fix compiler warnings - Move sync_edit_url_param helper to group handle_editor_event clauses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0b86cd66ce
commit
638bb4fb70
22
PROGRESS.md
22
PROGRESS.md
@ -149,21 +149,33 @@ Close critical gaps identified in the competitive analysis. Phased approach: cor
|
|||||||
| 100 | Blog post type | — | 3h | planned |
|
| 100 | Blog post type | — | 3h | planned |
|
||||||
| 101 | Staff accounts & RBAC | — | 4h | planned |
|
| 101 | Staff accounts & RBAC | — | 4h | planned |
|
||||||
|
|
||||||
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md))
|
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md)) — In Progress
|
||||||
|
|
||||||
Restructure the 3-tab editor panel for better discoverability. Replace Settings tab with Site tab for site-wide content (announcement bar text, social links, nav items, footer content). Move branding from Theme to Site. Merge page settings inline into Page tab.
|
Restructure the 3-tab editor panel for better discoverability. Replace Settings tab with Site tab for site-wide content (announcement bar text, social links, nav items, footer content). Move branding from Theme to Site. Merge page settings inline into Page tab.
|
||||||
|
|
||||||
| # | Task | Est | Status |
|
| # | Task | Est | Status |
|
||||||
|---|------|-----|--------|
|
|---|------|-----|--------|
|
||||||
| 1-4 | Data model + Site tab skeleton | 3h | planned |
|
| 1-4 | Data model + Site tab skeleton | 3h | done |
|
||||||
| 5-7 | Announcement bar (editable text, link, styles) | 1.5h | planned |
|
| 5-7 | Announcement bar (editable text, link, styles) | 1.5h | done |
|
||||||
| 8-9 | Social links editor | 2h | planned |
|
| 8-9 | Social links editor | 2h | done |
|
||||||
| 10-14 | Header & footer navigation editors | 3h | planned |
|
| 10-14 | Header & footer navigation editors | 3h | planned |
|
||||||
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | planned |
|
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done |
|
||||||
| 17-18 | Move branding from Theme to Site | 1.5h | planned |
|
| 17-18 | Move branding from Theme to Site | 1.5h | planned |
|
||||||
| 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | planned |
|
| 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | planned |
|
||||||
| 21-22 | Polish and testing | 2h | planned |
|
| 21-22 | Polish and testing | 2h | planned |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
| Phase | Description | Est | Status |
|
||||||
|
|-------|-------------|-----|--------|
|
||||||
|
| 1 | Site tab preview without auto-save | 1h | done |
|
||||||
|
| 2 | Theme tab preview without auto-save | 1.5h | done |
|
||||||
|
| 3 | Unified Save button (saves all dirty tabs) | 1h | done |
|
||||||
|
| 4 | Visual indicators (dirty dots on tabs, "Unsaved" text) | 0.5h | done |
|
||||||
|
| 5 | Navigation warning modal (Save & continue, Discard, Cancel) | 1h | done |
|
||||||
|
|
||||||
### SEO enhancements ([plan](docs/plans/seo-enhancements.md))
|
### SEO enhancements ([plan](docs/plans/seo-enhancements.md))
|
||||||
|
|
||||||
Comprehensive SEO tooling to rival Yoast/RankMath. Per-page SEO controls, enhanced schema, SEO preview panel, focus keyword with scoring, FAQ schema, Search Console integration.
|
Comprehensive SEO tooling to rival Yoast/RankMath. Per-page SEO controls, enhanced schema, SEO preview panel, focus keyword with scoring, FAQ schema, Search Console integration.
|
||||||
|
|||||||
@ -3093,6 +3093,58 @@
|
|||||||
border-bottom-color: var(--t-accent, oklch(0.6 0.15 250));
|
border-bottom-color: var(--t-accent, oklch(0.6 0.15 250));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-tab-dirty-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-status-warning, oklch(0.75 0.18 85));
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navigation warning modal ── */
|
||||||
|
.editor-nav-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: oklch(0 0 0 / 0.5);
|
||||||
|
border: none;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-nav-modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px oklch(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
& h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: oklch(20% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
color: oklch(40% 0 0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-nav-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Panel content ── */
|
/* ── Panel content ── */
|
||||||
.editor-panel-content {
|
.editor-panel-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -3158,6 +3210,146 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Site editor ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-site-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section {
|
||||||
|
border: 1px solid var(--t-surface-sunken);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: var(--t-surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section-header::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section-header .size-4 {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section[open] .site-editor-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-section-content {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-placeholder {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--t-surface-sunken);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-nav-list,
|
||||||
|
.site-editor-social-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-nav-items,
|
||||||
|
.site-editor-social-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-nav-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--t-surface-sunken);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-nav-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-nav-url {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Social links editor item */
|
||||||
|
.site-editor-social-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--t-surface-sunken);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-social-item-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& select {
|
||||||
|
width: 7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-social-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.125rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-editor-add-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
Image field (block editor)
|
Image field (block editor)
|
||||||
═══════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@ -679,7 +679,7 @@ const DirtyGuard = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditorSheet: handles click-outside, Escape, and mobile drag-to-resize
|
// EditorSheet: handles click-outside, Escape, navigation guard, and mobile drag-to-resize
|
||||||
const EditorSheet = {
|
const EditorSheet = {
|
||||||
mounted() {
|
mounted() {
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
@ -691,6 +691,37 @@ const EditorSheet = {
|
|||||||
}
|
}
|
||||||
document.addEventListener("keydown", this._onKeydown)
|
document.addEventListener("keydown", this._onKeydown)
|
||||||
|
|
||||||
|
// Navigation guard: warn on browser close/refresh
|
||||||
|
this._beforeUnload = (e) => {
|
||||||
|
if (this.el.dataset.dirty === "true") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", this._beforeUnload)
|
||||||
|
|
||||||
|
// Navigation guard: intercept LiveView link clicks
|
||||||
|
this._clickGuard = (e) => {
|
||||||
|
if (this.el.dataset.dirty !== "true") return
|
||||||
|
|
||||||
|
const link = e.target.closest("a[data-phx-link]")
|
||||||
|
if (!link) return
|
||||||
|
|
||||||
|
// Don't block clicks inside the editor itself
|
||||||
|
if (this.el.contains(link)) return
|
||||||
|
|
||||||
|
// Block navigation and show custom modal
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
this.pushEvent("editor_nav_blocked", { href: link.getAttribute("href") })
|
||||||
|
}
|
||||||
|
document.addEventListener("click", this._clickGuard, true)
|
||||||
|
|
||||||
|
// Handle navigation after save/discard
|
||||||
|
this.handleEvent("editor_navigate", ({ href }) => {
|
||||||
|
window.location.href = href
|
||||||
|
})
|
||||||
|
|
||||||
// Restore saved height on mobile and mark as already opened
|
// Restore saved height on mobile and mark as already opened
|
||||||
this._restoreSavedHeight()
|
this._restoreSavedHeight()
|
||||||
this._hasDragged = !!localStorage.getItem("editor-panel-height")
|
this._hasDragged = !!localStorage.getItem("editor-panel-height")
|
||||||
@ -712,6 +743,8 @@ const EditorSheet = {
|
|||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
document.removeEventListener("keydown", this._onKeydown)
|
document.removeEventListener("keydown", this._onKeydown)
|
||||||
|
window.removeEventListener("beforeunload", this._beforeUnload)
|
||||||
|
document.removeEventListener("click", this._clickGuard, true)
|
||||||
this._cleanupDragHandle()
|
this._cleanupDragHandle()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -882,35 +915,33 @@ const Clipboard = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
|
// Preserve <details> open state across LiveView re-renders
|
||||||
|
// Morphdom removes the open attribute when server doesn't send it,
|
||||||
|
// so we store the state locally and restore it after each update.
|
||||||
|
const DetailsPreserver = {
|
||||||
|
mounted() {
|
||||||
|
this._saveState()
|
||||||
|
},
|
||||||
|
beforeUpdate() {
|
||||||
|
this._saveState()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this._restoreState()
|
||||||
|
},
|
||||||
|
_saveState() {
|
||||||
|
this._wasOpen = this.el.open
|
||||||
|
},
|
||||||
|
_restoreState() {
|
||||||
|
if (this._wasOpen !== undefined) {
|
||||||
|
this.el.open = this._wasOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
|
||||||
|
// Note: Navigation guards are now handled by EditorSheet hook
|
||||||
const EditorKeyboard = {
|
const EditorKeyboard = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this._beforeUnload = (e) => {
|
|
||||||
if (this.el.dataset.dirty === "true") {
|
|
||||||
e.preventDefault()
|
|
||||||
e.returnValue = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("beforeunload", this._beforeUnload)
|
|
||||||
|
|
||||||
// Intercept LiveView navigation clicks when editor has unsaved changes.
|
|
||||||
// Uses capture phase to fire before LiveView's own click handler.
|
|
||||||
this._clickGuard = (e) => {
|
|
||||||
if (this.el.dataset.dirty !== "true") return
|
|
||||||
|
|
||||||
const link = e.target.closest("a[data-phx-link]")
|
|
||||||
if (!link) return
|
|
||||||
|
|
||||||
// Don't block clicks inside the editor itself (e.g. block controls)
|
|
||||||
if (this.el.contains(link)) return
|
|
||||||
|
|
||||||
if (!window.confirm("You have unsaved changes that will be lost. Leave anyway?")) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("click", this._clickGuard, true)
|
|
||||||
|
|
||||||
const prefix = this.el.dataset.eventPrefix || ""
|
const prefix = this.el.dataset.eventPrefix || ""
|
||||||
this._keydown = (e) => {
|
this._keydown = (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
|
||||||
@ -926,8 +957,6 @@ const EditorKeyboard = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
window.removeEventListener("beforeunload", this._beforeUnload)
|
|
||||||
document.removeEventListener("click", this._clickGuard, true)
|
|
||||||
document.removeEventListener("keydown", this._keydown)
|
document.removeEventListener("keydown", this._keydown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -950,7 +979,7 @@ const Download = {
|
|||||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
||||||
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DirtyGuard, EditorKeyboard, EditorSheet, Download},
|
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DetailsPreserver, DirtyGuard, EditorKeyboard, EditorSheet, Download},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
|
|||||||
@ -13,7 +13,6 @@ config :berrypod, Berrypod.Repo,
|
|||||||
stacktrace: true,
|
stacktrace: true,
|
||||||
show_sensitive_data_on_connection_error: true
|
show_sensitive_data_on_connection_error: true
|
||||||
|
|
||||||
|
|
||||||
# For development, we disable any cache and enable
|
# For development, we disable any cache and enable
|
||||||
# debugging and code reloading.
|
# debugging and code reloading.
|
||||||
#
|
#
|
||||||
|
|||||||
321
docs/plans/unified-editor-session.md
Normal file
321
docs/plans/unified-editor-session.md
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
# Unified Editor Session
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Depends on:** Editor panel reorganisation (Phase 1-2 complete)
|
||||||
|
**Estimated effort:** 4-5 hours
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Unify the editing experience across all three tabs (Page, Theme, Site) so they behave consistently:
|
||||||
|
|
||||||
|
- **Instant preview** — all changes show immediately on the shop
|
||||||
|
- **Explicit save** — changes only persist to database when you click Save
|
||||||
|
- **Free tab switching** — no warnings when switching between tabs
|
||||||
|
- **Accumulated changes** — edits across all tabs are saved together
|
||||||
|
- **Navigation warning** — warn on page navigation or refresh if any tab has unsaved changes
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
| Tab | Dirty Tracking | Save Method | Preview |
|
||||||
|
|-----|----------------|-------------|---------|
|
||||||
|
| Page | `editor_dirty` | Manual Save | Instant (in-memory blocks) |
|
||||||
|
| Theme | None | Auto-save | Instant (CSS regenerated) |
|
||||||
|
| Site | None | Auto-save | Instant (assigns updated) |
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
| Tab | Dirty Tracking | Save Method | Preview |
|
||||||
|
|-----|----------------|-------------|---------|
|
||||||
|
| Page | `page_dirty` | Unified Save | Instant (in-memory blocks) |
|
||||||
|
| Theme | `theme_dirty` | Unified Save | Instant (in-memory settings) |
|
||||||
|
| Site | `site_dirty` | Unified Save | Instant (in-memory settings) |
|
||||||
|
|
||||||
|
Unified dirty flag: `editor_dirty = page_dirty || theme_dirty || site_dirty`
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Phase 1: Theme Tab — Preview Without Auto-Save (1.5h)
|
||||||
|
|
||||||
|
Currently Theme auto-saves every change. We need to decouple preview from persistence.
|
||||||
|
|
||||||
|
#### 1.1 Add theme dirty tracking
|
||||||
|
|
||||||
|
In `page_editor_hook.ex`, add assigns:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
|> assign(:theme_dirty, false)
|
||||||
|
|> assign(:theme_editor_original, nil) # snapshot at load time
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Modify theme event handlers
|
||||||
|
|
||||||
|
Change handlers like `theme_update_setting`, `theme_update_color`, etc. to:
|
||||||
|
|
||||||
|
1. Update `theme_editor_settings` in memory (for preview)
|
||||||
|
2. Regenerate CSS for live preview
|
||||||
|
3. Set `theme_dirty = true`
|
||||||
|
4. **Do NOT call `Settings.update_theme_settings()`**
|
||||||
|
|
||||||
|
#### 1.3 Store original theme state
|
||||||
|
|
||||||
|
When loading theme state in `load_theme_state/1`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
|> assign(:theme_editor_original, theme_settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 Add theme revert logic
|
||||||
|
|
||||||
|
On discard/close with dirty state, restore from `theme_editor_original`.
|
||||||
|
|
||||||
|
### Phase 2: Site Tab — Preview Without Auto-Save (1h)
|
||||||
|
|
||||||
|
#### 2.1 Add site dirty tracking
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
|> assign(:site_dirty, false)
|
||||||
|
|> assign(:site_editor_original, nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Modify site event handlers
|
||||||
|
|
||||||
|
Change `handle_site_update/2` to:
|
||||||
|
|
||||||
|
1. Update `site_*` assigns in memory (for preview)
|
||||||
|
2. Set `site_dirty = true`
|
||||||
|
3. **Do NOT call `Site.put_announcement()` or `Site.put_footer_content()`**
|
||||||
|
|
||||||
|
#### 2.3 Store original site state
|
||||||
|
|
||||||
|
When loading site state in `load_site_state/1`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
original = %{
|
||||||
|
announcement_text: site_settings.announcement_text,
|
||||||
|
announcement_link: site_settings.announcement_link,
|
||||||
|
announcement_style: site_settings.announcement_style,
|
||||||
|
footer_about: site_settings.footer_about,
|
||||||
|
footer_copyright: site_settings.footer_copyright,
|
||||||
|
show_newsletter: site_settings.show_newsletter
|
||||||
|
}
|
||||||
|
|> assign(:site_editor_original, original)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Unified Save Button (1h)
|
||||||
|
|
||||||
|
#### 3.1 Compute unified dirty state
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defp compute_editor_dirty(socket) do
|
||||||
|
page_dirty = socket.assigns[:editor_dirty] || false
|
||||||
|
theme_dirty = socket.assigns[:theme_dirty] || false
|
||||||
|
site_dirty = socket.assigns[:site_dirty] || false
|
||||||
|
settings_dirty = socket.assigns[:settings_dirty] || false
|
||||||
|
|
||||||
|
assign(socket, :any_dirty, page_dirty || theme_dirty || site_dirty || settings_dirty)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Update Save button
|
||||||
|
|
||||||
|
In `editor_sheet` header, change Save button to:
|
||||||
|
|
||||||
|
- Enabled when `@any_dirty`
|
||||||
|
- On click: `editor_save_all` event
|
||||||
|
|
||||||
|
#### 3.3 Implement unified save handler
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defp handle_editor_event("editor_save_all", _params, socket) do
|
||||||
|
socket = save_all_tabs(socket)
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_all_tabs(socket) do
|
||||||
|
socket
|
||||||
|
|> maybe_save_page()
|
||||||
|
|> maybe_save_theme()
|
||||||
|
|> maybe_save_site()
|
||||||
|
|> maybe_save_settings()
|
||||||
|
|> assign(:editor_save_status, :saved)
|
||||||
|
|> schedule_save_status_clear()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_page(socket) do
|
||||||
|
if socket.assigns[:editor_dirty] do
|
||||||
|
# existing page save logic
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_theme(socket) do
|
||||||
|
if socket.assigns[:theme_dirty] do
|
||||||
|
Settings.update_theme_settings(socket.assigns.theme_editor_settings)
|
||||||
|
assign(socket, :theme_dirty, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_site(socket) do
|
||||||
|
if socket.assigns[:site_dirty] do
|
||||||
|
site = socket.assigns
|
||||||
|
Site.put_announcement(site.site_announcement_text, site.site_announcement_link, site.site_announcement_style)
|
||||||
|
Site.put_footer_content(site.site_footer_about, site.site_footer_copyright, site.site_footer_show_newsletter)
|
||||||
|
assign(socket, :site_dirty, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Visual Indicators (0.5h)
|
||||||
|
|
||||||
|
#### 4.1 Dirty dots on tab labels
|
||||||
|
|
||||||
|
In `editor_sheet` tablist:
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<button role="tab" ...>
|
||||||
|
Page
|
||||||
|
<span :if={@page_dirty} class="editor-tab-dirty-dot" aria-label="unsaved changes"></span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS:
|
||||||
|
```css
|
||||||
|
.editor-tab-dirty-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-warning);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 Update EditorKeyboard hook
|
||||||
|
|
||||||
|
Pass unified dirty state to the hook:
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<div id="editor-sheet-inner"
|
||||||
|
phx-hook="EditorKeyboard"
|
||||||
|
data-dirty={to_string(@any_dirty)}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Navigation Warning Flow (1h)
|
||||||
|
|
||||||
|
#### 5.1 Update EditorKeyboard JS hook
|
||||||
|
|
||||||
|
Modify the navigation intercept in `assets/js/app.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// On navigation attempt when dirty
|
||||||
|
if (this.el.dataset.dirty === "true") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
// Send event to LiveView to show modal
|
||||||
|
this.pushEvent("editor_nav_blocked", { href: href });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Add navigation modal component
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<dialog :if={@editor_nav_blocked} class="editor-nav-modal" open>
|
||||||
|
<div class="editor-nav-modal-content">
|
||||||
|
<h3>Unsaved changes</h3>
|
||||||
|
<p>You have unsaved changes. What would you like to do?</p>
|
||||||
|
<div class="editor-nav-modal-actions">
|
||||||
|
<button phx-click="editor_save_and_navigate">Save & continue</button>
|
||||||
|
<button phx-click="editor_discard_and_navigate">Discard & continue</button>
|
||||||
|
<button phx-click="editor_cancel_navigate">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.3 Handle modal events
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
|
||||||
|
{:halt, assign(socket, editor_nav_blocked: href)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_save_and_navigate", _params, socket) do
|
||||||
|
socket = save_all_tabs(socket)
|
||||||
|
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
|
||||||
|
socket = revert_all_tabs(socket)
|
||||||
|
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
|
||||||
|
# Re-open sheet if collapsed
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:editor_nav_blocked, nil)
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4 Add revert logic
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defp revert_all_tabs(socket) do
|
||||||
|
socket
|
||||||
|
|> maybe_revert_page()
|
||||||
|
|> maybe_revert_theme()
|
||||||
|
|> maybe_revert_site()
|
||||||
|
|> assign(:any_dirty, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_revert_theme(socket) do
|
||||||
|
if socket.assigns[:theme_dirty] do
|
||||||
|
original = socket.assigns.theme_editor_original
|
||||||
|
# Regenerate CSS from original
|
||||||
|
# Reset theme_editor_settings to original
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/berrypod_web/page_editor_hook.ex` | Dirty tracking, save handlers, revert logic |
|
||||||
|
| `lib/berrypod_web/components/shop_components/layout.ex` | Tab dirty dots, Save button, nav modal |
|
||||||
|
| `lib/berrypod_web/page_renderer.ex` | Pass unified dirty state to editor_sheet |
|
||||||
|
| `assets/js/app.js` | EditorKeyboard nav intercept |
|
||||||
|
| `assets/css/admin/components.css` | Dirty dot styles, nav modal styles |
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
1. **Page tab**: Edit blocks → switch tabs → switch back → verify changes preserved
|
||||||
|
2. **Theme tab**: Change color → switch tabs → verify preview still shows new color
|
||||||
|
3. **Site tab**: Edit announcement → switch tabs → verify preview shows new text
|
||||||
|
4. **Save all**: Make changes in all tabs → click Save → verify all persisted
|
||||||
|
5. **Navigate away**: Make changes → click link → verify warning modal
|
||||||
|
6. **Save & continue**: Warning modal → Save & continue → verify saved and navigated
|
||||||
|
7. **Discard & continue**: Warning modal → Discard → verify reverted and navigated
|
||||||
|
8. **Cancel**: Warning modal → Cancel → verify sheet opens, changes preserved
|
||||||
|
9. **Browser refresh**: Make changes → F5 → verify beforeunload warning
|
||||||
|
10. **Tab close**: Make changes → close tab → verify beforeunload warning
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues arise, the immediate fix is to re-enable auto-save for Theme/Site tabs by calling the persistence functions in the event handlers again. The dirty tracking can remain in place.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Undo/redo for Theme/Site**: Currently only Page tab has history
|
||||||
|
- **Draft persistence**: Save drafts to localStorage for recovery after crash
|
||||||
|
- **Keyboard shortcuts**: Cmd+S to save all
|
||||||
@ -176,7 +176,6 @@ defmodule Berrypod.Backup do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp process_table_stats(tables) do
|
defp process_table_stats(tables) do
|
||||||
|
|
||||||
table_names = Enum.map(tables, fn [name] -> name end)
|
table_names = Enum.map(tables, fn [name] -> name end)
|
||||||
|
|
||||||
# Get sizes via dbstat if available
|
# Get sizes via dbstat if available
|
||||||
@ -696,7 +695,9 @@ defmodule Berrypod.Backup do
|
|||||||
# Verify we can actually query
|
# Verify we can actually query
|
||||||
try do
|
try do
|
||||||
case Repo.query("SELECT 1") do
|
case Repo.query("SELECT 1") do
|
||||||
{:ok, _} -> :ok
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
Process.sleep(100)
|
Process.sleep(100)
|
||||||
wait_for_repo(attempts - 1)
|
wait_for_repo(attempts - 1)
|
||||||
@ -755,7 +756,6 @@ defmodule Berrypod.Backup do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
defp clear_ets_caches do
|
defp clear_ets_caches do
|
||||||
# Clear known ETS caches to ensure they get rebuilt from the new database
|
# Clear known ETS caches to ensure they get rebuilt from the new database
|
||||||
caches = [
|
caches = [
|
||||||
|
|||||||
431
lib/berrypod/site.ex
Normal file
431
lib/berrypod/site.ex
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
defmodule Berrypod.Site do
|
||||||
|
@moduledoc """
|
||||||
|
The Site context for managing site-wide content.
|
||||||
|
|
||||||
|
This includes navigation items, social links, announcement bar,
|
||||||
|
and footer content — everything that appears across all pages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias Berrypod.Repo
|
||||||
|
alias Berrypod.Site.{NavItem, SocialLink}
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
|
# ── Navigation items ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all navigation items for a location, ordered by position.
|
||||||
|
"""
|
||||||
|
def list_nav_items(location) when location in [:header, :footer, "header", "footer"] do
|
||||||
|
location = to_string(location)
|
||||||
|
|
||||||
|
NavItem
|
||||||
|
|> where([n], n.location == ^location)
|
||||||
|
|> order_by([n], asc: n.position)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single nav item by ID.
|
||||||
|
"""
|
||||||
|
def get_nav_item!(id), do: Repo.get!(NavItem, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a nav item.
|
||||||
|
"""
|
||||||
|
def create_nav_item(attrs \\ %{}) do
|
||||||
|
%NavItem{}
|
||||||
|
|> NavItem.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a nav item.
|
||||||
|
"""
|
||||||
|
def update_nav_item(%NavItem{} = nav_item, attrs) do
|
||||||
|
nav_item
|
||||||
|
|> NavItem.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a nav item.
|
||||||
|
"""
|
||||||
|
def delete_nav_item(%NavItem{} = nav_item) do
|
||||||
|
Repo.delete(nav_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a changeset for tracking nav item changes.
|
||||||
|
"""
|
||||||
|
def change_nav_item(%NavItem{} = nav_item, attrs \\ %{}) do
|
||||||
|
NavItem.changeset(nav_item, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reorders nav items by updating positions.
|
||||||
|
|
||||||
|
Takes a list of nav item IDs in the desired order.
|
||||||
|
"""
|
||||||
|
def reorder_nav_items(ids) when is_list(ids) do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
ids
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.each(fn {id, position} ->
|
||||||
|
from(n in NavItem, where: n.id == ^id)
|
||||||
|
|> Repo.update_all(set: [position: position])
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns nav items formatted for shop components.
|
||||||
|
|
||||||
|
Converts NavItem structs to the map format expected by layout components.
|
||||||
|
"""
|
||||||
|
def nav_items_for_shop(location) do
|
||||||
|
list_nav_items(location)
|
||||||
|
|> Enum.map(&nav_item_to_map/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp nav_item_to_map(%NavItem{} = item) do
|
||||||
|
%{
|
||||||
|
"label" => item.label,
|
||||||
|
"href" => item.url,
|
||||||
|
"slug" => slug_from_url(item.url),
|
||||||
|
"page_id" => item.page_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp slug_from_url("/"), do: "home"
|
||||||
|
defp slug_from_url("/collections" <> _), do: "collection"
|
||||||
|
defp slug_from_url("/products" <> _), do: "pdp"
|
||||||
|
defp slug_from_url("/" <> rest), do: String.split(rest, "/") |> List.first() || ""
|
||||||
|
defp slug_from_url(_), do: ""
|
||||||
|
|
||||||
|
# ── Social links ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all social links, ordered by position.
|
||||||
|
"""
|
||||||
|
def list_social_links do
|
||||||
|
SocialLink
|
||||||
|
|> order_by([s], asc: s.position)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single social link by ID.
|
||||||
|
"""
|
||||||
|
def get_social_link!(id), do: Repo.get!(SocialLink, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a social link.
|
||||||
|
"""
|
||||||
|
def create_social_link(attrs \\ %{}) do
|
||||||
|
%SocialLink{}
|
||||||
|
|> SocialLink.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a social link.
|
||||||
|
"""
|
||||||
|
def update_social_link(%SocialLink{} = social_link, attrs) do
|
||||||
|
social_link
|
||||||
|
|> SocialLink.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a social link.
|
||||||
|
"""
|
||||||
|
def delete_social_link(%SocialLink{} = social_link) do
|
||||||
|
Repo.delete(social_link)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a changeset for tracking social link changes.
|
||||||
|
"""
|
||||||
|
def change_social_link(%SocialLink{} = social_link, attrs \\ %{}) do
|
||||||
|
SocialLink.changeset(social_link, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reorders social links by updating positions.
|
||||||
|
"""
|
||||||
|
def reorder_social_links(ids) when is_list(ids) do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
ids
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.each(fn {id, position} ->
|
||||||
|
from(s in SocialLink, where: s.id == ^id)
|
||||||
|
|> Repo.update_all(set: [position: position])
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns social links formatted for shop components.
|
||||||
|
|
||||||
|
Filters out links with empty URLs (incomplete entries from the editor).
|
||||||
|
"""
|
||||||
|
def social_links_for_shop do
|
||||||
|
list_social_links()
|
||||||
|
|> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end)
|
||||||
|
|> Enum.map(fn link ->
|
||||||
|
%{
|
||||||
|
platform: String.to_existing_atom(link.platform),
|
||||||
|
url: link.url,
|
||||||
|
label: platform_label(link.platform)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp platform_label("instagram"), do: "Instagram"
|
||||||
|
defp platform_label("pinterest"), do: "Pinterest"
|
||||||
|
defp platform_label("tiktok"), do: "TikTok"
|
||||||
|
defp platform_label("facebook"), do: "Facebook"
|
||||||
|
defp platform_label("twitter"), do: "Twitter"
|
||||||
|
defp platform_label("youtube"), do: "YouTube"
|
||||||
|
defp platform_label("patreon"), do: "Patreon"
|
||||||
|
defp platform_label("kofi"), do: "Ko-fi"
|
||||||
|
defp platform_label("etsy"), do: "Etsy"
|
||||||
|
defp platform_label("gumroad"), do: "Gumroad"
|
||||||
|
defp platform_label("bandcamp"), do: "Bandcamp"
|
||||||
|
defp platform_label("mastodon"), do: "Mastodon"
|
||||||
|
defp platform_label("pixelfed"), do: "Pixelfed"
|
||||||
|
defp platform_label("bluesky"), do: "Bluesky"
|
||||||
|
defp platform_label("peertube"), do: "PeerTube"
|
||||||
|
defp platform_label("lemmy"), do: "Lemmy"
|
||||||
|
defp platform_label("matrix"), do: "Matrix"
|
||||||
|
defp platform_label("github"), do: "GitHub"
|
||||||
|
defp platform_label("gitlab"), do: "GitLab"
|
||||||
|
defp platform_label("codeberg"), do: "Codeberg"
|
||||||
|
defp platform_label("sourcehut"), do: "SourceHut"
|
||||||
|
defp platform_label(other), do: String.capitalize(other)
|
||||||
|
|
||||||
|
# ── Announcement bar ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the announcement bar text.
|
||||||
|
"""
|
||||||
|
def announcement_text do
|
||||||
|
Settings.get_setting("announcement_text", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the announcement bar text.
|
||||||
|
"""
|
||||||
|
def set_announcement_text(text) when is_binary(text) do
|
||||||
|
Settings.put_setting("announcement_text", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the announcement bar link URL.
|
||||||
|
"""
|
||||||
|
def announcement_link do
|
||||||
|
Settings.get_setting("announcement_link", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the announcement bar link URL.
|
||||||
|
"""
|
||||||
|
def set_announcement_link(url) when is_binary(url) do
|
||||||
|
Settings.put_setting("announcement_link", url)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the announcement bar style (info, sale, warning).
|
||||||
|
"""
|
||||||
|
def announcement_style do
|
||||||
|
Settings.get_setting("announcement_style", "info")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the announcement bar style.
|
||||||
|
"""
|
||||||
|
def set_announcement_style(style) when style in ["info", "sale", "warning"] do
|
||||||
|
Settings.put_setting("announcement_style", style)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Footer content ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the footer about text.
|
||||||
|
"""
|
||||||
|
def footer_about do
|
||||||
|
Settings.get_setting("footer_about", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the footer about text.
|
||||||
|
"""
|
||||||
|
def set_footer_about(text) when is_binary(text) do
|
||||||
|
Settings.put_setting("footer_about", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the footer copyright text.
|
||||||
|
|
||||||
|
Returns empty string if not set (caller should generate default).
|
||||||
|
"""
|
||||||
|
def footer_copyright do
|
||||||
|
Settings.get_setting("footer_copyright", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the footer copyright text.
|
||||||
|
|
||||||
|
Pass empty string to use auto-generated copyright.
|
||||||
|
"""
|
||||||
|
def set_footer_copyright(text) when is_binary(text) do
|
||||||
|
Settings.put_setting("footer_copyright", text)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the newsletter signup should be shown in the footer.
|
||||||
|
"""
|
||||||
|
def show_newsletter? do
|
||||||
|
Settings.get_setting("show_newsletter", true) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets whether the newsletter signup should be shown in the footer.
|
||||||
|
"""
|
||||||
|
def set_show_newsletter(show?) when is_boolean(show?) do
|
||||||
|
Settings.put_setting("show_newsletter", show?, "boolean")
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Bulk updates ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the announcement bar settings.
|
||||||
|
"""
|
||||||
|
def put_announcement(text, link, style) do
|
||||||
|
set_announcement_text(text)
|
||||||
|
set_announcement_link(link)
|
||||||
|
set_announcement_style(style)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the footer content settings.
|
||||||
|
"""
|
||||||
|
def put_footer_content(about, copyright, show_newsletter) do
|
||||||
|
set_footer_about(about)
|
||||||
|
set_footer_copyright(copyright)
|
||||||
|
set_show_newsletter(show_newsletter)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates multiple site settings at once.
|
||||||
|
|
||||||
|
Accepts a map with keys like :announcement_text, :footer_about, etc.
|
||||||
|
"""
|
||||||
|
def update_settings(attrs) when is_map(attrs) do
|
||||||
|
Enum.each(attrs, fn
|
||||||
|
{:announcement_text, value} -> set_announcement_text(value || "")
|
||||||
|
{:announcement_link, value} -> set_announcement_link(value || "")
|
||||||
|
{:announcement_style, value} -> set_announcement_style(value || "info")
|
||||||
|
{:footer_about, value} -> set_footer_about(value || "")
|
||||||
|
{:footer_copyright, value} -> set_footer_copyright(value || "")
|
||||||
|
{:show_newsletter, value} -> set_show_newsletter(value == true)
|
||||||
|
_ -> :ok
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns all site settings as a map.
|
||||||
|
"""
|
||||||
|
def get_settings do
|
||||||
|
%{
|
||||||
|
announcement_text: announcement_text(),
|
||||||
|
announcement_link: announcement_link(),
|
||||||
|
announcement_style: announcement_style(),
|
||||||
|
footer_about: footer_about(),
|
||||||
|
footer_copyright: footer_copyright(),
|
||||||
|
show_newsletter: show_newsletter?()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Seeding ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@default_header_nav [
|
||||||
|
%{label: "Home", url: "/", position: 0},
|
||||||
|
%{label: "Shop", url: "/collections/all", position: 1},
|
||||||
|
%{label: "About", url: "/about", position: 2},
|
||||||
|
%{label: "Contact", url: "/contact", position: 3}
|
||||||
|
]
|
||||||
|
|
||||||
|
@default_footer_nav [
|
||||||
|
%{label: "Delivery & returns", url: "/delivery", position: 0},
|
||||||
|
%{label: "Privacy policy", url: "/privacy", position: 1},
|
||||||
|
%{label: "Terms of service", url: "/terms", position: 2},
|
||||||
|
%{label: "Contact", url: "/contact", position: 3}
|
||||||
|
]
|
||||||
|
|
||||||
|
@default_social_links [
|
||||||
|
%{platform: "instagram", url: "https://instagram.com", position: 0},
|
||||||
|
%{platform: "bluesky", url: "https://bsky.app", position: 1}
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Seeds default navigation items and social links if none exist.
|
||||||
|
|
||||||
|
Safe to call multiple times — only inserts if tables are empty.
|
||||||
|
"""
|
||||||
|
def seed_defaults do
|
||||||
|
seed_nav_items()
|
||||||
|
seed_social_links()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_nav_items do
|
||||||
|
if Repo.aggregate(NavItem, :count) == 0 do
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
header_items =
|
||||||
|
Enum.map(@default_header_nav, fn item ->
|
||||||
|
Map.merge(item, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
location: "header",
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
footer_items =
|
||||||
|
Enum.map(@default_footer_nav, fn item ->
|
||||||
|
Map.merge(item, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
location: "footer",
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
Repo.insert_all(NavItem, header_items ++ footer_items)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_social_links do
|
||||||
|
if Repo.aggregate(SocialLink, :count) == 0 do
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
links =
|
||||||
|
Enum.map(@default_social_links, fn item ->
|
||||||
|
Map.merge(item, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
Repo.insert_all(SocialLink, links)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
32
lib/berrypod/site/nav_item.ex
Normal file
32
lib/berrypod/site/nav_item.ex
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
defmodule Berrypod.Site.NavItem do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
@locations ~w(header footer)
|
||||||
|
|
||||||
|
schema "nav_items" do
|
||||||
|
field :location, :string
|
||||||
|
field :label, :string
|
||||||
|
field :url, :string
|
||||||
|
field :position, :integer, default: 0
|
||||||
|
|
||||||
|
belongs_to :page, Berrypod.Pages.Page
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def locations, do: @locations
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(nav_item, attrs) do
|
||||||
|
nav_item
|
||||||
|
|> cast(attrs, [:location, :label, :url, :page_id, :position])
|
||||||
|
|> validate_required([:location, :label, :url])
|
||||||
|
|> validate_inclusion(:location, @locations)
|
||||||
|
|> validate_length(:label, min: 1, max: 50)
|
||||||
|
|> foreign_key_constraint(:page_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
216
lib/berrypod/site/social_link.ex
Normal file
216
lib/berrypod/site/social_link.ex
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
defmodule Berrypod.Site.SocialLink do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
# Grouped by category for the editor dropdown
|
||||||
|
@platform_groups [
|
||||||
|
{"Social", ~w(instagram threads tiktok facebook twitter snapchat linkedin)},
|
||||||
|
{"Video & streaming", ~w(youtube twitch vimeo kick rumble)},
|
||||||
|
{"Music & podcasts", ~w(spotify soundcloud bandcamp applepodcasts)},
|
||||||
|
{"Creative", ~w(pinterest behance dribbble tumblr medium)},
|
||||||
|
{"Support & sales", ~w(patreon kofi etsy gumroad substack)},
|
||||||
|
{"Federated", ~w(mastodon pixelfed bluesky peertube lemmy matrix)},
|
||||||
|
{"Developer", ~w(github gitlab codeberg sourcehut reddit)},
|
||||||
|
{"Messaging", ~w(discord telegram signal whatsapp)},
|
||||||
|
{"Other", ~w(linktree rss website custom)}
|
||||||
|
]
|
||||||
|
|
||||||
|
@platforms @platform_groups |> Enum.flat_map(fn {_, platforms} -> platforms end)
|
||||||
|
|
||||||
|
schema "social_links" do
|
||||||
|
field :platform, :string
|
||||||
|
field :url, :string
|
||||||
|
field :position, :integer, default: 0
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def platforms, do: @platforms
|
||||||
|
def platform_groups, do: @platform_groups
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Normalizes a URL by trimming whitespace and adding https:// if missing.
|
||||||
|
|
||||||
|
Returns the normalized URL string, or the original if empty/nil.
|
||||||
|
"""
|
||||||
|
def normalize_url(nil), do: nil
|
||||||
|
def normalize_url(""), do: ""
|
||||||
|
|
||||||
|
def normalize_url(url) when is_binary(url) do
|
||||||
|
url = String.trim(url)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
url == "" -> ""
|
||||||
|
# Preserve existing protocols (http, https, and app-specific deep links)
|
||||||
|
String.contains?(url, "://") -> url
|
||||||
|
# Preserve other URI schemes (mailto:, tel:, etc.)
|
||||||
|
Regex.match?(~r/^[a-z][a-z0-9+.-]*:/i, url) -> url
|
||||||
|
# Default to https for bare domains
|
||||||
|
true -> "https://" <> url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Detects the platform from a URL by matching the domain.
|
||||||
|
|
||||||
|
Returns the platform string if detected, or "custom" for unknown domains.
|
||||||
|
Returns nil for invalid URLs. Automatically normalizes the URL first.
|
||||||
|
"""
|
||||||
|
def detect_platform(url) when is_binary(url) do
|
||||||
|
url
|
||||||
|
|> normalize_url()
|
||||||
|
|> do_detect_platform()
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_platform(_), do: nil
|
||||||
|
|
||||||
|
defp do_detect_platform(""), do: nil
|
||||||
|
defp do_detect_platform(nil), do: nil
|
||||||
|
|
||||||
|
# Scheme-to-platform mapping for app deep links and custom protocols
|
||||||
|
@scheme_platforms %{
|
||||||
|
# RSS/feeds
|
||||||
|
"rss" => "rss",
|
||||||
|
"feed" => "rss",
|
||||||
|
# Social
|
||||||
|
"instagram" => "instagram",
|
||||||
|
"fb" => "facebook",
|
||||||
|
"twitter" => "twitter",
|
||||||
|
"snapchat" => "snapchat",
|
||||||
|
"linkedin" => "linkedin",
|
||||||
|
# Video & streaming
|
||||||
|
"youtube" => "youtube",
|
||||||
|
"vnd.youtube" => "youtube",
|
||||||
|
"twitch" => "twitch",
|
||||||
|
"vimeo" => "vimeo",
|
||||||
|
# Music & podcasts
|
||||||
|
"spotify" => "spotify",
|
||||||
|
"soundcloud" => "soundcloud",
|
||||||
|
"bandcamp" => "bandcamp",
|
||||||
|
"podcasts" => "applepodcasts",
|
||||||
|
"itms-podcasts" => "applepodcasts",
|
||||||
|
# Creative
|
||||||
|
"pinterest" => "pinterest",
|
||||||
|
"tumblr" => "tumblr",
|
||||||
|
# Support & sales
|
||||||
|
"patreon" => "patreon",
|
||||||
|
# Federated
|
||||||
|
"matrix" => "matrix",
|
||||||
|
# Developer
|
||||||
|
"github" => "github",
|
||||||
|
"github-mac" => "github",
|
||||||
|
"github-windows" => "github",
|
||||||
|
"x-github-client" => "github",
|
||||||
|
"gitlab" => "gitlab",
|
||||||
|
"reddit" => "reddit",
|
||||||
|
# Messaging
|
||||||
|
"discord" => "discord",
|
||||||
|
"tg" => "telegram",
|
||||||
|
"telegram" => "telegram",
|
||||||
|
"sgnl" => "signal",
|
||||||
|
"signal" => "signal",
|
||||||
|
"whatsapp" => "whatsapp"
|
||||||
|
}
|
||||||
|
|
||||||
|
defp do_detect_platform(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
# Scheme-based detection for app deep links
|
||||||
|
%URI{scheme: scheme} when is_map_key(@scheme_platforms, scheme) ->
|
||||||
|
@scheme_platforms[scheme]
|
||||||
|
|
||||||
|
# Host-based detection (the common case)
|
||||||
|
%URI{host: host} when is_binary(host) ->
|
||||||
|
host
|
||||||
|
|> String.downcase()
|
||||||
|
|> String.replace_prefix("www.", "")
|
||||||
|
|> detect_from_host()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
# Video & streaming
|
||||||
|
"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",
|
||||||
|
"podcasts.apple.com" => "applepodcasts",
|
||||||
|
# Creative
|
||||||
|
"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",
|
||||||
|
# Federated
|
||||||
|
"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",
|
||||||
|
# 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",
|
||||||
|
# Other
|
||||||
|
"linktr.ee" => "linktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Subdomain suffixes for user-specific URLs (e.g., user.bandcamp.com)
|
||||||
|
@subdomain_platforms [
|
||||||
|
{".bandcamp.com", "bandcamp"},
|
||||||
|
{".substack.com", "substack"},
|
||||||
|
{".tumblr.com", "tumblr"},
|
||||||
|
{".medium.com", "medium"}
|
||||||
|
]
|
||||||
|
|
||||||
|
defp detect_from_host(host) do
|
||||||
|
Map.get_lazy(@host_platforms, host, fn ->
|
||||||
|
Enum.find_value(@subdomain_platforms, "custom", fn {suffix, platform} ->
|
||||||
|
if String.ends_with?(host, suffix), do: platform
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(social_link, attrs) do
|
||||||
|
social_link
|
||||||
|
|> cast(attrs, [:platform, :url, :position])
|
||||||
|
|> validate_required([:platform])
|
||||||
|
|> validate_inclusion(:platform, @platforms)
|
||||||
|
|> validate_url(:url)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_url(changeset, field) do
|
||||||
|
validate_change(changeset, field, fn _, value ->
|
||||||
|
# Allow empty/blank URLs (for newly added links)
|
||||||
|
if value == "" or is_nil(value) do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
case URI.parse(value) do
|
||||||
|
# Accept any URL with a valid scheme (http, https, app deep links, etc.)
|
||||||
|
%URI{scheme: scheme} when is_binary(scheme) and scheme != "" ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[{field, "must be a valid URL"}]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -713,6 +713,143 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Additional social platforms
|
||||||
|
defp social_icon(%{platform: :linkedin} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :threads} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :whatsapp} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :twitch} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :spotify} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :soundcloud} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M1.175 12.225c-.051 0-.094.046-.101.1l-.233 2.154.233 2.105c.007.058.05.098.101.098.05 0 .09-.04.099-.098l.255-2.105-.27-2.154c0-.057-.045-.1-.09-.1m-.899.828c-.06 0-.091.037-.104.094L0 14.479l.165 1.308c0 .055.045.094.09.094s.089-.045.104-.104l.21-1.319-.21-1.334c0-.061-.044-.09-.09-.09m1.83-1.229c-.061 0-.12.045-.12.104l-.21 2.563.225 2.458c0 .06.045.12.119.12.061 0 .105-.061.121-.12l.254-2.474-.254-2.548c-.016-.06-.061-.12-.121-.12m.945-.089c-.075 0-.135.06-.15.135l-.193 2.64.21 2.544c.016.077.075.138.149.138.075 0 .135-.061.15-.15l.24-2.532-.24-2.623c0-.075-.06-.135-.135-.135l-.031-.017zm1.155.36c-.005-.09-.075-.149-.159-.149-.09 0-.158.06-.164.149l-.217 2.43.2 2.563c0 .09.075.157.159.157.074 0 .148-.068.148-.158l.227-2.563-.227-2.444.033.015zm.809-1.709c-.101 0-.18.09-.18.181l-.21 3.957.187 2.563c0 .09.08.164.18.164.094 0 .174-.09.18-.18l.209-2.563-.209-3.972c-.008-.104-.088-.18-.18-.18m.959-.914c-.105 0-.195.09-.203.194l-.18 4.872.165 2.548c0 .12.09.209.195.209.104 0 .194-.089.21-.209l.193-2.548-.192-4.856c-.016-.12-.105-.21-.21-.21m.989-.449c-.121 0-.211.089-.225.209l-.165 5.275.165 2.52c.014.119.104.225.225.225.119 0 .225-.105.225-.225l.195-2.52-.196-5.275c0-.12-.105-.225-.225-.225m1.245.045c0-.135-.105-.24-.24-.24-.119 0-.24.105-.24.24l-.149 5.441.149 2.503c.016.135.121.24.256.24s.24-.105.24-.24l.164-2.503-.164-5.456-.016.015zm.749-.134c-.135 0-.255.119-.255.254l-.15 5.322.15 2.473c0 .15.12.255.255.255s.255-.12.255-.27l.15-2.474-.165-5.307c0-.148-.12-.27-.271-.27m1.005.166c-.164 0-.284.135-.284.285l-.103 5.143.135 2.474c0 .149.119.277.284.277.149 0 .271-.12.284-.285l.121-2.443-.135-5.112c-.012-.164-.135-.285-.285-.285m1.184-.945c-.045-.029-.105-.044-.165-.044s-.119.015-.165.044c-.09.054-.149.15-.149.255v.061l-.104 6.048.115 2.449v.008c.008.06.03.135.074.18.058.061.142.104.234.104.08 0 .158-.044.209-.09.058-.06.091-.135.091-.225l.015-.24.117-2.203-.135-6.086c0-.104-.061-.193-.135-.239l-.002-.022zm1.006-.547c-.045-.045-.09-.061-.15-.061-.074 0-.149.016-.209.061-.075.061-.119.15-.119.24v.029l-.137 6.609.076 1.215.061 1.185c0 .164.148.314.328.314.181 0 .33-.15.33-.329l.15-2.414-.15-6.637c0-.12-.074-.221-.165-.277m8.934 3.777c-.405 0-.795.086-1.139.232-.24-2.654-2.46-4.736-5.188-4.736-.659 0-1.305.135-1.889.359-.225.09-.27.18-.285.359v9.368c.016.18.15.33.33.345h8.185C22.681 17.218 24 15.914 24 14.28s-1.319-2.952-2.938-2.952" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :vimeo} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :behance} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16.969 16.927a2.561 2.561 0 0 0 1.901.677 2.501 2.501 0 0 0 1.531-.475c.362-.235.636-.584.779-.99h2.585a5.091 5.091 0 0 1-1.9 2.896 5.292 5.292 0 0 1-3.091.88 5.839 5.839 0 0 1-2.284-.433 4.871 4.871 0 0 1-1.723-1.211 5.657 5.657 0 0 1-1.08-1.874 7.057 7.057 0 0 1-.383-2.393c-.005-.8.129-1.595.396-2.349a5.313 5.313 0 0 1 5.088-3.604 4.87 4.87 0 0 1 2.376.563c.661.362 1.231.87 1.668 1.485a6.2 6.2 0 0 1 .943 2.133c.194.821.263 1.666.205 2.508h-7.699c-.063.79.184 1.574.688 2.187ZM6.947 4.084a8.065 8.065 0 0 1 1.928.198 4.29 4.29 0 0 1 1.49.638c.418.303.748.711.958 1.182.241.579.357 1.203.341 1.83a3.506 3.506 0 0 1-.506 1.961 3.726 3.726 0 0 1-1.503 1.287 3.588 3.588 0 0 1 2.027 1.437c.464.747.697 1.615.67 2.494a4.593 4.593 0 0 1-.423 2.032 3.945 3.945 0 0 1-1.163 1.413 5.114 5.114 0 0 1-1.683.807 7.135 7.135 0 0 1-1.928.259H0V4.084h6.947Zm-.235 12.9c.308.004.616-.029.916-.099a2.18 2.18 0 0 0 .766-.332c.228-.158.411-.371.534-.619.142-.317.208-.663.191-1.009a2.08 2.08 0 0 0-.642-1.715 2.618 2.618 0 0 0-1.696-.505h-3.54v4.279h3.471Zm13.635-5.967a2.13 2.13 0 0 0-1.654-.619 2.336 2.336 0 0 0-1.163.259 2.474 2.474 0 0 0-.738.62 2.359 2.359 0 0 0-.396.792c-.074.239-.12.485-.137.734h4.769a3.239 3.239 0 0 0-.679-1.785l-.002-.001Zm-13.813-.648a2.254 2.254 0 0 0 1.423-.433c.399-.355.607-.88.56-1.413a1.916 1.916 0 0 0-.178-.891 1.298 1.298 0 0 0-.495-.533 1.851 1.851 0 0 0-.711-.274 3.966 3.966 0 0 0-.835-.073H3.241v3.631h3.293v-.014ZM21.62 5.122h-5.976v1.527h5.976V5.122Z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :dribbble} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm10.12-10.358c-.35-.11-3.17-.953-6.384-.438 1.34 3.684 1.887 6.684 1.992 7.308 2.3-1.555 3.936-4.02 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4 1.73 1.358 3.92 2.166 6.29 2.166 1.42 0 2.77-.29 4-.814zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765.135-.045.27-.084.405-.12-.26-.585-.54-1.167-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248-1.698-3.018-3.53-5.558-3.8-5.928-2.868 1.35-5.01 3.99-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6 3.645-1.365 5.19-3.44 5.373-3.702-1.81-1.61-4.19-2.586-6.795-2.586-.825 0-1.63.1-2.4.285zm10.335 3.483c-.218.29-1.935 2.493-5.724 4.04.24.49.47.985.68 1.486.08.18.15.36.22.53 3.41-.43 6.8.26 7.14.33-.02-2.42-.88-4.64-2.31-6.38z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :linktree} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :snapchat} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :reddit} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :medium} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M13.54 12a6.8 6.8 0 01-6.77 6.82A6.8 6.8 0 010 12a6.8 6.8 0 016.77-6.82A6.8 6.8 0 0113.54 12zM20.96 12c0 3.54-1.51 6.42-3.38 6.42-1.87 0-3.39-2.88-3.39-6.42s1.52-6.42 3.39-6.42 3.38 2.88 3.38 6.42M24 12c0 3.17-.53 5.75-1.19 5.75-.66 0-1.19-2.58-1.19-5.75s.53-5.75 1.19-5.75C23.47 6.25 24 8.83 24 12z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :tumblr} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :applepodcasts} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :kick} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp social_icon(%{platform: :rumble} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14.4528 13.5458c.8064-.6542.9297-1.8381.2756-2.6445a1.8802 1.8802 0 0 0-.2756-.2756 21.2127 21.2127 0 0 0-4.3121-2.776c-1.066-.51-2.256.2-2.4261 1.414a23.5226 23.5226 0 0 0-.14 5.5021c.116 1.23 1.292 1.964 2.372 1.492a19.6285 19.6285 0 0 0 4.5062-2.704v-.008zm6.9322-5.4002c2.0335 2.228 2.0396 5.637.014 7.8723A26.1487 26.1487 0 0 1 8.2946 23.846c-2.6848.6713-5.4168-.914-6.1662-3.5781-1.524-5.2002-1.3-11.0803.17-16.3045.772-2.744 3.3521-4.4661 6.0102-3.832 4.9242 1.174 9.5443 4.196 13.0764 8.0121v.002z" />
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
# Fallback for unknown platforms
|
# Fallback for unknown platforms
|
||||||
defp social_icon(assigns) do
|
defp social_icon(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|||||||
@ -13,21 +13,40 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
## Attributes
|
## Attributes
|
||||||
|
|
||||||
* `theme_settings` - Required. The theme settings map.
|
* `theme_settings` - Required. The theme settings map.
|
||||||
* `message` - Optional. The announcement message to display.
|
* `message` - The announcement message to display.
|
||||||
Defaults to "Free delivery on orders over £40".
|
* `link` - Optional URL to link the announcement to.
|
||||||
|
* `style` - Visual style: "info", "sale", or "warning".
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
<.announcement_bar theme_settings={@theme_settings} />
|
<.announcement_bar theme_settings={@theme_settings} message="Free shipping!" />
|
||||||
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
|
<.announcement_bar theme_settings={@theme_settings} message="20% off!" link="/sale" style="sale" />
|
||||||
"""
|
"""
|
||||||
attr :theme_settings, :map, required: true
|
attr :theme_settings, :map, required: true
|
||||||
attr :message, :string, default: "Sample announcement – e.g. free delivery, sales, or new drops"
|
attr :message, :string, default: ""
|
||||||
|
attr :link, :string, default: ""
|
||||||
|
attr :style, :string, default: "info"
|
||||||
|
|
||||||
def announcement_bar(assigns) do
|
def announcement_bar(assigns) do
|
||||||
|
# Use default message if none provided
|
||||||
|
message =
|
||||||
|
if assigns.message in ["", nil] do
|
||||||
|
"Sample announcement – e.g. free delivery, sales, or new drops"
|
||||||
|
else
|
||||||
|
assigns.message
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns = assign(assigns, :display_message, message)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="announcement-bar">
|
<div class={"announcement-bar announcement-bar--#{@style}"}>
|
||||||
<p>{@message}</p>
|
<%= if @link != "" do %>
|
||||||
|
<a href={@link} class="announcement-bar-link">
|
||||||
|
<p>{@display_message}</p>
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<p>{@display_message}</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@ -53,7 +72,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
search_query search_results search_open categories shipping_estimate
|
search_query search_results search_open categories shipping_estimate
|
||||||
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
|
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
|
||||||
editor_active_tab editor_sheet_state editor_dirty editor_save_status
|
editor_active_tab editor_sheet_state editor_dirty editor_save_status
|
||||||
header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a
|
header_nav_items footer_nav_items social_links announcement_text announcement_link announcement_style
|
||||||
|
newsletter_enabled newsletter_state stripe_connected)a
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
||||||
@ -65,9 +85,106 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</.shop_layout>
|
</.shop_layout>
|
||||||
"""
|
"""
|
||||||
def layout_assigns(assigns) do
|
def layout_assigns(assigns) do
|
||||||
Map.take(assigns, @layout_keys)
|
base = Map.take(assigns, @layout_keys)
|
||||||
|
|
||||||
|
# When site editor is active, use in-memory values for live preview
|
||||||
|
# The site_* assigns are the editor's working copies, while announcement_*
|
||||||
|
# and social_links are the database-loaded values from theme_hook
|
||||||
|
# Only override when site_editing is true (editor has loaded site state)
|
||||||
|
if assigns[:site_editing] do
|
||||||
|
# Convert raw SocialLink structs to shop format
|
||||||
|
social_links = format_social_links_for_shop(assigns[:site_social_links] || [])
|
||||||
|
|
||||||
|
base
|
||||||
|
|> Map.put(:announcement_text, assigns[:site_announcement_text])
|
||||||
|
|> Map.put(:announcement_link, assigns[:site_announcement_link])
|
||||||
|
|> Map.put(:announcement_style, assigns[:site_announcement_style])
|
||||||
|
|> Map.put(:social_links, social_links)
|
||||||
|
else
|
||||||
|
base
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Convert raw SocialLink structs to the format expected by shop components
|
||||||
|
# Filters out links with empty URLs (incomplete entries still being edited)
|
||||||
|
# Using String.to_atom is safe here because platforms are validated by the schema
|
||||||
|
defp format_social_links_for_shop(links) do
|
||||||
|
links
|
||||||
|
|> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end)
|
||||||
|
|> Enum.map(fn link ->
|
||||||
|
platform = if is_binary(link.platform), do: link.platform, else: to_string(link.platform)
|
||||||
|
|
||||||
|
%{
|
||||||
|
platform: String.to_atom(platform),
|
||||||
|
url: link.url,
|
||||||
|
label: platform_display_label(platform)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Social
|
||||||
|
defp platform_display_label("instagram"), do: "Instagram"
|
||||||
|
defp platform_display_label("threads"), do: "Threads"
|
||||||
|
defp platform_display_label("facebook"), do: "Facebook"
|
||||||
|
defp platform_display_label("twitter"), do: "Twitter"
|
||||||
|
defp platform_display_label("snapchat"), do: "Snapchat"
|
||||||
|
defp platform_display_label("linkedin"), do: "LinkedIn"
|
||||||
|
|
||||||
|
# Video & streaming
|
||||||
|
defp platform_display_label("youtube"), do: "YouTube"
|
||||||
|
defp platform_display_label("twitch"), do: "Twitch"
|
||||||
|
defp platform_display_label("vimeo"), do: "Vimeo"
|
||||||
|
defp platform_display_label("kick"), do: "Kick"
|
||||||
|
defp platform_display_label("rumble"), do: "Rumble"
|
||||||
|
|
||||||
|
# Music & podcasts
|
||||||
|
defp platform_display_label("spotify"), do: "Spotify"
|
||||||
|
defp platform_display_label("soundcloud"), do: "SoundCloud"
|
||||||
|
defp platform_display_label("bandcamp"), do: "Bandcamp"
|
||||||
|
defp platform_display_label("applepodcasts"), do: "Podcasts"
|
||||||
|
|
||||||
|
# Creative
|
||||||
|
defp platform_display_label("pinterest"), do: "Pinterest"
|
||||||
|
defp platform_display_label("behance"), do: "Behance"
|
||||||
|
defp platform_display_label("dribbble"), do: "Dribbble"
|
||||||
|
defp platform_display_label("tumblr"), do: "Tumblr"
|
||||||
|
defp platform_display_label("medium"), do: "Medium"
|
||||||
|
|
||||||
|
# Support & sales
|
||||||
|
defp platform_display_label("patreon"), do: "Patreon"
|
||||||
|
defp platform_display_label("kofi"), do: "Ko-fi"
|
||||||
|
defp platform_display_label("etsy"), do: "Etsy"
|
||||||
|
defp platform_display_label("gumroad"), do: "Gumroad"
|
||||||
|
defp platform_display_label("substack"), do: "Substack"
|
||||||
|
|
||||||
|
# Federated
|
||||||
|
defp platform_display_label("mastodon"), do: "Mastodon"
|
||||||
|
defp platform_display_label("pixelfed"), do: "Pixelfed"
|
||||||
|
defp platform_display_label("bluesky"), do: "Bluesky"
|
||||||
|
defp platform_display_label("peertube"), do: "PeerTube"
|
||||||
|
defp platform_display_label("lemmy"), do: "Lemmy"
|
||||||
|
defp platform_display_label("matrix"), do: "Matrix"
|
||||||
|
|
||||||
|
# Developer
|
||||||
|
defp platform_display_label("github"), do: "GitHub"
|
||||||
|
defp platform_display_label("gitlab"), do: "GitLab"
|
||||||
|
defp platform_display_label("codeberg"), do: "Codeberg"
|
||||||
|
defp platform_display_label("sourcehut"), do: "SourceHut"
|
||||||
|
defp platform_display_label("reddit"), do: "Reddit"
|
||||||
|
|
||||||
|
# Messaging
|
||||||
|
defp platform_display_label("discord"), do: "Discord"
|
||||||
|
defp platform_display_label("telegram"), do: "Telegram"
|
||||||
|
defp platform_display_label("signal"), do: "Signal"
|
||||||
|
defp platform_display_label("whatsapp"), do: "WhatsApp"
|
||||||
|
|
||||||
|
# Other
|
||||||
|
defp platform_display_label("linktree"), do: "Linktree"
|
||||||
|
defp platform_display_label("rss"), do: "RSS"
|
||||||
|
defp platform_display_label("website"), do: "Website"
|
||||||
|
defp platform_display_label("custom"), do: "Link"
|
||||||
|
defp platform_display_label(other), do: String.capitalize(other)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Wraps page content in the standard shop shell: container, header, footer,
|
Wraps page content in the standard shop shell: container, header, footer,
|
||||||
cart drawer, search modal, and mobile bottom nav.
|
cart drawer, search modal, and mobile bottom nav.
|
||||||
@ -100,6 +217,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :available_countries, :list, default: []
|
attr :available_countries, :list, default: []
|
||||||
attr :header_nav_items, :list, default: []
|
attr :header_nav_items, :list, default: []
|
||||||
attr :footer_nav_items, :list, default: []
|
attr :footer_nav_items, :list, default: []
|
||||||
|
attr :social_links, :list, default: []
|
||||||
|
attr :announcement_text, :string, default: ""
|
||||||
|
attr :announcement_link, :string, default: ""
|
||||||
|
attr :announcement_style, :string, default: "info"
|
||||||
attr :newsletter_enabled, :boolean, default: false
|
attr :newsletter_enabled, :boolean, default: false
|
||||||
attr :newsletter_state, :atom, default: :idle
|
attr :newsletter_state, :atom, default: :idle
|
||||||
attr :stripe_connected, :boolean, default: true
|
attr :stripe_connected, :boolean, default: true
|
||||||
@ -133,7 +254,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<.skip_link />
|
<.skip_link />
|
||||||
|
|
||||||
<%= if @theme_settings.announcement_bar do %>
|
<%= if @theme_settings.announcement_bar do %>
|
||||||
<.announcement_bar theme_settings={@theme_settings} />
|
<.announcement_bar
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
message={@announcement_text}
|
||||||
|
link={@announcement_link}
|
||||||
|
style={@announcement_style}
|
||||||
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<.shop_header
|
<.shop_header
|
||||||
@ -156,6 +282,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
mode={@mode}
|
mode={@mode}
|
||||||
categories={assigns[:categories] || []}
|
categories={assigns[:categories] || []}
|
||||||
footer_nav_items={@footer_nav_items}
|
footer_nav_items={@footer_nav_items}
|
||||||
|
social_links={@social_links}
|
||||||
newsletter_enabled={@newsletter_enabled}
|
newsletter_enabled={@newsletter_enabled}
|
||||||
newsletter_state={@newsletter_state}
|
newsletter_state={@newsletter_state}
|
||||||
/>
|
/>
|
||||||
@ -652,6 +779,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :mode, :atom, default: :live
|
attr :mode, :atom, default: :live
|
||||||
attr :categories, :list, default: []
|
attr :categories, :list, default: []
|
||||||
attr :footer_nav_items, :list, default: []
|
attr :footer_nav_items, :list, default: []
|
||||||
|
attr :social_links, :list, default: []
|
||||||
attr :newsletter_enabled, :boolean, default: false
|
attr :newsletter_enabled, :boolean, default: false
|
||||||
attr :newsletter_state, :atom, default: :idle
|
attr :newsletter_state, :atom, default: :idle
|
||||||
|
|
||||||
@ -750,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<p class="footer-copyright">
|
<p class="footer-copyright">
|
||||||
© {@current_year} {@site_name}
|
© {@current_year} {@site_name}
|
||||||
</p>
|
</p>
|
||||||
<.social_links />
|
<.social_links links={@social_links} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@ -1071,9 +1199,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :editing, :boolean, default: false
|
attr :editing, :boolean, default: false
|
||||||
attr :theme_editing, :boolean, default: false
|
attr :theme_editing, :boolean, default: false
|
||||||
attr :editor_dirty, :boolean, default: false
|
attr :editor_dirty, :boolean, default: false
|
||||||
|
attr :theme_dirty, :boolean, default: false
|
||||||
|
attr :site_dirty, :boolean, default: false
|
||||||
attr :editor_sheet_state, :atom, default: :collapsed
|
attr :editor_sheet_state, :atom, default: :collapsed
|
||||||
attr :editor_save_status, :atom, default: :idle
|
attr :editor_save_status, :atom, default: :idle
|
||||||
attr :editor_active_tab, :atom, default: :page
|
attr :editor_active_tab, :atom, default: :page
|
||||||
|
attr :editor_nav_blocked, :string, default: nil
|
||||||
attr :has_editable_page, :boolean, default: false
|
attr :has_editable_page, :boolean, default: false
|
||||||
|
|
||||||
slot :inner_block
|
slot :inner_block
|
||||||
@ -1084,16 +1215,21 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
case assigns.editor_active_tab do
|
case assigns.editor_active_tab do
|
||||||
:page -> "Page"
|
:page -> "Page"
|
||||||
:theme -> "Theme"
|
:theme -> "Theme"
|
||||||
|
:site -> "Site"
|
||||||
:settings -> "Settings"
|
:settings -> "Settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Any editing mode active
|
# Any editing mode active
|
||||||
any_editing = assigns.editing || assigns.theme_editing
|
any_editing = assigns.editing || assigns.theme_editing
|
||||||
|
|
||||||
|
# Any tab has unsaved changes
|
||||||
|
any_dirty = assigns.editor_dirty || assigns.theme_dirty || assigns.site_dirty
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:title, title)
|
|> assign(:title, title)
|
||||||
|> assign(:any_editing, any_editing)
|
|> assign(:any_editing, any_editing)
|
||||||
|
|> assign(:any_dirty, any_dirty)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%!-- Floating action button: always visible when panel is closed --%>
|
<%!-- Floating action button: always visible when panel is closed --%>
|
||||||
@ -1110,7 +1246,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
>
|
>
|
||||||
<.edit_pencil_svg />
|
<.edit_pencil_svg />
|
||||||
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
|
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
|
||||||
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
|
<span :if={@any_editing && @any_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<%!-- Overlay to catch taps outside the panel --%>
|
<%!-- Overlay to catch taps outside the panel --%>
|
||||||
@ -1131,6 +1267,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
|
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
|
||||||
data-state={@editor_sheet_state}
|
data-state={@editor_sheet_state}
|
||||||
data-editing={to_string(@any_editing)}
|
data-editing={to_string(@any_editing)}
|
||||||
|
data-dirty={to_string(@any_dirty)}
|
||||||
phx-hook="EditorSheet"
|
phx-hook="EditorSheet"
|
||||||
>
|
>
|
||||||
<%!-- Drag handle for mobile resizing --%>
|
<%!-- Drag handle for mobile resizing --%>
|
||||||
@ -1141,14 +1278,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<div class="editor-panel-header">
|
<div class="editor-panel-header">
|
||||||
<div class="editor-panel-header-left">
|
<div class="editor-panel-header-left">
|
||||||
<span class="editor-panel-title">{@title}</span>
|
<span class="editor-panel-title">{@title}</span>
|
||||||
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
|
<span :if={@any_dirty} class="editor-panel-dirty" aria-live="polite">
|
||||||
<span class="editor-panel-dirty-dot" aria-hidden="true" />
|
<span class="editor-panel-dirty-dot" aria-hidden="true" />
|
||||||
<span>Unsaved</span>
|
<span>Unsaved</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-panel-header-actions">
|
<div class="editor-panel-header-actions">
|
||||||
<button
|
<button
|
||||||
:if={@editor_active_tab == :page && @editor_save_status == :saved}
|
:if={@editor_save_status == :saved}
|
||||||
type="button"
|
type="button"
|
||||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||||
disabled
|
disabled
|
||||||
@ -1156,11 +1293,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
Saved ✓
|
Saved ✓
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:if={@editor_active_tab == :page && @editor_save_status != :saved}
|
:if={@editor_save_status != :saved}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="editor_save"
|
phx-click="editor_save_all"
|
||||||
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
|
class={["admin-btn admin-btn-sm", @any_dirty && "admin-btn-primary"]}
|
||||||
disabled={!@editor_dirty}
|
disabled={!@any_dirty}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@ -1201,6 +1338,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Page
|
Page
|
||||||
|
<span
|
||||||
|
:if={@editor_dirty}
|
||||||
|
class="editor-tab-dirty-dot"
|
||||||
|
aria-label="unsaved changes"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1211,16 +1353,26 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
aria-selected={to_string(@editor_active_tab == :theme)}
|
aria-selected={to_string(@editor_active_tab == :theme)}
|
||||||
>
|
>
|
||||||
Theme
|
Theme
|
||||||
|
<span
|
||||||
|
:if={@theme_dirty}
|
||||||
|
class="editor-tab-dirty-dot"
|
||||||
|
aria-label="unsaved changes"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
phx-click="editor_set_tab"
|
phx-click="editor_set_tab"
|
||||||
phx-value-tab="settings"
|
phx-value-tab="site"
|
||||||
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
|
class={["editor-tab", @editor_active_tab == :site && "editor-tab-active"]}
|
||||||
aria-selected={to_string(@editor_active_tab == :settings)}
|
aria-selected={to_string(@editor_active_tab == :site)}
|
||||||
>
|
>
|
||||||
Settings
|
Site
|
||||||
|
<span
|
||||||
|
:if={@site_dirty}
|
||||||
|
class="editor-tab-dirty-dot"
|
||||||
|
aria-label="unsaved changes"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1231,6 +1383,37 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
|
|
||||||
<%!-- Live region for screen reader announcements --%>
|
<%!-- Live region for screen reader announcements --%>
|
||||||
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
|
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
|
||||||
|
|
||||||
|
<%!-- Navigation warning modal --%>
|
||||||
|
<dialog :if={@editor_nav_blocked} class="editor-nav-modal" open>
|
||||||
|
<div class="editor-nav-modal-content">
|
||||||
|
<h3>Unsaved changes</h3>
|
||||||
|
<p>You have unsaved changes that will be lost if you leave.</p>
|
||||||
|
<div class="editor-nav-modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="editor_save_and_navigate"
|
||||||
|
class="admin-btn admin-btn-sm admin-btn-primary"
|
||||||
|
>
|
||||||
|
Save and go
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="editor_discard_and_navigate"
|
||||||
|
class="admin-btn admin-btn-sm admin-btn-danger"
|
||||||
|
>
|
||||||
|
Don't save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="editor_cancel_navigate"
|
||||||
|
class="admin-btn admin-btn-sm"
|
||||||
|
>
|
||||||
|
Stay here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
420
lib/berrypod_web/components/shop_components/site_editor.ex
Normal file
420
lib/berrypod_web/components/shop_components/site_editor.ex
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
||||||
|
@moduledoc """
|
||||||
|
Site editor component for the on-site editor panel.
|
||||||
|
|
||||||
|
Manages site-wide content that appears across all pages:
|
||||||
|
- Branding (shop name, logo, favicon)
|
||||||
|
- Announcement bar
|
||||||
|
- Header navigation
|
||||||
|
- Footer content & navigation
|
||||||
|
- Social links
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
import BerrypodWeb.CoreComponents, only: [icon: 1]
|
||||||
|
|
||||||
|
# ── Main Editor Component ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the site editor panel.
|
||||||
|
|
||||||
|
Shows collapsible sections for each category of site-wide content.
|
||||||
|
|
||||||
|
Expects assigns from the page editor hook:
|
||||||
|
- site_header_nav, site_footer_nav, site_social_links
|
||||||
|
- site_announcement_text, site_announcement_link, site_announcement_style
|
||||||
|
- site_footer_about, site_footer_copyright, site_footer_show_newsletter
|
||||||
|
"""
|
||||||
|
attr :site_header_nav, :list, default: []
|
||||||
|
attr :site_footer_nav, :list, default: []
|
||||||
|
attr :site_social_links, :list, default: []
|
||||||
|
attr :site_announcement_text, :string, default: ""
|
||||||
|
attr :site_announcement_link, :string, default: ""
|
||||||
|
attr :site_announcement_style, :string, default: "info"
|
||||||
|
attr :site_footer_about, :string, default: ""
|
||||||
|
attr :site_footer_copyright, :string, default: ""
|
||||||
|
attr :site_footer_show_newsletter, :boolean, default: true
|
||||||
|
attr :event_prefix, :string, default: "site_"
|
||||||
|
|
||||||
|
def site_editor(assigns) do
|
||||||
|
# Build settings map for child components
|
||||||
|
settings = %{
|
||||||
|
announcement_text: assigns.site_announcement_text,
|
||||||
|
announcement_link: assigns.site_announcement_link,
|
||||||
|
announcement_style: assigns.site_announcement_style,
|
||||||
|
footer_about: assigns.site_footer_about,
|
||||||
|
footer_copyright: assigns.site_footer_copyright,
|
||||||
|
show_newsletter: assigns.site_footer_show_newsletter
|
||||||
|
}
|
||||||
|
|
||||||
|
assigns = assign(assigns, :settings, settings)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="editor-site-content">
|
||||||
|
<.site_section title="Branding" icon="hero-sparkles" open={true}>
|
||||||
|
<.branding_placeholder />
|
||||||
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Announcement bar" icon="hero-megaphone">
|
||||||
|
<.announcement_editor settings={@settings} event_prefix={@event_prefix} />
|
||||||
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Header navigation" icon="hero-bars-3">
|
||||||
|
<.nav_list_placeholder items={@site_header_nav} location="header" />
|
||||||
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Footer" icon="hero-document-text">
|
||||||
|
<.footer_editor settings={@settings} event_prefix={@event_prefix} />
|
||||||
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Footer navigation" icon="hero-queue-list">
|
||||||
|
<.nav_list_placeholder items={@site_footer_nav} location="footer" />
|
||||||
|
</.site_section>
|
||||||
|
|
||||||
|
<.site_section title="Social links" icon="hero-link">
|
||||||
|
<.social_links_editor links={@site_social_links} event_prefix={@event_prefix} />
|
||||||
|
</.site_section>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Collapsible Section ────────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :icon, :string, default: nil
|
||||||
|
attr :open, :boolean, default: false
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
defp site_section(assigns) do
|
||||||
|
# Generate a stable ID from the title for the details element
|
||||||
|
id = "site-section-" <> (assigns.title |> String.downcase() |> String.replace(~r/\s+/, "-"))
|
||||||
|
assigns = assign(assigns, :id, id)
|
||||||
|
|
||||||
|
# Use phx-hook to preserve open state across re-renders
|
||||||
|
~H"""
|
||||||
|
<details id={@id} class="site-editor-section" phx-hook="DetailsPreserver" {if @open, do: [open: true], else: []}>
|
||||||
|
<summary class="site-editor-section-header">
|
||||||
|
<.icon :if={@icon} name={@icon} class="size-4" />
|
||||||
|
<span>{@title}</span>
|
||||||
|
<.icon name="hero-chevron-down-mini" class="size-4 site-editor-chevron" />
|
||||||
|
</summary>
|
||||||
|
<div class="site-editor-section-content">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Branding Section (placeholder) ─────────────────────────────────
|
||||||
|
|
||||||
|
defp branding_placeholder(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="site-editor-placeholder">
|
||||||
|
<p class="admin-text-secondary">
|
||||||
|
Branding settings (shop name, logo, favicon) will be moved here from the Theme tab.
|
||||||
|
</p>
|
||||||
|
<p class="admin-help-text">Coming soon in the next phase.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Announcement Bar Editor ─────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: "site_"
|
||||||
|
|
||||||
|
defp announcement_editor(assigns) do
|
||||||
|
~H"""
|
||||||
|
<form class="site-editor-form" phx-change={@event_prefix <> "update"}>
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="announcement-text">Announcement text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="announcement-text"
|
||||||
|
name="site[announcement_text]"
|
||||||
|
value={@settings.announcement_text}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="Free shipping on orders over £40"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="announcement-link">Link URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="announcement-link"
|
||||||
|
name="site[announcement_link]"
|
||||||
|
value={@settings.announcement_link}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder="/delivery"
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Style</label>
|
||||||
|
<div class="site-editor-radio-group">
|
||||||
|
<label class="admin-radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="site[announcement_style]"
|
||||||
|
value="info"
|
||||||
|
checked={@settings.announcement_style == "info"}
|
||||||
|
/>
|
||||||
|
<span>Info</span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="site[announcement_style]"
|
||||||
|
value="sale"
|
||||||
|
checked={@settings.announcement_style == "sale"}
|
||||||
|
/>
|
||||||
|
<span>Sale</span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="site[announcement_style]"
|
||||||
|
value="warning"
|
||||||
|
checked={@settings.announcement_style == "warning"}
|
||||||
|
/>
|
||||||
|
<span>Warning</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Footer Content Editor ───────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: "site_"
|
||||||
|
|
||||||
|
defp footer_editor(assigns) do
|
||||||
|
~H"""
|
||||||
|
<form class="site-editor-form" phx-change={@event_prefix <> "update"}>
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="footer-about">About text</label>
|
||||||
|
<textarea
|
||||||
|
id="footer-about"
|
||||||
|
name="site[footer_about]"
|
||||||
|
rows="3"
|
||||||
|
class="admin-input admin-textarea"
|
||||||
|
placeholder="A short blurb about your shop..."
|
||||||
|
phx-debounce="500"
|
||||||
|
>{@settings.footer_about}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label" for="footer-copyright">Copyright text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="footer-copyright"
|
||||||
|
name="site[footer_copyright]"
|
||||||
|
value={@settings.footer_copyright}
|
||||||
|
class="admin-input"
|
||||||
|
placeholder={"Leave blank for \"© #{Date.utc_today().year} Shop Name\""}
|
||||||
|
phx-debounce="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="admin-check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="site[show_newsletter]"
|
||||||
|
value="true"
|
||||||
|
checked={@settings.show_newsletter}
|
||||||
|
class="admin-checkbox admin-checkbox-sm"
|
||||||
|
/>
|
||||||
|
<span class="theme-check-text">Show newsletter signup</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Navigation List (placeholder) ───────────────────────────────────
|
||||||
|
|
||||||
|
attr :items, :list, required: true
|
||||||
|
attr :location, :string, required: true
|
||||||
|
|
||||||
|
defp nav_list_placeholder(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="site-editor-nav-list">
|
||||||
|
<ul class="site-editor-nav-items">
|
||||||
|
<li :for={item <- @items} class="site-editor-nav-item">
|
||||||
|
<span class="site-editor-nav-label">{item.label}</span>
|
||||||
|
<span class="site-editor-nav-url admin-text-tertiary">{item.url}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p :if={@items == []} class="admin-text-tertiary">No navigation items</p>
|
||||||
|
<p class="admin-help-text">
|
||||||
|
Drag to reorder. Full editing coming in the next phase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Social Links Editor ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@platform_groups Berrypod.Site.SocialLink.platform_groups()
|
||||||
|
|
||||||
|
attr :links, :list, required: true
|
||||||
|
attr :event_prefix, :string, default: "site_"
|
||||||
|
|
||||||
|
defp social_links_editor(assigns) do
|
||||||
|
assigns = assign(assigns, :platform_groups, @platform_groups)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="site-editor-social-list">
|
||||||
|
<ul :if={@links != []} class="site-editor-social-items">
|
||||||
|
<li
|
||||||
|
:for={{link, index} <- Enum.with_index(@links)}
|
||||||
|
class="site-editor-social-item"
|
||||||
|
data-link-id={link.id}
|
||||||
|
>
|
||||||
|
<form class="site-editor-social-item-content" phx-change={@event_prefix <> "update_social_link"} phx-value-id={link.id}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={"social_link[#{link.id}][url]"}
|
||||||
|
value={link.url}
|
||||||
|
class="admin-input admin-input-sm site-editor-social-url"
|
||||||
|
placeholder="Paste your link..."
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name={"social_link[#{link.id}][platform]"}
|
||||||
|
class="admin-select admin-select-sm site-editor-social-platform"
|
||||||
|
aria-label="Platform"
|
||||||
|
>
|
||||||
|
<optgroup :for={{group_name, platforms} <- @platform_groups} label={group_name}>
|
||||||
|
<option
|
||||||
|
:for={platform <- platforms}
|
||||||
|
value={platform}
|
||||||
|
selected={link.platform == platform}
|
||||||
|
>
|
||||||
|
{platform_label(platform)}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<div class="site-editor-social-item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-icon-button"
|
||||||
|
phx-click={@event_prefix <> "move_social_link"}
|
||||||
|
phx-value-id={link.id}
|
||||||
|
phx-value-dir="up"
|
||||||
|
disabled={index == 0}
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
<.icon name="hero-chevron-up-mini" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-icon-button"
|
||||||
|
phx-click={@event_prefix <> "move_social_link"}
|
||||||
|
phx-value-id={link.id}
|
||||||
|
phx-value-dir="down"
|
||||||
|
disabled={index == length(@links) - 1}
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
<.icon name="hero-chevron-down-mini" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-icon-button admin-icon-button-danger"
|
||||||
|
phx-click={@event_prefix <> "remove_social_link"}
|
||||||
|
phx-value-id={link.id}
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark-mini" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p :if={@links == []} class="admin-text-tertiary admin-empty-message">
|
||||||
|
No social links yet
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-button admin-button-sm admin-button-outline site-editor-add-button"
|
||||||
|
phx-click={@event_prefix <> "add_social_link"}
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus-mini" class="size-4" />
|
||||||
|
<span>Add social link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Social
|
||||||
|
defp platform_label("instagram"), do: "Instagram"
|
||||||
|
defp platform_label("threads"), do: "Threads"
|
||||||
|
defp platform_label("facebook"), do: "Facebook"
|
||||||
|
defp platform_label("twitter"), do: "Twitter / X"
|
||||||
|
defp platform_label("snapchat"), do: "Snapchat"
|
||||||
|
defp platform_label("linkedin"), do: "LinkedIn"
|
||||||
|
|
||||||
|
# Video & streaming
|
||||||
|
defp platform_label("youtube"), do: "YouTube"
|
||||||
|
defp platform_label("twitch"), do: "Twitch"
|
||||||
|
defp platform_label("vimeo"), do: "Vimeo"
|
||||||
|
defp platform_label("kick"), do: "Kick"
|
||||||
|
defp platform_label("rumble"), do: "Rumble"
|
||||||
|
|
||||||
|
# Music & podcasts
|
||||||
|
defp platform_label("spotify"), do: "Spotify"
|
||||||
|
defp platform_label("soundcloud"), do: "SoundCloud"
|
||||||
|
defp platform_label("bandcamp"), do: "Bandcamp"
|
||||||
|
defp platform_label("applepodcasts"), do: "Apple Podcasts"
|
||||||
|
|
||||||
|
# Creative
|
||||||
|
defp platform_label("pinterest"), do: "Pinterest"
|
||||||
|
defp platform_label("behance"), do: "Behance"
|
||||||
|
defp platform_label("dribbble"), do: "Dribbble"
|
||||||
|
defp platform_label("tumblr"), do: "Tumblr"
|
||||||
|
defp platform_label("medium"), do: "Medium"
|
||||||
|
|
||||||
|
# Support & sales
|
||||||
|
defp platform_label("patreon"), do: "Patreon"
|
||||||
|
defp platform_label("kofi"), do: "Ko-fi"
|
||||||
|
defp platform_label("etsy"), do: "Etsy"
|
||||||
|
defp platform_label("gumroad"), do: "Gumroad"
|
||||||
|
defp platform_label("substack"), do: "Substack"
|
||||||
|
|
||||||
|
# Federated
|
||||||
|
defp platform_label("mastodon"), do: "Mastodon"
|
||||||
|
defp platform_label("pixelfed"), do: "Pixelfed"
|
||||||
|
defp platform_label("bluesky"), do: "Bluesky"
|
||||||
|
defp platform_label("peertube"), do: "PeerTube"
|
||||||
|
defp platform_label("lemmy"), do: "Lemmy"
|
||||||
|
defp platform_label("matrix"), do: "Matrix"
|
||||||
|
|
||||||
|
# Developer
|
||||||
|
defp platform_label("github"), do: "GitHub"
|
||||||
|
defp platform_label("gitlab"), do: "GitLab"
|
||||||
|
defp platform_label("codeberg"), do: "Codeberg"
|
||||||
|
defp platform_label("sourcehut"), do: "SourceHut"
|
||||||
|
defp platform_label("reddit"), do: "Reddit"
|
||||||
|
|
||||||
|
# Messaging
|
||||||
|
defp platform_label("discord"), do: "Discord"
|
||||||
|
defp platform_label("telegram"), do: "Telegram"
|
||||||
|
defp platform_label("signal"), do: "Signal"
|
||||||
|
defp platform_label("whatsapp"), do: "WhatsApp"
|
||||||
|
|
||||||
|
# Other
|
||||||
|
defp platform_label("linktree"), do: "Linktree"
|
||||||
|
defp platform_label("rss"), do: "RSS feed"
|
||||||
|
defp platform_label("website"), do: "Website"
|
||||||
|
defp platform_label("custom"), do: "Custom link"
|
||||||
|
defp platform_label(other), do: String.capitalize(other)
|
||||||
|
end
|
||||||
@ -66,7 +66,6 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
def handle_event("validate_upload", _params, socket) do
|
def handle_event("validate_upload", _params, socket) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
@ -274,11 +273,10 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-section-desc">
|
<p class="admin-section-desc">
|
||||||
{Backup.format_size(@stats.total_size)} total ·
|
{Backup.format_size(@stats.total_size)} total · {length(@stats.tables)} tables · {@stats.key_counts[
|
||||||
{length(@stats.tables)} tables ·
|
"products"
|
||||||
{@stats.key_counts["products"] || 0} products ·
|
] || 0} products · {@stats.key_counts["orders"] || 0} orders · {@stats.key_counts["images"] ||
|
||||||
{@stats.key_counts["orders"] || 0} orders ·
|
0} images
|
||||||
{@stats.key_counts["images"] || 0} images
|
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-section-body">
|
<div class="admin-section-body">
|
||||||
<button
|
<button
|
||||||
@ -459,10 +457,22 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
<div class="backup-comparison-col">
|
<div class="backup-comparison-col">
|
||||||
<h4 class="backup-comparison-label">Current</h4>
|
<h4 class="backup-comparison-label">Current</h4>
|
||||||
<dl class="backup-comparison-stats">
|
<dl class="backup-comparison-stats">
|
||||||
<div><dt>Size</dt><dd>{Backup.format_size(@stats.total_size)}</dd></div>
|
<div>
|
||||||
<div><dt>Products</dt><dd>{@stats.key_counts["products"] || 0}</dd></div>
|
<dt>Size</dt>
|
||||||
<div><dt>Orders</dt><dd>{@stats.key_counts["orders"] || 0}</dd></div>
|
<dd>{Backup.format_size(@stats.total_size)}</dd>
|
||||||
<div><dt>Images</dt><dd>{@stats.key_counts["images"] || 0}</dd></div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Products</dt>
|
||||||
|
<dd>{@stats.key_counts["products"] || 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Orders</dt>
|
||||||
|
<dd>{@stats.key_counts["orders"] || 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Images</dt>
|
||||||
|
<dd>{@stats.key_counts["images"] || 0}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-comparison-arrow">
|
<div class="backup-comparison-arrow">
|
||||||
@ -471,10 +481,22 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
<div class="backup-comparison-col">
|
<div class="backup-comparison-col">
|
||||||
<h4 class="backup-comparison-label">Uploaded</h4>
|
<h4 class="backup-comparison-label">Uploaded</h4>
|
||||||
<dl class="backup-comparison-stats">
|
<dl class="backup-comparison-stats">
|
||||||
<div><dt>Size</dt><dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd></div>
|
<div>
|
||||||
<div><dt>Products</dt><dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd></div>
|
<dt>Size</dt>
|
||||||
<div><dt>Orders</dt><dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd></div>
|
<dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd>
|
||||||
<div><dt>Images</dt><dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd></div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Products</dt>
|
||||||
|
<dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Orders</dt>
|
||||||
|
<dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Images</dt>
|
||||||
|
<dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -482,7 +504,9 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
|
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
|
||||||
<div class="backup-validation backup-validation-ok">
|
<div class="backup-validation backup-validation-ok">
|
||||||
<.icon name="hero-check-circle-mini" class="size-4" />
|
<.icon name="hero-check-circle-mini" class="size-4" />
|
||||||
<span>Backup validated · Schema version {@uploaded_backup.stats.latest_migration}</span>
|
<span>
|
||||||
|
Backup validated · Schema version {@uploaded_backup.stats.latest_migration}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @restoring do %>
|
<%= if @restoring do %>
|
||||||
@ -496,22 +520,40 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
<% else %>
|
<% else %>
|
||||||
<%= if @confirming_restore do %>
|
<%= if @confirming_restore do %>
|
||||||
<div class="backup-warning">
|
<div class="backup-warning">
|
||||||
<p>This will replace your current database. A backup will be saved automatically.</p>
|
<p>
|
||||||
|
This will replace your current database. A backup will be saved automatically.
|
||||||
|
</p>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
<button type="button" class="admin-btn admin-btn-danger admin-btn-sm" phx-click="execute_restore">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-btn admin-btn-danger admin-btn-sm"
|
||||||
|
phx-click="execute_restore"
|
||||||
|
>
|
||||||
Replace database
|
Replace database
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
phx-click="cancel_restore"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
<button type="button" class="admin-btn admin-btn-primary admin-btn-sm" phx-click="confirm_restore">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||||
|
phx-click="confirm_restore"
|
||||||
|
>
|
||||||
Restore this backup
|
Restore this backup
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
phx-click="cancel_restore"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -526,7 +568,11 @@ defmodule BerrypodWeb.Admin.Backup do
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
|
phx-click="cancel_restore"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -851,36 +851,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
|
|
||||||
# ── Helpers ──
|
# ── Helpers ──
|
||||||
|
|
||||||
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
|
|
||||||
site_name = Settings.site_name()
|
|
||||||
|
|
||||||
if site_name != "Store Name" do
|
|
||||||
"#{site_name} · #{user.email}"
|
|
||||||
else
|
|
||||||
user.email
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp account_summary(_), do: "Account created"
|
|
||||||
|
|
||||||
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
|
|
||||||
case Provider.get(type) do
|
|
||||||
nil -> "Connected"
|
|
||||||
info -> "Connected to #{info.name}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp provider_summary(_), do: nil
|
|
||||||
|
|
||||||
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
|
|
||||||
case Settings.secret_hint("stripe_api_key") do
|
|
||||||
nil -> "Connected"
|
|
||||||
hint -> "Connected · #{hint}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp stripe_summary(_), do: nil
|
|
||||||
|
|
||||||
defp provider_card_options(providers) do
|
defp provider_card_options(providers) do
|
||||||
Enum.map(providers, fn provider ->
|
Enum.map(providers, fn provider ->
|
||||||
option = %{
|
option = %{
|
||||||
|
|||||||
@ -20,7 +20,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
|
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
|
||||||
|
|
||||||
alias Berrypod.{Media, Settings}
|
alias Berrypod.{Media, Settings, Site}
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||||
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
||||||
@ -53,6 +53,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_active_tab, :page)
|
|> assign(:editor_active_tab, :page)
|
||||||
# Theme editing state
|
# Theme editing state
|
||||||
|> assign(:theme_editing, false)
|
|> assign(:theme_editing, false)
|
||||||
|
|> assign(:theme_dirty, false)
|
||||||
|
|> assign(:theme_editor_original, nil)
|
||||||
|> assign(:theme_editor_settings, nil)
|
|> assign(:theme_editor_settings, nil)
|
||||||
|> assign(:theme_editor_active_preset, nil)
|
|> assign(:theme_editor_active_preset, nil)
|
||||||
|> assign(:theme_editor_logo_image, nil)
|
|> assign(:theme_editor_logo_image, nil)
|
||||||
@ -64,6 +66,21 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
# Settings editing state
|
# Settings editing state
|
||||||
|> assign(:settings_dirty, false)
|
|> assign(:settings_dirty, false)
|
||||||
|> assign(:settings_save_status, :idle)
|
|> assign(:settings_save_status, :idle)
|
||||||
|
# Site editing state
|
||||||
|
|> assign(:site_editing, false)
|
||||||
|
|> assign(:site_dirty, false)
|
||||||
|
|> assign(:site_editor_original, nil)
|
||||||
|
|> assign(:site_header_nav, [])
|
||||||
|
|> assign(:site_footer_nav, [])
|
||||||
|
|> assign(:site_social_links, [])
|
||||||
|
|> assign(:site_announcement_text, "")
|
||||||
|
|> assign(:site_announcement_link, "")
|
||||||
|
|> assign(:site_announcement_style, "info")
|
||||||
|
|> assign(:site_footer_about, "")
|
||||||
|
|> assign(:site_footer_copyright, "")
|
||||||
|
|> assign(:site_footer_show_newsletter, true)
|
||||||
|
# Navigation warning state
|
||||||
|
|> assign(:editor_nav_blocked, nil)
|
||||||
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|
||||||
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|
||||||
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
|
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
|
||||||
@ -119,6 +136,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_active_tab, :settings)
|
|> assign(:editor_active_tab, :settings)
|
||||||
|> maybe_enter_theme_mode()
|
|> maybe_enter_theme_mode()
|
||||||
|
|
||||||
|
"site" ->
|
||||||
|
socket
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
|> assign(:editor_active_tab, :site)
|
||||||
|
|> maybe_enter_site_mode()
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
# No edit param - collapse the editor (supports browser back)
|
# No edit param - collapse the editor (supports browser back)
|
||||||
assign(socket, :editor_sheet_state, :collapsed)
|
assign(socket, :editor_sheet_state, :collapsed)
|
||||||
@ -153,6 +176,14 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_enter_site_mode(socket) do
|
||||||
|
if socket.assigns.site_editing do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
load_site_state(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ── handle_info ─────────────────────────────────────────────────
|
# ── handle_info ─────────────────────────────────────────────────
|
||||||
|
|
||||||
defp handle_editor_info(:editor_clear_save_status, socket) do
|
defp handle_editor_info(:editor_clear_save_status, socket) do
|
||||||
@ -192,18 +223,6 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sync URL ?edit param with editor state
|
|
||||||
defp sync_edit_url_param(socket, :collapsed) do
|
|
||||||
path = socket.assigns.editor_current_path || "/"
|
|
||||||
push_patch(socket, to: path)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sync_edit_url_param(socket, :open) do
|
|
||||||
path = socket.assigns.editor_current_path || "/"
|
|
||||||
tab = socket.assigns.editor_active_tab
|
|
||||||
push_patch(socket, to: "#{path}?edit=#{tab}")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Tab switching for unified editor
|
# Tab switching for unified editor
|
||||||
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
|
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
|
||||||
if socket.assigns.is_admin do
|
if socket.assigns.is_admin do
|
||||||
@ -249,6 +268,26 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
|
|
||||||
assign(socket, :editor_active_tab, :settings)
|
assign(socket, :editor_active_tab, :settings)
|
||||||
|
|
||||||
|
:site ->
|
||||||
|
# Site tab shows site-wide content editors
|
||||||
|
# Load theme state for branding settings (will be moved here from theme tab)
|
||||||
|
socket =
|
||||||
|
if socket.assigns.theme_editing do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
load_theme_state(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load site state if not already loaded
|
||||||
|
socket =
|
||||||
|
if socket.assigns.site_editing do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
load_site_state(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :editor_active_tab, :site)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Open the sheet and sync URL with new tab
|
# Open the sheet and sync URL with new tab
|
||||||
@ -276,6 +315,54 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Unified save works for all tabs regardless of editing mode
|
||||||
|
defp handle_editor_event("editor_save_all", _params, socket) do
|
||||||
|
if socket.assigns.is_admin do
|
||||||
|
socket = save_all_tabs(socket)
|
||||||
|
{:halt, socket}
|
||||||
|
else
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Navigation blocked by unsaved changes
|
||||||
|
defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
|
||||||
|
{:halt, assign(socket, :editor_nav_blocked, href)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_save_and_navigate", _params, socket) do
|
||||||
|
href = socket.assigns.editor_nav_blocked
|
||||||
|
socket = save_all_tabs(socket)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:editor_nav_blocked, nil)
|
||||||
|
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
|
||||||
|
href = socket.assigns.editor_nav_blocked
|
||||||
|
socket = revert_all_tabs(socket)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:editor_nav_blocked, nil)
|
||||||
|
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:editor_nav_blocked, nil)
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_editor_event("editor_" <> action, params, socket) do
|
defp handle_editor_event("editor_" <> action, params, socket) do
|
||||||
if socket.assigns.editing do
|
if socket.assigns.editing do
|
||||||
handle_editor_action(action, params, socket)
|
handle_editor_action(action, params, socket)
|
||||||
@ -302,8 +389,29 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Site editing events (announcement bar, footer, etc.)
|
||||||
|
defp handle_editor_event("site_" <> action, params, socket) do
|
||||||
|
if socket.assigns.is_admin do
|
||||||
|
handle_site_action(action, params, socket)
|
||||||
|
else
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
|
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
|
||||||
|
|
||||||
|
# Sync URL ?edit param with editor state
|
||||||
|
defp sync_edit_url_param(socket, :collapsed) do
|
||||||
|
path = socket.assigns.editor_current_path || "/"
|
||||||
|
push_patch(socket, to: path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_edit_url_param(socket, :open) do
|
||||||
|
path = socket.assigns.editor_current_path || "/"
|
||||||
|
tab = socket.assigns.editor_active_tab
|
||||||
|
push_patch(socket, to: "#{path}?edit=#{tab}")
|
||||||
|
end
|
||||||
|
|
||||||
# ── Block manipulation actions ───────────────────────────────────
|
# ── Block manipulation actions ───────────────────────────────────
|
||||||
|
|
||||||
defp handle_editor_action("move_up", %{"id" => id}, socket) do
|
defp handle_editor_action("move_up", %{"id" => id}, socket) do
|
||||||
@ -603,6 +711,11 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_editor_action("save_all", _params, socket) do
|
||||||
|
socket = save_all_tabs(socket)
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_editor_action("reset_defaults", _params, socket) do
|
defp handle_editor_action("reset_defaults", _params, socket) do
|
||||||
slug = socket.assigns.page.slug
|
slug = socket.assigns.page.slug
|
||||||
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
||||||
@ -627,8 +740,16 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
defp handle_theme_action("apply_preset", %{"preset" => preset_name}, socket) do
|
defp handle_theme_action("apply_preset", %{"preset" => preset_name}, socket) do
|
||||||
preset_atom = String.to_existing_atom(preset_name)
|
preset_atom = String.to_existing_atom(preset_name)
|
||||||
|
|
||||||
case Settings.apply_preset(preset_atom) do
|
# Get preset values and apply in-memory (don't persist yet)
|
||||||
{:ok, theme_settings} ->
|
case Presets.get(preset_atom) do
|
||||||
|
nil ->
|
||||||
|
{:halt, socket}
|
||||||
|
|
||||||
|
preset_values ->
|
||||||
|
# Merge preset values into current settings
|
||||||
|
current = socket.assigns.theme_editor_settings
|
||||||
|
theme_settings = struct(current, preset_values)
|
||||||
|
|
||||||
generated_css =
|
generated_css =
|
||||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||||
|
|
||||||
@ -641,14 +762,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:theme_editor_settings, theme_settings)
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|> assign(:theme_editor_active_preset, preset_atom)
|
|> assign(:theme_editor_active_preset, preset_atom)
|
||||||
|> assign(:theme_editor_contrast_warning, contrast_warning)
|
|> assign(:theme_editor_contrast_warning, contrast_warning)
|
||||||
|
|> assign(:theme_dirty, true)
|
||||||
# Update shop state so layout reflects changes live
|
# Update shop state so layout reflects changes live
|
||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
||||||
{:error, _} ->
|
|
||||||
{:halt, socket}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -658,6 +777,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
socket
|
socket
|
||||||
)
|
)
|
||||||
when field in @standalone_settings do
|
when field in @standalone_settings do
|
||||||
|
# Standalone settings (site_name, site_description) save immediately for now
|
||||||
|
# TODO: Track these separately for proper revert
|
||||||
Settings.put_setting(field, value, "string")
|
Settings.put_setting(field, value, "string")
|
||||||
# Also update the main assigns so ThemeHook sees the change
|
# Also update the main assigns so ThemeHook sees the change
|
||||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||||
@ -677,6 +798,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
value = params[field]
|
value = params[field]
|
||||||
|
|
||||||
if value do
|
if value do
|
||||||
|
# Standalone settings save immediately for now
|
||||||
|
# TODO: Track these separately for proper revert
|
||||||
Settings.put_setting(field, value, "string")
|
Settings.put_setting(field, value, "string")
|
||||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||||
else
|
else
|
||||||
@ -719,13 +842,14 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp handle_theme_action("remove_logo", _params, socket) do
|
defp handle_theme_action("remove_logo", _params, socket) do
|
||||||
|
# Delete the image immediately (this is a destructive action)
|
||||||
if logo = socket.assigns.theme_editor_logo_image do
|
if logo = socket.assigns.theme_editor_logo_image do
|
||||||
Media.delete_image(logo)
|
Media.delete_image(logo)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, theme_settings} =
|
# Update settings in memory only
|
||||||
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
|
current = socket.assigns.theme_editor_settings
|
||||||
|
theme_settings = %{current | logo_image_id: nil, show_site_name: true}
|
||||||
generated_css = CSSGenerator.generate(theme_settings)
|
generated_css = CSSGenerator.generate(theme_settings)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
@ -733,38 +857,51 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:theme_editor_logo_image, nil)
|
|> assign(:theme_editor_logo_image, nil)
|
||||||
|> assign(:logo_image, nil)
|
|> assign(:logo_image, nil)
|
||||||
|> assign(:theme_editor_settings, theme_settings)
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|
|> assign(:theme_dirty, true)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_theme_action("remove_header", _params, socket) do
|
defp handle_theme_action("remove_header", _params, socket) do
|
||||||
|
# Delete the image immediately (this is a destructive action)
|
||||||
if header = socket.assigns.theme_editor_header_image do
|
if header = socket.assigns.theme_editor_header_image do
|
||||||
Media.delete_image(header)
|
Media.delete_image(header)
|
||||||
end
|
end
|
||||||
|
|
||||||
Settings.update_theme_settings(%{header_image_id: nil})
|
# Update settings in memory only
|
||||||
|
current = socket.assigns.theme_editor_settings
|
||||||
|
theme_settings = %{current | header_image_id: nil}
|
||||||
|
generated_css = CSSGenerator.generate(theme_settings)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:theme_editor_header_image, nil)
|
|> assign(:theme_editor_header_image, nil)
|
||||||
|> assign(:header_image, nil)
|
|> assign(:header_image, nil)
|
||||||
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|
|> assign(:theme_dirty, true)
|
||||||
|> assign(:theme_editor_contrast_warning, :ok)
|
|> assign(:theme_editor_contrast_warning, :ok)
|
||||||
|
|> assign(:generated_css, generated_css)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_theme_action("remove_icon", _params, socket) do
|
defp handle_theme_action("remove_icon", _params, socket) do
|
||||||
|
# Delete the image immediately (this is a destructive action)
|
||||||
if icon = socket.assigns.theme_editor_icon_image do
|
if icon = socket.assigns.theme_editor_icon_image do
|
||||||
Media.delete_image(icon)
|
Media.delete_image(icon)
|
||||||
end
|
end
|
||||||
|
|
||||||
Settings.update_theme_settings(%{icon_image_id: nil})
|
# Update settings in memory only
|
||||||
|
current = socket.assigns.theme_editor_settings
|
||||||
|
theme_settings = %{current | icon_image_id: nil}
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:theme_editor_icon_image, nil)
|
|> assign(:theme_editor_icon_image, nil)
|
||||||
|> assign(:icon_image, nil)
|
|> assign(:icon_image, nil)
|
||||||
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|
|> assign(:theme_dirty, true)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
@ -853,6 +990,222 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
# Catch-all for unknown settings actions
|
# Catch-all for unknown settings actions
|
||||||
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
|
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
|
||||||
|
|
||||||
|
# --- Site tab event handlers ---
|
||||||
|
|
||||||
|
defp handle_site_action("update", %{"site" => site_params}, socket) do
|
||||||
|
socket = handle_site_update(socket, site_params)
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_site_action("update", _params, socket), do: {:halt, socket}
|
||||||
|
|
||||||
|
# Social link CRUD operations (persist immediately like images)
|
||||||
|
defp handle_site_action("add_social_link", _params, socket) do
|
||||||
|
# Create with "custom" platform and blank URL
|
||||||
|
# User will paste their link, which auto-detects the platform
|
||||||
|
position = length(socket.assigns.site_social_links)
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
platform: "custom",
|
||||||
|
url: "",
|
||||||
|
position: position
|
||||||
|
}
|
||||||
|
|
||||||
|
case Site.create_social_link(attrs) do
|
||||||
|
{:ok, link} ->
|
||||||
|
links = socket.assigns.site_social_links ++ [link]
|
||||||
|
{:halt, assign(socket, :site_social_links, links)}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_site_action("update_social_link", %{"id" => id} = params, socket) do
|
||||||
|
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
|
||||||
|
|
||||||
|
if link do
|
||||||
|
# Extract the nested params from the form field name
|
||||||
|
link_params = params["social_link"][id] || %{}
|
||||||
|
target = params["_target"] || []
|
||||||
|
|
||||||
|
# Check what field was changed
|
||||||
|
url_changed? = List.last(target) == "url"
|
||||||
|
platform_changed? = List.last(target) == "platform"
|
||||||
|
|
||||||
|
# Build attrs based on what changed
|
||||||
|
attrs =
|
||||||
|
cond do
|
||||||
|
# URL changed - normalize and update URL, maybe auto-detect platform
|
||||||
|
url_changed? ->
|
||||||
|
url = link_params["url"] |> Berrypod.Site.SocialLink.normalize_url()
|
||||||
|
detected = Berrypod.Site.SocialLink.detect_platform(url)
|
||||||
|
|
||||||
|
base = %{url: url}
|
||||||
|
|
||||||
|
# Auto-detect platform when:
|
||||||
|
# 1. Current platform is "custom" (initial state), OR
|
||||||
|
# 2. New URL detects to a different platform than currently set
|
||||||
|
# (e.g., changing from github.com to twitter.com)
|
||||||
|
should_update_platform? =
|
||||||
|
detected &&
|
||||||
|
detected != "custom" &&
|
||||||
|
(link.platform == "custom" || detected != link.platform)
|
||||||
|
|
||||||
|
if should_update_platform? do
|
||||||
|
Map.put(base, :platform, detected)
|
||||||
|
else
|
||||||
|
base
|
||||||
|
end
|
||||||
|
|
||||||
|
# Platform explicitly changed by user - use their selection
|
||||||
|
platform_changed? ->
|
||||||
|
%{platform: link_params["platform"]}
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
true ->
|
||||||
|
%{}
|
||||||
|
|> maybe_put(:url, link_params["url"])
|
||||||
|
|> maybe_put(:platform, link_params["platform"])
|
||||||
|
end
|
||||||
|
|
||||||
|
if attrs != %{} do
|
||||||
|
case Site.update_social_link(link, attrs) do
|
||||||
|
{:ok, updated_link} ->
|
||||||
|
links =
|
||||||
|
Enum.map(socket.assigns.site_social_links, fn l ->
|
||||||
|
if l.id == id, do: updated_link, else: l
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:halt, assign(socket, :site_social_links, links)}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_site_action("remove_social_link", %{"id" => id}, socket) do
|
||||||
|
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
|
||||||
|
|
||||||
|
if link do
|
||||||
|
case Site.delete_social_link(link) do
|
||||||
|
{:ok, _} ->
|
||||||
|
links = Enum.reject(socket.assigns.site_social_links, &(&1.id == id))
|
||||||
|
{:halt, assign(socket, :site_social_links, links)}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
|
||||||
|
links = socket.assigns.site_social_links
|
||||||
|
index = Enum.find_index(links, &(&1.id == id))
|
||||||
|
|
||||||
|
new_index =
|
||||||
|
case dir do
|
||||||
|
"up" -> max(0, index - 1)
|
||||||
|
"down" -> min(length(links) - 1, index + 1)
|
||||||
|
_ -> index
|
||||||
|
end
|
||||||
|
|
||||||
|
if index != new_index do
|
||||||
|
# Swap the items
|
||||||
|
item = Enum.at(links, index)
|
||||||
|
other = Enum.at(links, new_index)
|
||||||
|
|
||||||
|
reordered =
|
||||||
|
links
|
||||||
|
|> List.replace_at(index, other)
|
||||||
|
|> List.replace_at(new_index, item)
|
||||||
|
|
||||||
|
# Persist the new order
|
||||||
|
ids = Enum.map(reordered, & &1.id)
|
||||||
|
Site.reorder_social_links(ids)
|
||||||
|
|
||||||
|
{:halt, assign(socket, :site_social_links, reordered)}
|
||||||
|
else
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Catch-all for unknown site actions
|
||||||
|
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
|
||||||
|
|
||||||
|
defp handle_site_update(socket, params) do
|
||||||
|
# Handle announcement bar fields (preview only, no persistence)
|
||||||
|
socket =
|
||||||
|
if Map.has_key?(params, "announcement_text") or
|
||||||
|
Map.has_key?(params, "announcement_link") or
|
||||||
|
Map.has_key?(params, "announcement_style") do
|
||||||
|
text = params["announcement_text"] || socket.assigns.site_announcement_text
|
||||||
|
link = params["announcement_link"] || socket.assigns.site_announcement_link
|
||||||
|
style = params["announcement_style"] || socket.assigns.site_announcement_style
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_announcement_text, text)
|
||||||
|
|> assign(:site_announcement_link, link)
|
||||||
|
|> assign(:site_announcement_style, style)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle footer fields (preview only, no persistence)
|
||||||
|
socket =
|
||||||
|
if Map.has_key?(params, "footer_about") or
|
||||||
|
Map.has_key?(params, "footer_copyright") or
|
||||||
|
Map.has_key?(params, "show_newsletter") do
|
||||||
|
about = params["footer_about"] || socket.assigns.site_footer_about
|
||||||
|
copyright = params["footer_copyright"] || socket.assigns.site_footer_copyright
|
||||||
|
|
||||||
|
# Checkbox sends value when checked, absent when unchecked
|
||||||
|
show_newsletter =
|
||||||
|
if Map.has_key?(params, "show_newsletter") do
|
||||||
|
params["show_newsletter"] == "true"
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_footer_about, about)
|
||||||
|
|> assign(:site_footer_copyright, copyright)
|
||||||
|
|> assign(:site_footer_show_newsletter, show_newsletter)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark as dirty if values differ from original
|
||||||
|
socket
|
||||||
|
|> compute_site_dirty()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_site_dirty(socket) do
|
||||||
|
original = socket.assigns[:site_editor_original]
|
||||||
|
|
||||||
|
dirty =
|
||||||
|
if original do
|
||||||
|
socket.assigns.site_announcement_text != original.announcement_text or
|
||||||
|
socket.assigns.site_announcement_link != original.announcement_link or
|
||||||
|
socket.assigns.site_announcement_style != original.announcement_style or
|
||||||
|
socket.assigns.site_footer_about != original.footer_about or
|
||||||
|
socket.assigns.site_footer_copyright != original.footer_copyright or
|
||||||
|
socket.assigns.site_footer_show_newsletter != original.show_newsletter
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :site_dirty, dirty)
|
||||||
|
end
|
||||||
|
|
||||||
# Check if settings have changed from current page values
|
# Check if settings have changed from current page values
|
||||||
defp has_settings_changed?(page, params) do
|
defp has_settings_changed?(page, params) do
|
||||||
page.title != (params["title"] || "") or
|
page.title != (params["title"] || "") or
|
||||||
@ -881,30 +1234,34 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
assign(socket, :settings_form, form)
|
assign(socket, :settings_form, form)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to update a theme setting and regenerate CSS
|
# Helper to update a theme setting in-memory (preview only, no persistence)
|
||||||
defp update_theme_setting(socket, attrs, field) do
|
defp update_theme_setting(socket, attrs, field) do
|
||||||
case Settings.update_theme_settings(attrs) do
|
current = socket.assigns.theme_editor_settings
|
||||||
{:ok, theme_settings} ->
|
|
||||||
generated_css =
|
|
||||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
|
||||||
|
|
||||||
active_preset = Presets.detect_preset(theme_settings)
|
# Merge attrs into current settings (convert string keys to atoms)
|
||||||
|
theme_settings =
|
||||||
|
Enum.reduce(attrs, current, fn {key, value}, acc ->
|
||||||
|
atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key
|
||||||
|
Map.put(acc, atom_key, value)
|
||||||
|
end)
|
||||||
|
|
||||||
socket =
|
generated_css =
|
||||||
socket
|
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||||
# Update editor state
|
|
||||||
|> assign(:theme_editor_settings, theme_settings)
|
|
||||||
|> assign(:theme_editor_active_preset, active_preset)
|
|
||||||
|> maybe_recompute_contrast(field)
|
|
||||||
# Update shop state so layout reflects changes live
|
|
||||||
|> assign(:theme_settings, theme_settings)
|
|
||||||
|> assign(:generated_css, generated_css)
|
|
||||||
|
|
||||||
{:halt, socket}
|
active_preset = Presets.detect_preset(theme_settings)
|
||||||
|
|
||||||
{:error, _} ->
|
socket =
|
||||||
{:halt, socket}
|
socket
|
||||||
end
|
# Update editor state
|
||||||
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|
|> assign(:theme_editor_active_preset, active_preset)
|
||||||
|
|> assign(:theme_dirty, true)
|
||||||
|
|> maybe_recompute_contrast(field)
|
||||||
|
# Update shop state so layout reflects changes live
|
||||||
|
|> assign(:theme_settings, theme_settings)
|
||||||
|
|> assign(:generated_css, generated_css)
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_recompute_contrast(socket, field)
|
defp maybe_recompute_contrast(socket, field)
|
||||||
@ -1017,6 +1374,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:theme_editing, true)
|
|> assign(:theme_editing, true)
|
||||||
|
|> assign(:theme_dirty, false)
|
||||||
|
|> assign(:theme_editor_original, theme_settings)
|
||||||
|> assign(:theme_editor_settings, theme_settings)
|
|> assign(:theme_editor_settings, theme_settings)
|
||||||
|> assign(:theme_editor_active_preset, active_preset)
|
|> assign(:theme_editor_active_preset, active_preset)
|
||||||
|> assign(:theme_editor_logo_image, logo_image)
|
|> assign(:theme_editor_logo_image, logo_image)
|
||||||
@ -1054,4 +1413,189 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Site editing helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
defp load_site_state(socket) do
|
||||||
|
site_settings = Site.get_settings()
|
||||||
|
|
||||||
|
# Store original values for revert capability
|
||||||
|
original = %{
|
||||||
|
announcement_text: site_settings.announcement_text,
|
||||||
|
announcement_link: site_settings.announcement_link,
|
||||||
|
announcement_style: site_settings.announcement_style,
|
||||||
|
footer_about: site_settings.footer_about,
|
||||||
|
footer_copyright: site_settings.footer_copyright,
|
||||||
|
show_newsletter: site_settings.show_newsletter
|
||||||
|
}
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_editing, true)
|
||||||
|
|> assign(:site_dirty, false)
|
||||||
|
|> assign(:site_editor_original, original)
|
||||||
|
|> assign(:site_header_nav, Site.list_nav_items(:header))
|
||||||
|
|> assign(:site_footer_nav, Site.list_nav_items(:footer))
|
||||||
|
|> assign(:site_social_links, Site.list_social_links())
|
||||||
|
|> assign(:site_announcement_text, site_settings.announcement_text)
|
||||||
|
|> assign(:site_announcement_link, site_settings.announcement_link)
|
||||||
|
|> assign(:site_announcement_style, site_settings.announcement_style)
|
||||||
|
|> assign(:site_footer_about, site_settings.footer_about)
|
||||||
|
|> assign(:site_footer_copyright, site_settings.footer_copyright)
|
||||||
|
|> assign(:site_footer_show_newsletter, site_settings.show_newsletter)
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Unified save helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
defp save_all_tabs(socket) do
|
||||||
|
socket
|
||||||
|
|> maybe_save_page()
|
||||||
|
|> maybe_save_theme()
|
||||||
|
|> maybe_save_site()
|
||||||
|
|> assign(:editor_save_status, :saved)
|
||||||
|
|> schedule_save_status_clear()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_page(socket) do
|
||||||
|
if socket.assigns[:editor_dirty] do
|
||||||
|
%{page: page, editing_blocks: blocks} = socket.assigns
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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, [])
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_theme(socket) do
|
||||||
|
if socket.assigns[:theme_dirty] do
|
||||||
|
case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) do
|
||||||
|
{:ok, theme_settings} ->
|
||||||
|
socket
|
||||||
|
|> assign(:theme_editor_original, theme_settings)
|
||||||
|
|> assign(:theme_dirty, false)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_save_site(socket) do
|
||||||
|
if socket.assigns[:site_dirty] do
|
||||||
|
Site.put_announcement(
|
||||||
|
socket.assigns.site_announcement_text,
|
||||||
|
socket.assigns.site_announcement_link,
|
||||||
|
socket.assigns.site_announcement_style
|
||||||
|
)
|
||||||
|
|
||||||
|
Site.put_footer_content(
|
||||||
|
socket.assigns.site_footer_about,
|
||||||
|
socket.assigns.site_footer_copyright,
|
||||||
|
socket.assigns.site_footer_show_newsletter
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update original to match saved values
|
||||||
|
original = %{
|
||||||
|
announcement_text: socket.assigns.site_announcement_text,
|
||||||
|
announcement_link: socket.assigns.site_announcement_link,
|
||||||
|
announcement_style: socket.assigns.site_announcement_style,
|
||||||
|
footer_about: socket.assigns.site_footer_about,
|
||||||
|
footer_copyright: socket.assigns.site_footer_copyright,
|
||||||
|
show_newsletter: socket.assigns.site_footer_show_newsletter
|
||||||
|
}
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_editor_original, original)
|
||||||
|
|> assign(:site_dirty, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_save_status_clear(socket) do
|
||||||
|
Process.send_after(self(), :editor_clear_save_status, 2500)
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Revert helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp revert_all_tabs(socket) do
|
||||||
|
socket
|
||||||
|
|> maybe_revert_page()
|
||||||
|
|> maybe_revert_theme()
|
||||||
|
|> maybe_revert_site()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_revert_page(socket) do
|
||||||
|
if socket.assigns[:editor_dirty] do
|
||||||
|
# Reload the page from database
|
||||||
|
page = Pages.get_page(socket.assigns.page.slug)
|
||||||
|
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page, page)
|
||||||
|
|> assign(:editing_blocks, page.blocks)
|
||||||
|
|> assign(:editor_dirty, false)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|
|> assign(:editor_history, [])
|
||||||
|
|> assign(:editor_future, [])
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_revert_theme(socket) do
|
||||||
|
if socket.assigns[:theme_dirty] do
|
||||||
|
original = socket.assigns.theme_editor_original
|
||||||
|
generated_css = CSSGenerator.generate(original, &BerrypodWeb.Endpoint.static_path/1)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:theme_editor_settings, original)
|
||||||
|
|> assign(:theme_settings, original)
|
||||||
|
|> assign(:generated_css, generated_css)
|
||||||
|
|> assign(:theme_dirty, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_revert_site(socket) do
|
||||||
|
if socket.assigns[:site_dirty] do
|
||||||
|
original = socket.assigns.site_editor_original
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:site_announcement_text, original.announcement_text)
|
||||||
|
|> assign(:site_announcement_link, original.announcement_link)
|
||||||
|
|> assign(:site_announcement_style, original.announcement_style)
|
||||||
|
|> assign(:site_footer_about, original.footer_about)
|
||||||
|
|> assign(:site_footer_copyright, original.footer_copyright)
|
||||||
|
|> assign(:site_footer_show_newsletter, original.show_newsletter)
|
||||||
|
|> assign(:site_dirty, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Small helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp maybe_put(map, _key, nil), do: map
|
||||||
|
defp maybe_put(map, _key, ""), do: map
|
||||||
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -86,9 +86,12 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
editing={@editing}
|
editing={@editing}
|
||||||
theme_editing={Map.get(assigns, :theme_editing, false)}
|
theme_editing={Map.get(assigns, :theme_editing, false)}
|
||||||
editor_dirty={@editor_dirty}
|
editor_dirty={@editor_dirty}
|
||||||
|
theme_dirty={Map.get(assigns, :theme_dirty, false)}
|
||||||
|
site_dirty={Map.get(assigns, :site_dirty, false)}
|
||||||
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
||||||
editor_save_status={@editor_save_status}
|
editor_save_status={@editor_save_status}
|
||||||
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
||||||
|
editor_nav_blocked={Map.get(assigns, :editor_nav_blocked)}
|
||||||
has_editable_page={@page != nil}
|
has_editable_page={@page != nil}
|
||||||
>
|
>
|
||||||
<.editor_panel_content
|
<.editor_panel_content
|
||||||
@ -123,6 +126,15 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
settings_form={Map.get(assigns, :settings_form)}
|
settings_form={Map.get(assigns, :settings_form)}
|
||||||
settings_dirty={Map.get(assigns, :settings_dirty, false)}
|
settings_dirty={Map.get(assigns, :settings_dirty, false)}
|
||||||
settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
|
settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
|
||||||
|
site_header_nav={Map.get(assigns, :site_header_nav, [])}
|
||||||
|
site_footer_nav={Map.get(assigns, :site_footer_nav, [])}
|
||||||
|
site_social_links={Map.get(assigns, :site_social_links, [])}
|
||||||
|
site_announcement_text={Map.get(assigns, :site_announcement_text, "")}
|
||||||
|
site_announcement_link={Map.get(assigns, :site_announcement_link, "")}
|
||||||
|
site_announcement_style={Map.get(assigns, :site_announcement_style, "info")}
|
||||||
|
site_footer_about={Map.get(assigns, :site_footer_about, "")}
|
||||||
|
site_footer_copyright={Map.get(assigns, :site_footer_copyright, "")}
|
||||||
|
site_footer_show_newsletter={Map.get(assigns, :site_footer_show_newsletter, true)}
|
||||||
/>
|
/>
|
||||||
</.editor_sheet>
|
</.editor_sheet>
|
||||||
"""
|
"""
|
||||||
@ -160,6 +172,15 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
attr :settings_form, :map, default: nil
|
attr :settings_form, :map, default: nil
|
||||||
attr :settings_dirty, :boolean, default: false
|
attr :settings_dirty, :boolean, default: false
|
||||||
attr :settings_save_status, :atom, default: :idle
|
attr :settings_save_status, :atom, default: :idle
|
||||||
|
attr :site_header_nav, :list, default: []
|
||||||
|
attr :site_footer_nav, :list, default: []
|
||||||
|
attr :site_social_links, :list, default: []
|
||||||
|
attr :site_announcement_text, :string, default: ""
|
||||||
|
attr :site_announcement_link, :string, default: ""
|
||||||
|
attr :site_announcement_style, :string, default: "info"
|
||||||
|
attr :site_footer_about, :string, default: ""
|
||||||
|
attr :site_footer_copyright, :string, default: ""
|
||||||
|
attr :site_footer_show_newsletter, :boolean, default: true
|
||||||
|
|
||||||
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
|
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -200,6 +221,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
|
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
|
||||||
|
# Legacy settings tab - will be removed once page settings are merged into Page tab
|
||||||
~H"""
|
~H"""
|
||||||
<BerrypodWeb.ShopComponents.SettingsEditor.settings_editor
|
<BerrypodWeb.ShopComponents.SettingsEditor.settings_editor
|
||||||
page={@page}
|
page={@page}
|
||||||
@ -213,6 +235,22 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp editor_panel_content(%{editor_active_tab: :site} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<BerrypodWeb.ShopComponents.SiteEditor.site_editor
|
||||||
|
site_header_nav={@site_header_nav}
|
||||||
|
site_footer_nav={@site_footer_nav}
|
||||||
|
site_social_links={@site_social_links}
|
||||||
|
site_announcement_text={@site_announcement_text}
|
||||||
|
site_announcement_link={@site_announcement_link}
|
||||||
|
site_announcement_style={@site_announcement_style}
|
||||||
|
site_footer_about={@site_footer_about}
|
||||||
|
site_footer_copyright={@site_footer_copyright}
|
||||||
|
site_footer_show_newsletter={@site_footer_show_newsletter}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
# Theme editor content - uses shared component
|
# Theme editor content - uses shared component
|
||||||
attr :theme_editor_settings, :map, default: nil
|
attr :theme_editor_settings, :map, default: nil
|
||||||
attr :theme_editor_active_preset, :atom, default: nil
|
attr :theme_editor_active_preset, :atom, default: nil
|
||||||
@ -467,7 +505,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
||||||
~H"<.social_links_card />"
|
~H"<.social_links_card links={assigns[:social_links] || []} />"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do
|
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do
|
||||||
|
|||||||
@ -14,7 +14,7 @@ defmodule BerrypodWeb.ThemeHook do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias Berrypod.{Products, Settings, Media}
|
alias Berrypod.{Products, Settings, Site, Media}
|
||||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||||
|
|
||||||
@default_header_nav [
|
@default_header_nav [
|
||||||
@ -67,8 +67,12 @@ defmodule BerrypodWeb.ThemeHook do
|
|||||||
:is_admin,
|
:is_admin,
|
||||||
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
|
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
|
||||||
)
|
)
|
||||||
|> assign(:header_nav_items, load_nav("header_nav", @default_header_nav))
|
|> assign(:header_nav_items, load_header_nav())
|
||||||
|> assign(:footer_nav_items, load_nav("footer_nav", @default_footer_nav))
|
|> assign(:footer_nav_items, load_footer_nav())
|
||||||
|
|> assign(:social_links, Site.social_links_for_shop())
|
||||||
|
|> assign(:announcement_text, Site.announcement_text())
|
||||||
|
|> assign(:announcement_link, Site.announcement_link())
|
||||||
|
|> assign(:announcement_style, Site.announcement_style())
|
||||||
|
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
@ -87,10 +91,24 @@ defmodule BerrypodWeb.ThemeHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_nav(key, default) do
|
defp load_header_nav do
|
||||||
case Settings.get_setting(key) do
|
items = Site.nav_items_for_shop("header")
|
||||||
items when is_list(items) -> items
|
if items == [], do: @default_header_nav, else: add_active_slugs(items)
|
||||||
_ -> default
|
end
|
||||||
end
|
|
||||||
|
defp load_footer_nav do
|
||||||
|
items = Site.nav_items_for_shop("footer")
|
||||||
|
if items == [], do: @default_footer_nav, else: items
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add active_slugs for Shop nav item to highlight on collection and pdp pages
|
||||||
|
defp add_active_slugs(items) do
|
||||||
|
Enum.map(items, fn item ->
|
||||||
|
if item["slug"] == "collection" do
|
||||||
|
Map.put(item, "active_slugs", ["collection", "pdp"])
|
||||||
|
else
|
||||||
|
item
|
||||||
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
16
priv/repo/migrations/20260327163916_create_social_links.exs
Normal file
16
priv/repo/migrations/20260327163916_create_social_links.exs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.CreateSocialLinks do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:social_links, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :platform, :string, null: false
|
||||||
|
add :url, :string, null: false
|
||||||
|
add :position, :integer, null: false, default: 0
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:social_links, [:position])
|
||||||
|
end
|
||||||
|
end
|
||||||
19
priv/repo/migrations/20260327163919_create_nav_items.exs
Normal file
19
priv/repo/migrations/20260327163919_create_nav_items.exs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.CreateNavItems do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:nav_items, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :location, :string, null: false
|
||||||
|
add :label, :string, null: false
|
||||||
|
add :url, :string, null: false
|
||||||
|
add :page_id, references(:pages, type: :binary_id, on_delete: :nilify_all)
|
||||||
|
add :position, :integer, null: false, default: 0
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:nav_items, [:location, :position])
|
||||||
|
create index(:nav_items, [:page_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AllowNullSocialLinkUrl do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
# Allow blank URLs so users can add a link and then paste the URL
|
||||||
|
# SQLite doesn't support ALTER COLUMN, so we recreate the table
|
||||||
|
execute(
|
||||||
|
"CREATE TABLE social_links_new (
|
||||||
|
id BLOB PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inserted_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"DROP TABLE social_links_new"
|
||||||
|
)
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"INSERT INTO social_links_new SELECT * FROM social_links",
|
||||||
|
"INSERT INTO social_links SELECT * FROM social_links_new"
|
||||||
|
)
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"DROP TABLE social_links",
|
||||||
|
"CREATE TABLE social_links (
|
||||||
|
id BLOB PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inserted_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)"
|
||||||
|
)
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"ALTER TABLE social_links_new RENAME TO social_links",
|
||||||
|
"ALTER TABLE social_links RENAME TO social_links_new"
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:social_links, [:position])
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -10,7 +10,7 @@
|
|||||||
# We recommend using the bang functions (`insert!`, `update!`
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
# and so on) as they will fail if something goes wrong.
|
# and so on) as they will fail if something goes wrong.
|
||||||
|
|
||||||
alias Berrypod.Settings
|
alias Berrypod.{Settings, Site}
|
||||||
|
|
||||||
# Set default theme settings (Studio preset)
|
# Set default theme settings (Studio preset)
|
||||||
IO.puts("Setting up default theme settings...")
|
IO.puts("Setting up default theme settings...")
|
||||||
@ -18,3 +18,10 @@ IO.puts("Setting up default theme settings...")
|
|||||||
{:ok, _theme} = Settings.apply_preset(:studio)
|
{:ok, _theme} = Settings.apply_preset(:studio)
|
||||||
|
|
||||||
IO.puts("✓ Default theme settings applied (Studio preset)")
|
IO.puts("✓ Default theme settings applied (Studio preset)")
|
||||||
|
|
||||||
|
# Seed default navigation and social links
|
||||||
|
IO.puts("Setting up default site content...")
|
||||||
|
|
||||||
|
Site.seed_defaults()
|
||||||
|
|
||||||
|
IO.puts("✓ Default navigation and social links created")
|
||||||
|
|||||||
233
test/berrypod/site_test.exs
Normal file
233
test/berrypod/site_test.exs
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
defmodule Berrypod.SiteTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
|
||||||
|
alias Berrypod.Site
|
||||||
|
alias Berrypod.Site.SocialLink
|
||||||
|
|
||||||
|
describe "social links CRUD" do
|
||||||
|
test "creates a social link" do
|
||||||
|
assert {:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test"})
|
||||||
|
assert link.platform == "instagram"
|
||||||
|
assert link.url == "https://instagram.com/test"
|
||||||
|
assert link.position == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lists social links ordered by position" do
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||||
|
|
||||||
|
links = Site.list_social_links()
|
||||||
|
assert length(links) == 3
|
||||||
|
assert Enum.map(links, & &1.platform) == ["instagram", "twitter", "github"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates a social link" do
|
||||||
|
{:ok, link} = Site.create_social_link(%{platform: "custom", url: ""})
|
||||||
|
{:ok, updated} = Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
|
||||||
|
|
||||||
|
assert updated.url == "https://example.com"
|
||||||
|
assert updated.platform == "website"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes a social link" do
|
||||||
|
{:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
|
||||||
|
assert {:ok, _} = Site.delete_social_link(link)
|
||||||
|
assert Site.list_social_links() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reorders social links" do
|
||||||
|
{:ok, a} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||||
|
{:ok, b} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||||
|
{:ok, c} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||||
|
|
||||||
|
# Reorder: github, instagram, twitter
|
||||||
|
Site.reorder_social_links([c.id, a.id, b.id])
|
||||||
|
|
||||||
|
links = Site.list_social_links()
|
||||||
|
assert Enum.map(links, & &1.platform) == ["github", "instagram", "twitter"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "social_links_for_shop/0" do
|
||||||
|
test "returns links formatted for shop components" do
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test", position: 0})
|
||||||
|
|
||||||
|
[link] = Site.social_links_for_shop()
|
||||||
|
assert link.platform == :instagram
|
||||||
|
assert link.url == "https://instagram.com/test"
|
||||||
|
assert link.label == "Instagram"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters out links with empty URLs" do
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "custom", url: "", position: 1})
|
||||||
|
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: nil, position: 2})
|
||||||
|
|
||||||
|
links = Site.social_links_for_shop()
|
||||||
|
assert length(links) == 1
|
||||||
|
assert hd(links).platform == :instagram
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "SocialLink.normalize_url/1" do
|
||||||
|
test "trims whitespace" do
|
||||||
|
assert SocialLink.normalize_url(" https://example.com ") == "https://example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "adds https:// to bare domains" do
|
||||||
|
assert SocialLink.normalize_url("example.com") == "https://example.com"
|
||||||
|
assert SocialLink.normalize_url("github.com/user") == "https://github.com/user"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves http://" do
|
||||||
|
assert SocialLink.normalize_url("http://example.com") == "http://example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves https://" do
|
||||||
|
assert SocialLink.normalize_url("https://example.com") == "https://example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves app deep links" do
|
||||||
|
assert SocialLink.normalize_url("tg://resolve?domain=channel") == "tg://resolve?domain=channel"
|
||||||
|
assert SocialLink.normalize_url("spotify:track:123") == "spotify:track:123"
|
||||||
|
assert SocialLink.normalize_url("rss://feed.example.com") == "rss://feed.example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves mailto: and tel:" do
|
||||||
|
assert SocialLink.normalize_url("mailto:user@example.com") == "mailto:user@example.com"
|
||||||
|
assert SocialLink.normalize_url("tel:+1234567890") == "tel:+1234567890"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles nil and empty strings" do
|
||||||
|
assert SocialLink.normalize_url(nil) == nil
|
||||||
|
assert SocialLink.normalize_url("") == ""
|
||||||
|
assert SocialLink.normalize_url(" ") == ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "SocialLink.detect_platform/1" do
|
||||||
|
test "detects common platforms from URLs" do
|
||||||
|
assert SocialLink.detect_platform("https://github.com/user") == "github"
|
||||||
|
assert SocialLink.detect_platform("https://twitter.com/user") == "twitter"
|
||||||
|
assert SocialLink.detect_platform("https://x.com/user") == "twitter"
|
||||||
|
assert SocialLink.detect_platform("https://instagram.com/user") == "instagram"
|
||||||
|
assert SocialLink.detect_platform("https://bsky.app/profile/user") == "bluesky"
|
||||||
|
assert SocialLink.detect_platform("https://mastodon.social/@user") == "mastodon"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles www prefix" do
|
||||||
|
assert SocialLink.detect_platform("https://www.github.com/user") == "github"
|
||||||
|
assert SocialLink.detect_platform("https://www.youtube.com/watch?v=123") == "youtube"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects subdomain-based platforms" do
|
||||||
|
assert SocialLink.detect_platform("https://artist.bandcamp.com") == "bandcamp"
|
||||||
|
assert SocialLink.detect_platform("https://writer.substack.com") == "substack"
|
||||||
|
assert SocialLink.detect_platform("https://user.tumblr.com") == "tumblr"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects platforms from URI schemes" do
|
||||||
|
assert SocialLink.detect_platform("tg://resolve?domain=channel") == "telegram"
|
||||||
|
assert SocialLink.detect_platform("spotify:track:123") == "spotify"
|
||||||
|
assert SocialLink.detect_platform("rss://feed.example.com") == "rss"
|
||||||
|
assert SocialLink.detect_platform("discord://discord.com/channels/123") == "discord"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normalizes URLs before detection" do
|
||||||
|
assert SocialLink.detect_platform("github.com/user") == "github"
|
||||||
|
assert SocialLink.detect_platform(" twitter.com/user ") == "twitter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns custom for unknown domains" do
|
||||||
|
assert SocialLink.detect_platform("https://example.com") == "custom"
|
||||||
|
assert SocialLink.detect_platform("https://my-personal-site.org") == "custom"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for invalid input" do
|
||||||
|
assert SocialLink.detect_platform("") == nil
|
||||||
|
assert SocialLink.detect_platform(nil) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "SocialLink changeset validation" do
|
||||||
|
test "requires platform" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{url: "https://example.com"})
|
||||||
|
assert %{platform: ["can't be blank"]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates platform is in allowed list" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "invalid_platform"})
|
||||||
|
assert %{platform: ["is invalid"]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows empty URL" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: ""})
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows nil URL" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: nil})
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates URL has a scheme" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: "no-scheme.com"})
|
||||||
|
assert %{url: ["must be a valid URL"]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts URLs with any scheme" do
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
|
||||||
|
assert changeset.valid?
|
||||||
|
|
||||||
|
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "announcement settings" do
|
||||||
|
test "stores and retrieves announcement text" do
|
||||||
|
Site.set_announcement_text("Free shipping!")
|
||||||
|
assert Site.announcement_text() == "Free shipping!"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves announcement link" do
|
||||||
|
Site.set_announcement_link("/delivery")
|
||||||
|
assert Site.announcement_link() == "/delivery"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves announcement style" do
|
||||||
|
Site.set_announcement_style("sale")
|
||||||
|
assert Site.announcement_style() == "sale"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to empty string for text and link" do
|
||||||
|
assert Site.announcement_text() == ""
|
||||||
|
assert Site.announcement_link() == ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to info for style" do
|
||||||
|
assert Site.announcement_style() == "info"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "footer settings" do
|
||||||
|
test "stores and retrieves footer about text" do
|
||||||
|
Site.set_footer_about("About us blurb")
|
||||||
|
assert Site.footer_about() == "About us blurb"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves footer copyright" do
|
||||||
|
Site.set_footer_copyright("© 2024 My Shop")
|
||||||
|
assert Site.footer_copyright() == "© 2024 My Shop"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores and retrieves newsletter visibility" do
|
||||||
|
Site.set_show_newsletter(false)
|
||||||
|
assert Site.show_newsletter?() == false
|
||||||
|
|
||||||
|
Site.set_show_newsletter(true)
|
||||||
|
assert Site.show_newsletter?() == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -4,7 +4,7 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import Berrypod.AccountsFixtures
|
import Berrypod.AccountsFixtures
|
||||||
|
|
||||||
alias Berrypod.{Pages, Settings}
|
alias Berrypod.{Pages, Settings, Site}
|
||||||
alias Berrypod.Pages.PageCache
|
alias Berrypod.Pages.PageCache
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@ -16,6 +16,9 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
|
|
||||||
describe "header navigation" do
|
describe "header navigation" do
|
||||||
test "renders default items", %{conn: conn} do
|
test "renders default items", %{conn: conn} do
|
||||||
|
# Seed defaults if not already present
|
||||||
|
Site.seed_defaults()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
|
|
||||||
assert html =~ "Home"
|
assert html =~ "Home"
|
||||||
@ -24,15 +27,15 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
assert html =~ "Contact"
|
assert html =~ "Contact"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders from saved settings", %{conn: conn} do
|
test "renders from database nav items", %{conn: conn} do
|
||||||
Settings.put_setting(
|
# Clear existing and add custom items
|
||||||
"header_nav",
|
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
|
||||||
[
|
|
||||||
%{"label" => "Blog", "href" => "/blog", "slug" => "blog"},
|
{:ok, _} =
|
||||||
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
|
Site.create_nav_item(%{location: "header", label: "Blog", url: "/blog", position: 0})
|
||||||
],
|
|
||||||
"json"
|
{:ok, _} =
|
||||||
)
|
Site.create_nav_item(%{location: "header", label: "FAQ", url: "/faq", position: 1})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
|
|
||||||
@ -44,6 +47,8 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
|
|
||||||
describe "footer navigation" do
|
describe "footer navigation" do
|
||||||
test "renders default items", %{conn: conn} do
|
test "renders default items", %{conn: conn} do
|
||||||
|
Site.seed_defaults()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
|
|
||||||
assert html =~ "Delivery & returns"
|
assert html =~ "Delivery & returns"
|
||||||
@ -51,15 +56,25 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
assert html =~ "Terms of service"
|
assert html =~ "Terms of service"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders from saved settings", %{conn: conn} do
|
test "renders from database nav items", %{conn: conn} do
|
||||||
Settings.put_setting(
|
# Clear existing and add custom items
|
||||||
"footer_nav",
|
for item <- Site.list_nav_items("footer"), do: Site.delete_nav_item(item)
|
||||||
[
|
|
||||||
%{"label" => "Returns", "href" => "/returns", "slug" => "returns"},
|
{:ok, _} =
|
||||||
%{"label" => "Shipping", "href" => "/shipping", "slug" => "shipping"}
|
Site.create_nav_item(%{
|
||||||
],
|
location: "footer",
|
||||||
"json"
|
label: "Returns",
|
||||||
)
|
url: "/returns",
|
||||||
|
position: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Site.create_nav_item(%{
|
||||||
|
location: "footer",
|
||||||
|
label: "Shipping",
|
||||||
|
url: "/shipping",
|
||||||
|
position: 1
|
||||||
|
})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
|
|
||||||
@ -71,16 +86,21 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
|||||||
|
|
||||||
describe "custom page in navigation" do
|
describe "custom page in navigation" do
|
||||||
test "renders when added to header nav", %{conn: conn} do
|
test "renders when added to header nav", %{conn: conn} do
|
||||||
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
{:ok, page} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||||
|
|
||||||
Settings.put_setting(
|
# Clear existing header nav and add custom items
|
||||||
"header_nav",
|
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
|
||||||
[
|
|
||||||
%{"label" => "Home", "href" => "/", "slug" => "home"},
|
{:ok, _} = Site.create_nav_item(%{location: "header", label: "Home", url: "/", position: 0})
|
||||||
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
|
|
||||||
],
|
{:ok, _} =
|
||||||
"json"
|
Site.create_nav_item(%{
|
||||||
)
|
location: "header",
|
||||||
|
label: "FAQ",
|
||||||
|
url: "/faq",
|
||||||
|
page_id: page.id,
|
||||||
|
position: 1
|
||||||
|
})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
|
|
||||||
|
|||||||
@ -171,7 +171,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
view |> element("button[phx-click='editor_save_all']") |> render_click()
|
||||||
|
|
||||||
# Verify persistence
|
# Verify persistence
|
||||||
updated = Pages.get_page("home")
|
updated = Pages.get_page("home")
|
||||||
@ -277,7 +277,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
view |> element("button[phx-click='editor_save_all']") |> render_click()
|
||||||
|
|
||||||
# After save, undo should be disabled
|
# After save, undo should be disabled
|
||||||
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user