add Site context with social links editor and site-wide settings
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:
jamey
2026-03-28 10:09:33 +00:00
parent 0b86cd66ce
commit 638bb4fb70
24 changed files with 3121 additions and 195 deletions

View File

@@ -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"""

View File

@@ -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

View 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