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:
@@ -713,6 +713,143 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
"""
|
||||
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
|
||||
defp social_icon(assigns) do
|
||||
~H"""
|
||||
|
||||
@@ -13,21 +13,40 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
## Attributes
|
||||
|
||||
* `theme_settings` - Required. The theme settings map.
|
||||
* `message` - Optional. The announcement message to display.
|
||||
Defaults to "Free delivery on orders over £40".
|
||||
* `message` - The announcement message to display.
|
||||
* `link` - Optional URL to link the announcement to.
|
||||
* `style` - Visual style: "info", "sale", or "warning".
|
||||
|
||||
## Examples
|
||||
|
||||
<.announcement_bar theme_settings={@theme_settings} />
|
||||
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
|
||||
<.announcement_bar theme_settings={@theme_settings} message="Free shipping!" />
|
||||
<.announcement_bar theme_settings={@theme_settings} message="20% off!" link="/sale" style="sale" />
|
||||
"""
|
||||
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
|
||||
# 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"""
|
||||
<div class="announcement-bar">
|
||||
<p>{@message}</p>
|
||||
<div class={"announcement-bar announcement-bar--#{@style}"}>
|
||||
<%= if @link != "" do %>
|
||||
<a href={@link} class="announcement-bar-link">
|
||||
<p>{@display_message}</p>
|
||||
</a>
|
||||
<% else %>
|
||||
<p>{@display_message}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -53,7 +72,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
|
||||
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 """
|
||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
||||
@@ -65,9 +85,106 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
</.shop_layout>
|
||||
"""
|
||||
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
|
||||
|
||||
# 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 """
|
||||
Wraps page content in the standard shop shell: container, header, footer,
|
||||
cart drawer, search modal, and mobile bottom nav.
|
||||
@@ -100,6 +217,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :available_countries, :list, default: []
|
||||
attr :header_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_state, :atom, default: :idle
|
||||
attr :stripe_connected, :boolean, default: true
|
||||
@@ -133,7 +254,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<.skip_link />
|
||||
|
||||
<%= 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 %>
|
||||
|
||||
<.shop_header
|
||||
@@ -156,6 +282,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
mode={@mode}
|
||||
categories={assigns[:categories] || []}
|
||||
footer_nav_items={@footer_nav_items}
|
||||
social_links={@social_links}
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
newsletter_state={@newsletter_state}
|
||||
/>
|
||||
@@ -652,6 +779,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :mode, :atom, default: :live
|
||||
attr :categories, :list, default: []
|
||||
attr :footer_nav_items, :list, default: []
|
||||
attr :social_links, :list, default: []
|
||||
attr :newsletter_enabled, :boolean, default: false
|
||||
attr :newsletter_state, :atom, default: :idle
|
||||
|
||||
@@ -750,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<p class="footer-copyright">
|
||||
© {@current_year} {@site_name}
|
||||
</p>
|
||||
<.social_links />
|
||||
<.social_links links={@social_links} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1071,9 +1199,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :editing, :boolean, default: false
|
||||
attr :theme_editing, :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_save_status, :atom, default: :idle
|
||||
attr :editor_active_tab, :atom, default: :page
|
||||
attr :editor_nav_blocked, :string, default: nil
|
||||
attr :has_editable_page, :boolean, default: false
|
||||
|
||||
slot :inner_block
|
||||
@@ -1084,16 +1215,21 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
case assigns.editor_active_tab do
|
||||
:page -> "Page"
|
||||
:theme -> "Theme"
|
||||
:site -> "Site"
|
||||
:settings -> "Settings"
|
||||
end
|
||||
|
||||
# Any editing mode active
|
||||
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
|
||||
|> assign(:title, title)
|
||||
|> assign(:any_editing, any_editing)
|
||||
|> assign(:any_dirty, any_dirty)
|
||||
|
||||
~H"""
|
||||
<%!-- Floating action button: always visible when panel is closed --%>
|
||||
@@ -1110,7 +1246,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<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>
|
||||
|
||||
<%!-- Overlay to catch taps outside the panel --%>
|
||||
@@ -1131,6 +1267,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
|
||||
data-state={@editor_sheet_state}
|
||||
data-editing={to_string(@any_editing)}
|
||||
data-dirty={to_string(@any_dirty)}
|
||||
phx-hook="EditorSheet"
|
||||
>
|
||||
<%!-- Drag handle for mobile resizing --%>
|
||||
@@ -1141,14 +1278,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<div class="editor-panel-header">
|
||||
<div class="editor-panel-header-left">
|
||||
<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>Unsaved</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-panel-header-actions">
|
||||
<button
|
||||
:if={@editor_active_tab == :page && @editor_save_status == :saved}
|
||||
:if={@editor_save_status == :saved}
|
||||
type="button"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
disabled
|
||||
@@ -1156,11 +1293,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
Saved ✓
|
||||
</button>
|
||||
<button
|
||||
:if={@editor_active_tab == :page && @editor_save_status != :saved}
|
||||
:if={@editor_save_status != :saved}
|
||||
type="button"
|
||||
phx-click="editor_save"
|
||||
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
|
||||
disabled={!@editor_dirty}
|
||||
phx-click="editor_save_all"
|
||||
class={["admin-btn admin-btn-sm", @any_dirty && "admin-btn-primary"]}
|
||||
disabled={!@any_dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -1201,6 +1338,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
}
|
||||
>
|
||||
Page
|
||||
<span
|
||||
:if={@editor_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1211,16 +1353,26 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
aria-selected={to_string(@editor_active_tab == :theme)}
|
||||
>
|
||||
Theme
|
||||
<span
|
||||
:if={@theme_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
phx-click="editor_set_tab"
|
||||
phx-value-tab="settings"
|
||||
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :settings)}
|
||||
phx-value-tab="site"
|
||||
class={["editor-tab", @editor_active_tab == :site && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :site)}
|
||||
>
|
||||
Settings
|
||||
Site
|
||||
<span
|
||||
:if={@site_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1231,6 +1383,37 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
|
||||
<%!-- Live region for screen reader announcements --%>
|
||||
<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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def handle_event("validate_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
@@ -274,11 +273,10 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="admin-section-desc">
|
||||
{Backup.format_size(@stats.total_size)} total ·
|
||||
{length(@stats.tables)} tables ·
|
||||
{@stats.key_counts["products"] || 0} products ·
|
||||
{@stats.key_counts["orders"] || 0} orders ·
|
||||
{@stats.key_counts["images"] || 0} images
|
||||
{Backup.format_size(@stats.total_size)} total · {length(@stats.tables)} tables · {@stats.key_counts[
|
||||
"products"
|
||||
] || 0} products · {@stats.key_counts["orders"] || 0} orders · {@stats.key_counts["images"] ||
|
||||
0} images
|
||||
</p>
|
||||
<div class="admin-section-body">
|
||||
<button
|
||||
@@ -459,10 +457,22 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
<div class="backup-comparison-col">
|
||||
<h4 class="backup-comparison-label">Current</h4>
|
||||
<dl class="backup-comparison-stats">
|
||||
<div><dt>Size</dt><dd>{Backup.format_size(@stats.total_size)}</dd></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>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{Backup.format_size(@stats.total_size)}</dd>
|
||||
</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>
|
||||
</div>
|
||||
<div class="backup-comparison-arrow">
|
||||
@@ -471,10 +481,22 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
<div class="backup-comparison-col">
|
||||
<h4 class="backup-comparison-label">Uploaded</h4>
|
||||
<dl class="backup-comparison-stats">
|
||||
<div><dt>Size</dt><dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd></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>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,7 +504,9 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
|
||||
<div class="backup-validation backup-validation-ok">
|
||||
<.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>
|
||||
|
||||
<%= if @restoring do %>
|
||||
@@ -496,22 +520,40 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
<% else %>
|
||||
<%= if @confirming_restore do %>
|
||||
<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">
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -526,7 +568,11 @@ defmodule BerrypodWeb.Admin.Backup do
|
||||
</span>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -851,36 +851,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
|
||||
# ── 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
|
||||
Enum.map(providers, fn provider ->
|
||||
option = %{
|
||||
|
||||
@@ -20,7 +20,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
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.{BlockEditor, BlockTypes, Defaults}
|
||||
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
||||
@@ -53,6 +53,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_active_tab, :page)
|
||||
# Theme editing state
|
||||
|> assign(:theme_editing, false)
|
||||
|> assign(:theme_dirty, false)
|
||||
|> assign(:theme_editor_original, nil)
|
||||
|> assign(:theme_editor_settings, nil)
|
||||
|> assign(:theme_editor_active_preset, nil)
|
||||
|> assign(:theme_editor_logo_image, nil)
|
||||
@@ -64,6 +66,21 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
# Settings editing state
|
||||
|> assign(:settings_dirty, false)
|
||||
|> 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_events, :handle_event, &handle_editor_event/3)
|
||||
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
|
||||
@@ -119,6 +136,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_active_tab, :settings)
|
||||
|> maybe_enter_theme_mode()
|
||||
|
||||
"site" ->
|
||||
socket
|
||||
|> assign(:editor_sheet_state, :open)
|
||||
|> assign(:editor_active_tab, :site)
|
||||
|> maybe_enter_site_mode()
|
||||
|
||||
nil ->
|
||||
# No edit param - collapse the editor (supports browser back)
|
||||
assign(socket, :editor_sheet_state, :collapsed)
|
||||
@@ -153,6 +176,14 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
defp handle_editor_info(:editor_clear_save_status, socket) do
|
||||
@@ -192,18 +223,6 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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
|
||||
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
|
||||
if socket.assigns.is_admin do
|
||||
@@ -249,6 +268,26 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# Open the sheet and sync URL with new tab
|
||||
@@ -276,6 +315,54 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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
|
||||
if socket.assigns.editing do
|
||||
handle_editor_action(action, params, socket)
|
||||
@@ -302,8 +389,29 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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}
|
||||
|
||||
# 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 ───────────────────────────────────
|
||||
|
||||
defp handle_editor_action("move_up", %{"id" => id}, socket) do
|
||||
@@ -603,6 +711,11 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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
|
||||
slug = socket.assigns.page.slug
|
||||
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
|
||||
preset_atom = String.to_existing_atom(preset_name)
|
||||
|
||||
case Settings.apply_preset(preset_atom) do
|
||||
{:ok, theme_settings} ->
|
||||
# Get preset values and apply in-memory (don't persist yet)
|
||||
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 =
|
||||
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_active_preset, preset_atom)
|
||||
|> assign(:theme_editor_contrast_warning, contrast_warning)
|
||||
|> assign(:theme_dirty, true)
|
||||
# Update shop state so layout reflects changes live
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -658,6 +777,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
socket
|
||||
)
|
||||
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")
|
||||
# Also update the main assigns so ThemeHook sees the change
|
||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||
@@ -677,6 +798,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
value = params[field]
|
||||
|
||||
if value do
|
||||
# Standalone settings save immediately for now
|
||||
# TODO: Track these separately for proper revert
|
||||
Settings.put_setting(field, value, "string")
|
||||
{:halt, assign(socket, String.to_existing_atom(field), value)}
|
||||
else
|
||||
@@ -719,13 +842,14 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
end
|
||||
|
||||
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
|
||||
Media.delete_image(logo)
|
||||
end
|
||||
|
||||
{:ok, theme_settings} =
|
||||
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
|
||||
|
||||
# Update settings in memory only
|
||||
current = socket.assigns.theme_editor_settings
|
||||
theme_settings = %{current | logo_image_id: nil, show_site_name: true}
|
||||
generated_css = CSSGenerator.generate(theme_settings)
|
||||
|
||||
socket =
|
||||
@@ -733,38 +857,51 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:theme_editor_logo_image, nil)
|
||||
|> assign(:logo_image, nil)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_dirty, true)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
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
|
||||
Media.delete_image(header)
|
||||
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
|
||||
|> assign(:theme_editor_header_image, nil)
|
||||
|> assign(:header_image, nil)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_dirty, true)
|
||||
|> assign(:theme_editor_contrast_warning, :ok)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
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
|
||||
Media.delete_image(icon)
|
||||
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
|
||||
|> assign(:theme_editor_icon_image, nil)
|
||||
|> assign(:icon_image, nil)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_dirty, true)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
@@ -853,6 +990,222 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
# Catch-all for unknown settings actions
|
||||
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
|
||||
defp has_settings_changed?(page, params) do
|
||||
page.title != (params["title"] || "") or
|
||||
@@ -881,30 +1234,34 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
assign(socket, :settings_form, form)
|
||||
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
|
||||
case Settings.update_theme_settings(attrs) do
|
||||
{:ok, theme_settings} ->
|
||||
generated_css =
|
||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||
current = socket.assigns.theme_editor_settings
|
||||
|
||||
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 =
|
||||
socket
|
||||
# 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)
|
||||
generated_css =
|
||||
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
||||
|
||||
{:halt, socket}
|
||||
active_preset = Presets.detect_preset(theme_settings)
|
||||
|
||||
{:error, _} ->
|
||||
{:halt, socket}
|
||||
end
|
||||
socket =
|
||||
socket
|
||||
# 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
|
||||
|
||||
defp maybe_recompute_contrast(socket, field)
|
||||
@@ -1017,6 +1374,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|
||||
socket
|
||||
|> assign(:theme_editing, true)
|
||||
|> assign(:theme_dirty, false)
|
||||
|> assign(:theme_editor_original, theme_settings)
|
||||
|> assign(:theme_editor_settings, theme_settings)
|
||||
|> assign(:theme_editor_active_preset, active_preset)
|
||||
|> assign(:theme_editor_logo_image, logo_image)
|
||||
@@ -1054,4 +1413,189 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
:ok
|
||||
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
|
||||
|
||||
@@ -86,9 +86,12 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
editing={@editing}
|
||||
theme_editing={Map.get(assigns, :theme_editing, false)}
|
||||
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_save_status={@editor_save_status}
|
||||
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
|
||||
editor_nav_blocked={Map.get(assigns, :editor_nav_blocked)}
|
||||
has_editable_page={@page != nil}
|
||||
>
|
||||
<.editor_panel_content
|
||||
@@ -123,6 +126,15 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
settings_form={Map.get(assigns, :settings_form)}
|
||||
settings_dirty={Map.get(assigns, :settings_dirty, false)}
|
||||
settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
|
||||
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>
|
||||
"""
|
||||
@@ -160,6 +172,15 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
attr :settings_form, :map, default: nil
|
||||
attr :settings_dirty, :boolean, default: false
|
||||
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
|
||||
~H"""
|
||||
@@ -200,6 +221,7 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
end
|
||||
|
||||
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"""
|
||||
<BerrypodWeb.ShopComponents.SettingsEditor.settings_editor
|
||||
page={@page}
|
||||
@@ -213,6 +235,22 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
"""
|
||||
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
|
||||
attr :theme_editor_settings, :map, default: nil
|
||||
attr :theme_editor_active_preset, :atom, default: nil
|
||||
@@ -467,7 +505,7 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
end
|
||||
|
||||
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
||||
~H"<.social_links_card />"
|
||||
~H"<.social_links_card links={assigns[:social_links] || []} />"
|
||||
end
|
||||
|
||||
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do
|
||||
|
||||
@@ -14,7 +14,7 @@ defmodule BerrypodWeb.ThemeHook do
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias Berrypod.{Products, Settings, Media}
|
||||
alias Berrypod.{Products, Settings, Site, Media}
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
@default_header_nav [
|
||||
@@ -67,8 +67,12 @@ defmodule BerrypodWeb.ThemeHook do
|
||||
:is_admin,
|
||||
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
|
||||
)
|
||||
|> assign(:header_nav_items, load_nav("header_nav", @default_header_nav))
|
||||
|> assign(:footer_nav_items, load_nav("footer_nav", @default_footer_nav))
|
||||
|> assign(:header_nav_items, load_header_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}
|
||||
end
|
||||
@@ -87,10 +91,24 @@ defmodule BerrypodWeb.ThemeHook do
|
||||
end
|
||||
end
|
||||
|
||||
defp load_nav(key, default) do
|
||||
case Settings.get_setting(key) do
|
||||
items when is_list(items) -> items
|
||||
_ -> default
|
||||
end
|
||||
defp load_header_nav do
|
||||
items = Site.nav_items_for_shop("header")
|
||||
if items == [], do: @default_header_nav, else: add_active_slugs(items)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user