add admin page editor with block reordering and management
Stage 6 of the page builder: admin UI at /admin/pages for managing page layouts. Page list shows all 14 pages grouped by category. Editor supports reorder (up/down), add, remove, duplicate, save, and reset to defaults. DirtyGuard JS hook warns on unsaved changes. ARIA live regions announce block operations for screen readers. Also: regenerate admin icons (81 rules via mix task with @layer wrapping), add gen_smtp dep for SMTP email adapter, add :key to page renderer block loop for correct LiveView diffing. 1309 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21f57e39e2
commit
660fda928f
@ -1062,4 +1062,260 @@
|
||||
border-top: 1px solid var(--t-surface-sunken, #e5e5e5);
|
||||
}
|
||||
|
||||
/* ── Page editor ── */
|
||||
|
||||
.page-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.page-group-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-group-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
border: 1px solid var(--t-border-default);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--t-surface-base);
|
||||
text-decoration: none;
|
||||
color: var(--t-text-primary);
|
||||
transition: background 100ms;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--t-border-default);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--t-surface-sunken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--t-surface-sunken);
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
|
||||
}
|
||||
|
||||
.page-card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-card-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-card-meta {
|
||||
font-size: 0.75rem;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
|
||||
}
|
||||
|
||||
.page-card-arrow {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Block list in editor */
|
||||
|
||||
.block-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.block-list-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
|
||||
font-size: 0.875rem;
|
||||
border: 1px dashed var(--t-border-default);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.block-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--t-border-default);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--t-surface-base);
|
||||
transition: box-shadow 150ms;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--t-text-primary) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.block-card-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 60%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-card-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.block-card-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-remove-btn {
|
||||
color: color-mix(in oklch, var(--t-status-error) 70%, transparent);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: var(--t-status-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Add block button */
|
||||
|
||||
.block-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.block-add-btn {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Block picker */
|
||||
|
||||
.block-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.block-picker {
|
||||
background: var(--t-surface-base);
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
border-radius: 0.75rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
.block-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
& h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.block-picker-search {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.block-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.block-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--t-border-default);
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
transition: background 100ms;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--t-surface-sunken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-picker-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
} /* @layer admin */
|
||||
|
||||
@ -74,6 +74,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-arrow-uturn-left {
|
||||
--hero-arrow-uturn-left: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2015%203%209m0%200%206-6M3%209h12a6%206%200%200%201%200%2012h-3"/></svg>');
|
||||
-webkit-mask: var(--hero-arrow-uturn-left);
|
||||
mask: var(--hero-arrow-uturn-left);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-arrow-uturn-left-mini {
|
||||
--hero-arrow-uturn-left-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M7.793%202.232a.75.75%200%200%201-.025%201.06L3.622%207.25h10.003a5.375%205.375%200%200%201%200%2010.75H10.75a.75.75%200%200%201%200-1.5h2.875a3.875%203.875%200%200%200%200-7.75H3.622l4.146%203.957a.75.75%200%200%201-1.036%201.085l-5.5-5.25a.75.75%200%200%201%200-1.085l5.5-5.25a.75.75%200%200%201%201.06.025Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-arrow-uturn-left-mini);
|
||||
@ -86,6 +98,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-arrow-uturn-right {
|
||||
--hero-arrow-uturn-right: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m15%2015%206-6m0%200-6-6m6%206H9a6%206%200%200%200%200%2012h3"/></svg>');
|
||||
-webkit-mask: var(--hero-arrow-uturn-right);
|
||||
mask: var(--hero-arrow-uturn-right);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-banknotes {
|
||||
--hero-banknotes: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M2.25%2018.75a60.07%2060.07%200%200%201%2015.797%202.101c.727.198%201.453-.342%201.453-1.096V18.75M3.75%204.5v.75A.75.75%200%200%201%203%206h-.75m0%200v-.375c0-.621.504-1.125%201.125-1.125H20.25M2.25%206v9m18-10.5v.75c0%20.414.336.75.75.75h.75m-1.5-1.5h.375c.621%200%201.125.504%201.125%201.125v9.75c0%20.621-.504%201.125-1.125%201.125h-.375m1.5-1.5H21a.75.75%200%200%200-.75.75v.75m0%200H3.75m0%200h-.375a1.125%201.125%200%200%201-1.125-1.125V15m1.5%201.5v-.75A.75.75%200%200%200%203%2015h-.75M15%2010.5a3%203%200%201%201-6%200%203%203%200%200%201%206%200Zm3%200h.008v.008H18V10.5Zm-12%200h.008v.008H6V10.5Z"/></svg>');
|
||||
-webkit-mask: var(--hero-banknotes);
|
||||
@ -122,6 +146,18 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-calculator {
|
||||
--hero-calculator: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.75%2015.75V18m-7.5-6.75h.008v.008H8.25v-.008Zm0%202.25h.008v.008H8.25V13.5Zm0%202.25h.008v.008H8.25v-.008Zm0%202.25h.008v.008H8.25V18Zm2.498-6.75h.007v.008h-.007v-.008Zm0%202.25h.007v.008h-.007V13.5Zm0%202.25h.007v.008h-.007v-.008Zm0%202.25h.007v.008h-.007V18Zm2.504-6.75h.008v.008h-.008v-.008Zm0%202.25h.008v.008h-.008V13.5Zm0%202.25h.008v.008h-.008v-.008Zm0%202.25h.008v.008h-.008V18Zm2.498-6.75h.008v.008h-.008v-.008Zm0%202.25h.008v.008h-.008V13.5ZM8.25%206h7.5v2.25h-7.5V6ZM12%202.25c-1.892%200-3.758.11-5.593.322C5.307%202.7%204.5%203.65%204.5%204.757V19.5a2.25%202.25%200%200%200%202.25%202.25h10.5a2.25%202.25%200%200%200%202.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507%2048.507%200%200%200%2012%202.25Z"/></svg>');
|
||||
-webkit-mask: var(--hero-calculator);
|
||||
mask: var(--hero-calculator);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-chart-bar {
|
||||
--hero-chart-bar: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M3%2013.125C3%2012.504%203.504%2012%204.125%2012h2.25c.621%200%201.125.504%201.125%201.125v6.75C7.5%2020.496%206.996%2021%206.375%2021h-2.25A1.125%201.125%200%200%201%203%2019.875v-6.75ZM9.75%208.625c0-.621.504-1.125%201.125-1.125h2.25c.621%200%201.125.504%201.125%201.125v11.25c0%20.621-.504%201.125-1.125%201.125h-2.25a1.125%201.125%200%200%201-1.125-1.125V8.625ZM16.5%204.125c0-.621.504-1.125%201.125-1.125h2.25C20.496%203%2021%203.504%2021%204.125v15.75c0%20.621-.504%201.125-1.125%201.125h-2.25a1.125%201.125%200%200%201-1.125-1.125V4.125Z"/></svg>');
|
||||
-webkit-mask: var(--hero-chart-bar);
|
||||
@ -134,6 +170,18 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-chat-bubble-left-right {
|
||||
--hero-chat-bubble-left-right: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M20.25%208.511c.884.284%201.5%201.128%201.5%202.097v4.286c0%201.136-.847%202.1-1.98%202.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354%200-2.694-.055-4.02-.163a2.115%202.115%200%200%201-.825-.242m9.345-8.334a2.126%202.126%200%200%200-.476-.095%2048.64%2048.64%200%200%200-8.048%200c-1.131.094-1.976%201.057-1.976%202.192v4.286c0%20.837.46%201.58%201.155%201.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455%2048.455%200%200%200%2011.25%203c-2.115%200-4.198.137-6.24.402-1.608.209-2.76%201.614-2.76%203.235v6.226c0%201.621%201.152%203.026%202.76%203.235.577.075%201.157.14%201.74.194V21l4.155-4.155"/></svg>');
|
||||
-webkit-mask: var(--hero-chat-bubble-left-right);
|
||||
mask: var(--hero-chat-bubble-left-right);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-check-badge {
|
||||
--hero-check-badge: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012.75%2011.25%2015%2015%209.75M21%2012c0%201.268-.63%202.39-1.593%203.068a3.745%203.745%200%200%201-1.043%203.296%203.745%203.745%200%200%201-3.296%201.043A3.745%203.745%200%200%201%2012%2021c-1.268%200-2.39-.63-3.068-1.593a3.746%203.746%200%200%201-3.296-1.043%203.745%203.745%200%200%201-1.043-3.296A3.745%203.745%200%200%201%203%2012c0-1.268.63-2.39%201.593-3.068a3.745%203.745%200%200%201%201.043-3.296%203.746%203.746%200%200%201%203.296-1.043A3.746%203.746%200%200%201%2012%203c1.268%200%202.39.63%203.068%201.593a3.746%203.746%200%200%201%203.296%201.043%203.746%203.746%200%200%201%201.043%203.296A3.745%203.745%200%200%201%2021%2012Z"/></svg>');
|
||||
-webkit-mask: var(--hero-check-badge);
|
||||
@ -194,6 +242,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-chevron-right {
|
||||
--hero-chevron-right: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m8.25%204.5%207.5%207.5-7.5%207.5"/></svg>');
|
||||
-webkit-mask: var(--hero-chevron-right);
|
||||
mask: var(--hero-chevron-right);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-chevron-right-mini {
|
||||
--hero-chevron-right-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M8.22%205.22a.75.75%200%200%201%201.06%200l4.25%204.25a.75.75%200%200%201%200%201.06l-4.25%204.25a.75.75%200%200%201-1.06-1.06L11.94%2010%208.22%206.28a.75.75%200%200%201%200-1.06Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-chevron-right-mini);
|
||||
@ -218,6 +278,30 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-clipboard-document {
|
||||
--hero-clipboard-document: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M8.25%207.5V6.108c0-1.135.845-2.098%201.976-2.192.373-.03.748-.057%201.123-.08M15.75%2018H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08M15.75%2018.75v-1.875a3.375%203.375%200%200%200-3.375-3.375h-1.5a1.125%201.125%200%200%201-1.125-1.125v-1.5A3.375%203.375%200%200%200%206.375%207.5H5.25m11.9-3.664A2.251%202.251%200%200%200%2015%202.25h-1.5a2.251%202.251%200%200%200-2.15%201.586m5.8%200c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75%207.5H4.875c-.621%200-1.125.504-1.125%201.125v12c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V16.5a9%209%200%200%200-9-9Z"/></svg>');
|
||||
-webkit-mask: var(--hero-clipboard-document);
|
||||
mask: var(--hero-clipboard-document);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-clipboard-document-list {
|
||||
--hero-clipboard-document-list: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012h3.75M9%2015h3.75M9%2018h3.75m3%20.75H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08m-5.801%200c-.065.21-.1.433-.1.664%200%20.414.336.75.75.75h4.5a.75.75%200%200%200%20.75-.75%202.25%202.25%200%200%200-.1-.664m-5.8%200A2.251%202.251%200%200%201%2013.5%202.25H15c1.012%200%201.867.668%202.15%201.586m-5.8%200c-.376.023-.75.05-1.124.08C9.095%204.01%208.25%204.973%208.25%206.108V8.25m0%200H4.875c-.621%200-1.125.504-1.125%201.125v11.25c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75%2012h.008v.008H6.75V12Zm0%203h.008v.008H6.75V15Zm0%203h.008v.008H6.75V18Z"/></svg>');
|
||||
-webkit-mask: var(--hero-clipboard-document-list);
|
||||
mask: var(--hero-clipboard-document-list);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-clock {
|
||||
--hero-clock: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M12%206v6h4.5m4.5%200a9%209%200%201%201-18%200%209%209%200%200%201%2018%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-clock);
|
||||
@ -290,6 +374,54 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-document {
|
||||
--hero-document: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M19.5%2014.25v-2.625a3.375%203.375%200%200%200-3.375-3.375h-1.5A1.125%201.125%200%200%201%2013.5%207.125v-1.5a3.375%203.375%200%200%200-3.375-3.375H8.25m2.25%200H5.625c-.621%200-1.125.504-1.125%201.125v17.25c0%20.621.504%201.125%201.125%201.125h12.75c.621%200%201.125-.504%201.125-1.125V11.25a9%209%200%200%200-9-9Z"/></svg>');
|
||||
-webkit-mask: var(--hero-document);
|
||||
mask: var(--hero-document);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-document-duplicate-mini {
|
||||
--hero-document-duplicate-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20d="M7%203.5A1.5%201.5%200%200%201%208.5%202h3.879a1.5%201.5%200%200%201%201.06.44l3.122%203.12A1.5%201.5%200%200%201%2017%206.622V12.5a1.5%201.5%200%200%201-1.5%201.5h-1v-3.379a3%203%200%200%200-.879-2.121L10.5%205.379A3%203%200%200%200%208.379%204.5H7v-1Z"/>%20%20<path%20d="M4.5%206A1.5%201.5%200%200%200%203%207.5v9A1.5%201.5%200%200%200%204.5%2018h7a1.5%201.5%200%200%200%201.5-1.5v-5.879a1.5%201.5%200%200%200-.44-1.06L9.44%206.439A1.5%201.5%200%200%200%208.378%206H4.5Z"/></svg>');
|
||||
-webkit-mask: var(--hero-document-duplicate-mini);
|
||||
mask: var(--hero-document-duplicate-mini);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-document-text {
|
||||
--hero-document-text: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M19.5%2014.25v-2.625a3.375%203.375%200%200%200-3.375-3.375h-1.5A1.125%201.125%200%200%201%2013.5%207.125v-1.5a3.375%203.375%200%200%200-3.375-3.375H8.25m0%2012.75h7.5m-7.5%203H12M10.5%202.25H5.625c-.621%200-1.125.504-1.125%201.125v17.25c0%20.621.504%201.125%201.125%201.125h12.75c.621%200%201.125-.504%201.125-1.125V11.25a9%209%200%200%200-9-9Z"/></svg>');
|
||||
-webkit-mask: var(--hero-document-text);
|
||||
mask: var(--hero-document-text);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-envelope {
|
||||
--hero-envelope: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M21.75%206.75v10.5a2.25%202.25%200%200%201-2.25%202.25h-15a2.25%202.25%200%200%201-2.25-2.25V6.75m19.5%200A2.25%202.25%200%200%200%2019.5%204.5h-15a2.25%202.25%200%200%200-2.25%202.25m19.5%200v.243a2.25%202.25%200%200%201-1.07%201.916l-7.5%204.615a2.25%202.25%200%200%201-2.36%200L3.32%208.91a2.25%202.25%200%200%201-1.07-1.916V6.75"/></svg>');
|
||||
-webkit-mask: var(--hero-envelope);
|
||||
mask: var(--hero-envelope);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-exclamation-circle {
|
||||
--hero-exclamation-circle: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M12%209v3.75m9-.75a9%209%200%201%201-18%200%209%209%200%200%201%2018%200Zm-9%203.75h.008v.008H12v-.008Z"/></svg>');
|
||||
-webkit-mask: var(--hero-exclamation-circle);
|
||||
@ -374,6 +506,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-funnel {
|
||||
--hero-funnel: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M12%203c2.755%200%205.455.232%208.083.678.533.09.917.556.917%201.096v1.044a2.25%202.25%200%200%201-.659%201.591l-5.432%205.432a2.25%202.25%200%200%200-.659%201.591v2.927a2.25%202.25%200%200%201-1.244%202.013L9.75%2021v-6.568a2.25%202.25%200%200%200-.659-1.591L3.659%207.409A2.25%202.25%200%200%201%203%205.818V4.774c0-.54.384-1.006.917-1.096A48.32%2048.32%200%200%201%2012%203Z"/></svg>');
|
||||
-webkit-mask: var(--hero-funnel);
|
||||
mask: var(--hero-funnel);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-home {
|
||||
--hero-home: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m2.25%2012%208.954-8.955c.44-.439%201.152-.439%201.591%200L21.75%2012M4.5%209.75v10.125c0%20.621.504%201.125%201.125%201.125H9.75v-4.875c0-.621.504-1.125%201.125-1.125h2.25c.621%200%201.125.504%201.125%201.125V21h4.125c.621%200%201.125-.504%201.125-1.125V9.75M8.25%2021h8.25"/></svg>');
|
||||
-webkit-mask: var(--hero-home);
|
||||
@ -410,6 +554,54 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
--hero-link: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M13.19%208.688a4.5%204.5%200%200%201%201.242%207.244l-4.5%204.5a4.5%204.5%200%200%201-6.364-6.364l1.757-1.757m13.35-.622%201.757-1.757a4.5%204.5%200%200%200-6.364-6.364l-4.5%204.5a4.5%204.5%200%200%200%201.242%207.244"/></svg>');
|
||||
-webkit-mask: var(--hero-link);
|
||||
mask: var(--hero-link);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-lock-closed {
|
||||
--hero-lock-closed: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M16.5%2010.5V6.75a4.5%204.5%200%201%200-9%200v3.75m-.75%2011.25h10.5a2.25%202.25%200%200%200%202.25-2.25v-6.75a2.25%202.25%200%200%200-2.25-2.25H6.75a2.25%202.25%200%200%200-2.25%202.25v6.75a2.25%202.25%200%200%200%202.25%202.25Z"/></svg>');
|
||||
-webkit-mask: var(--hero-lock-closed);
|
||||
mask: var(--hero-lock-closed);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-magnifying-glass {
|
||||
--hero-magnifying-glass: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m21%2021-5.197-5.197m0%200A7.5%207.5%200%201%200%205.196%205.196a7.5%207.5%200%200%200%2010.607%2010.607Z"/></svg>');
|
||||
-webkit-mask: var(--hero-magnifying-glass);
|
||||
mask: var(--hero-magnifying-glass);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-megaphone {
|
||||
--hero-megaphone: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M10.34%2015.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5%204.5%200%201%201%200-9h.75c.704%200%201.402-.03%202.09-.09m0%209.18c.253.962.584%201.892.985%202.783.247.55.06%201.21-.463%201.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845%2020.845%200%200%201-1.44-4.282m3.102.069a18.03%2018.03%200%200%201-.59-4.59c0-1.586.205-3.124.59-4.59m0%209.18a23.848%2023.848%200%200%201%208.835%202.535M10.34%206.66a23.847%2023.847%200%200%200%208.835-2.535m0%200A23.74%2023.74%200%200%200%2018.795%203m.38%201.125a23.91%2023.91%200%200%201%201.014%205.395m-1.014%208.855c-.118.38-.245.754-.38%201.125m.38-1.125a23.91%2023.91%200%200%200%201.014-5.395m0-3.46c.495.413.811%201.035.811%201.73%200%20.695-.316%201.317-.811%201.73m0-3.46a24.347%2024.347%200%200%201%200%203.46"/></svg>');
|
||||
-webkit-mask: var(--hero-megaphone);
|
||||
mask: var(--hero-megaphone);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-minus-circle-mini {
|
||||
--hero-minus-circle-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M10%2018a8%208%200%201%200%200-16%208%208%200%200%200%200%2016ZM6.75%209.25a.75.75%200%200%200%200%201.5h6.5a.75.75%200%200%200%200-1.5h-6.5Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-minus-circle-mini);
|
||||
@ -470,6 +662,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-paper-airplane {
|
||||
--hero-paper-airplane: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M6%2012%203.269%203.125A59.769%2059.769%200%200%201%2021.485%2012%2059.768%2059.768%200%200%201%203.27%2020.875L5.999%2012Zm0%200h7.5"/></svg>');
|
||||
-webkit-mask: var(--hero-paper-airplane);
|
||||
mask: var(--hero-paper-airplane);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-paper-airplane-mini {
|
||||
--hero-paper-airplane-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20d="M3.105%202.288a.75.75%200%200%200-.826.95l1.414%204.926A1.5%201.5%200%200%200%205.135%209.25h6.115a.75.75%200%200%201%200%201.5H5.135a1.5%201.5%200%200%200-1.442%201.086l-1.414%204.926a.75.75%200%200%200%20.826.95%2028.897%2028.897%200%200%200%2015.293-7.155.75.75%200%200%200%200-1.114A28.897%2028.897%200%200%200%203.105%202.288Z"/></svg>');
|
||||
-webkit-mask: var(--hero-paper-airplane-mini);
|
||||
@ -518,6 +722,18 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-puzzle-piece {
|
||||
--hero-puzzle-piece: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M14.25%206.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003%200-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25%201.875c0%20.369.128.713.349%201.003.215.283.401.604.401.959v0a.64.64%200%200%201-.657.643%2048.39%2048.39%200%200%201-4.163-.3c.186%201.613.293%203.25.315%204.907a.656.656%200%200%201-.658.663v0c-.355%200-.676-.186-.959-.401a1.647%201.647%200%200%200-1.003-.349c-1.036%200-1.875%201.007-1.875%202.25s.84%202.25%201.875%202.25c.369%200%20.713-.128%201.003-.349.283-.215.604-.401.959-.401v0c.31%200%20.555.26.532.57a48.039%2048.039%200%200%201-.642%205.056c1.518.19%203.058.309%204.616.354a.64.64%200%200%200%20.657-.643v0c0-.355-.186-.676-.401-.959a1.647%201.647%200%200%201-.349-1.003c0-1.035%201.008-1.875%202.25-1.875%201.243%200%202.25.84%202.25%201.875%200%20.369-.128.713-.349%201.003-.215.283-.4.604-.4.959v0c0%20.333.277.599.61.58a48.1%2048.1%200%200%200%205.427-.63%2048.05%2048.05%200%200%200%20.582-4.717.532.532%200%200%200-.533-.57v0c-.355%200-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035%200-1.875-1.007-1.875-2.25s.84-2.25%201.875-2.25c.37%200%20.713.128%201.003.349.283.215.604.401.96.401v0a.656.656%200%200%200%20.658-.663%2048.422%2048.422%200%200%200-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578%200%200%201-.61-.58v0Z"/></svg>');
|
||||
-webkit-mask: var(--hero-puzzle-piece);
|
||||
mask: var(--hero-puzzle-piece);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-question-mark-circle-mini {
|
||||
--hero-question-mark-circle-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M18%2010a8%208%200%201%201-16%200%208%208%200%200%201%2016%200ZM8.94%206.94a.75.75%200%201%201-1.061-1.061%203%203%200%201%201%202.871%205.026v.345a.75.75%200%200%201-1.5%200v-.5c0-.72.57-1.172%201.081-1.287A1.5%201.5%200%201%200%208.94%206.94ZM10%2015a1%201%200%201%200%200-2%201%201%200%200%200%200%202Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-question-mark-circle-mini);
|
||||
@ -530,10 +746,34 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-rocket-launch {
|
||||
--hero-rocket-launch: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.59%2014.37a6%206%200%200%201-5.84%207.38v-4.8m5.84-2.58a14.98%2014.98%200%200%200%206.16-12.12A14.98%2014.98%200%200%200%209.631%208.41m5.96%205.96a14.926%2014.926%200%200%201-5.841%202.58m-.119-8.54a6%206%200%200%200-7.381%205.84h4.8m2.581-5.84a14.927%2014.927%200%200%200-2.58%205.84m2.699%202.7c-.103.021-.207.041-.311.06a15.09%2015.09%200%200%201-2.448-2.448%2014.9%2014.9%200%200%201%20.06-.312m-2.24%202.39a4.493%204.493%200%200%200-1.757%204.306%204.493%204.493%200%200%200%204.306-1.758M16.5%209a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-rocket-launch);
|
||||
mask: var(--hero-rocket-launch);
|
||||
.hero-rocket-launch-mini {
|
||||
--hero-rocket-launch-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M4.606%2012.97a.75.75%200%200%201-.134%201.051%202.494%202.494%200%200%200-.93%202.437%202.494%202.494%200%200%200%202.437-.93.75.75%200%201%201%201.186.918%203.995%203.995%200%200%201-4.482%201.332.75.75%200%200%201-.461-.461%203.994%203.994%200%200%201%201.332-4.482.75.75%200%200%201%201.052.134Z"%20clip-rule="evenodd"/>%20%20<path%20fill-rule="evenodd"%20d="M5.752%2012A13.07%2013.07%200%200%200%208%2014.248v4.002c0%20.414.336.75.75.75a5%205%200%200%200%204.797-6.414%2012.984%2012.984%200%200%200%205.45-10.848.75.75%200%200%200-.735-.735%2012.984%2012.984%200%200%200-10.849%205.45A5%205%200%200%200%201%2011.25c.001.414.337.75.751.75h4.002ZM13%209a2%202%200%201%200%200-4%202%202%200%200%200%200%204Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-rocket-launch-mini);
|
||||
mask: var(--hero-rocket-launch-mini);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-share {
|
||||
--hero-share: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M7.217%2010.907a2.25%202.25%200%201%200%200%202.186m0-2.186c.18.324.283.696.283%201.093s-.103.77-.283%201.093m0-2.186%209.566-5.314m-9.566%207.5%209.566%205.314m0%200a2.25%202.25%200%201%200%203.935%202.186%202.25%202.25%200%200%200-3.935-2.186Zm0-12.814a2.25%202.25%200%201%200%203.933-2.185%202.25%202.25%200%200%200-3.933%202.185Z"/></svg>');
|
||||
-webkit-mask: var(--hero-share);
|
||||
mask: var(--hero-share);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-shield-check {
|
||||
--hero-shield-check: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012.75%2011.25%2015%2015%209.75m-3-7.036A11.959%2011.959%200%200%201%203.598%206%2011.99%2011.99%200%200%200%203%209.749c0%205.592%203.824%2010.29%209%2011.623%205.176-1.332%209-6.03%209-11.622%200-1.31-.21-2.571-.598-3.751h-.152c-3.196%200-6.1-1.248-8.25-3.285Z"/></svg>');
|
||||
-webkit-mask: var(--hero-shield-check);
|
||||
mask: var(--hero-shield-check);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
@ -554,6 +794,18 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-shopping-cart {
|
||||
--hero-shopping-cart: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M2.25%203h1.386c.51%200%20.955.343%201.087.835l.383%201.437M7.5%2014.25a3%203%200%200%200-3%203h15.75m-12.75-3h11.218c1.121-2.3%202.1-4.684%202.924-7.138a60.114%2060.114%200%200%200-16.536-1.84M7.5%2014.25%205.106%205.272M6%2020.25a.75.75%200%201%201-1.5%200%20.75.75%200%200%201%201.5%200Zm12.75%200a.75.75%200%201%201-1.5%200%20.75.75%200%200%201%201.5%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-shopping-cart);
|
||||
mask: var(--hero-shopping-cart);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-signal {
|
||||
--hero-signal: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9.348%2014.652a3.75%203.75%200%200%201%200-5.304m5.304%200a3.75%203.75%200%200%201%200%205.304m-7.425%202.121a6.75%206.75%200%200%201%200-9.546m9.546%200a6.75%206.75%200%200%201%200%209.546M5.106%2018.894c-3.808-3.807-3.808-9.98%200-13.788m13.788%200c3.808%203.807%203.808%209.98%200%2013.788M12%2012h.008v.008H12V12Zm.375%200a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-signal);
|
||||
@ -566,6 +818,42 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-squares-2x2 {
|
||||
--hero-squares-2x2: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M3.75%206A2.25%202.25%200%200%201%206%203.75h2.25A2.25%202.25%200%200%201%2010.5%206v2.25a2.25%202.25%200%200%201-2.25%202.25H6a2.25%202.25%200%200%201-2.25-2.25V6ZM3.75%2015.75A2.25%202.25%200%200%201%206%2013.5h2.25a2.25%202.25%200%200%201%202.25%202.25V18a2.25%202.25%200%200%201-2.25%202.25H6A2.25%202.25%200%200%201%203.75%2018v-2.25ZM13.5%206a2.25%202.25%200%200%201%202.25-2.25H18A2.25%202.25%200%200%201%2020.25%206v2.25A2.25%202.25%200%200%201%2018%2010.5h-2.25a2.25%202.25%200%200%201-2.25-2.25V6ZM13.5%2015.75a2.25%202.25%200%200%201%202.25-2.25H18a2.25%202.25%200%200%201%202.25%202.25V18A2.25%202.25%200%200%201%2018%2020.25h-2.25A2.25%202.25%200%200%201%2013.5%2018v-2.25Z"/></svg>');
|
||||
-webkit-mask: var(--hero-squares-2x2);
|
||||
mask: var(--hero-squares-2x2);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-squares-plus {
|
||||
--hero-squares-plus: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M13.5%2016.875h3.375m0%200h3.375m-3.375%200V13.5m0%203.375v3.375M6%2010.5h2.25a2.25%202.25%200%200%200%202.25-2.25V6a2.25%202.25%200%200%200-2.25-2.25H6A2.25%202.25%200%200%200%203.75%206v2.25A2.25%202.25%200%200%200%206%2010.5Zm0%209.75h2.25A2.25%202.25%200%200%200%2010.5%2018v-2.25a2.25%202.25%200%200%200-2.25-2.25H6a2.25%202.25%200%200%200-2.25%202.25V18A2.25%202.25%200%200%200%206%2020.25Zm9.75-9.75H18a2.25%202.25%200%200%200%202.25-2.25V6A2.25%202.25%200%200%200%2018%203.75h-2.25A2.25%202.25%200%200%200%2013.5%206v2.25a2.25%202.25%200%200%200%202.25%202.25Z"/></svg>');
|
||||
-webkit-mask: var(--hero-squares-plus);
|
||||
mask: var(--hero-squares-plus);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-star {
|
||||
--hero-star: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M11.48%203.499a.562.562%200%200%201%201.04%200l2.125%205.111a.563.563%200%200%200%20.475.345l5.518.442c.499.04.701.663.321.988l-4.204%203.602a.563.563%200%200%200-.182.557l1.285%205.385a.562.562%200%200%201-.84.61l-4.725-2.885a.562.562%200%200%200-.586%200L6.982%2020.54a.562.562%200%200%201-.84-.61l1.285-5.386a.562.562%200%200%200-.182-.557l-4.204-3.602a.562.562%200%200%201%20.321-.988l5.518-.442a.563.563%200%200%200%20.475-.345L11.48%203.5Z"/></svg>');
|
||||
-webkit-mask: var(--hero-star);
|
||||
mask: var(--hero-star);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-sun-micro {
|
||||
--hero-sun-micro: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2016%2016"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20d="M8%201a.75.75%200%200%201%20.75.75v1.5a.75.75%200%200%201-1.5%200v-1.5A.75.75%200%200%201%208%201ZM10.5%208a2.5%202.5%200%201%201-5%200%202.5%202.5%200%200%201%205%200ZM12.95%204.11a.75.75%200%201%200-1.06-1.06l-1.062%201.06a.75.75%200%200%200%201.061%201.062l1.06-1.061ZM15%208a.75.75%200%200%201-.75.75h-1.5a.75.75%200%200%201%200-1.5h1.5A.75.75%200%200%201%2015%208ZM11.89%2012.95a.75.75%200%200%200%201.06-1.06l-1.06-1.062a.75.75%200%200%200-1.062%201.061l1.061%201.06ZM8%2012a.75.75%200%200%201%20.75.75v1.5a.75.75%200%200%201-1.5%200v-1.5A.75.75%200%200%201%208%2012ZM5.172%2011.89a.75.75%200%200%200-1.061-1.062L3.05%2011.89a.75.75%200%201%200%201.06%201.06l1.06-1.06ZM4%208a.75.75%200%200%201-.75.75h-1.5a.75.75%200%200%201%200-1.5h1.5A.75.75%200%200%201%204%208ZM4.11%205.172A.75.75%200%200%200%205.173%204.11L4.11%203.05a.75.75%200%201%200-1.06%201.06l1.06%201.06Z"/></svg>');
|
||||
-webkit-mask: var(--hero-sun-micro);
|
||||
@ -578,6 +866,42 @@
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
--hero-tag: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9.568%203H5.25A2.25%202.25%200%200%200%203%205.25v4.318c0%20.597.237%201.17.659%201.591l9.581%209.581c.699.699%201.78.872%202.607.33a18.095%2018.095%200%200%200%205.223-5.223c.542-.827.369-1.908-.33-2.607L11.16%203.66A2.25%202.25%200%200%200%209.568%203Z"/>%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M6%206h.008v.008H6V6Z"/></svg>');
|
||||
-webkit-mask: var(--hero-tag);
|
||||
mask: var(--hero-tag);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-trash-mini {
|
||||
--hero-trash-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M8.75%201A2.75%202.75%200%200%200%206%203.75v.443c-.795.077-1.584.176-2.365.298a.75.75%200%201%200%20.23%201.482l.149-.022.841%2010.518A2.75%202.75%200%200%200%207.596%2019h4.807a2.75%202.75%200%200%200%202.742-2.53l.841-10.52.149.023a.75.75%200%200%200%20.23-1.482A41.03%2041.03%200%200%200%2014%204.193V3.75A2.75%202.75%200%200%200%2011.25%201h-2.5ZM10%204c.84%200%201.673.025%202.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69%200-1.25.56-1.25%201.25v.325C8.327%204.025%209.16%204%2010%204ZM8.58%207.72a.75.75%200%200%200-1.5.06l.3%207.5a.75.75%200%201%200%201.5-.06l-.3-7.5Zm4.34.06a.75.75%200%201%200-1.5-.06l-.3%207.5a.75.75%200%201%200%201.5.06l.3-7.5Z"%20clip-rule="evenodd"/></svg>');
|
||||
-webkit-mask: var(--hero-trash-mini);
|
||||
mask: var(--hero-trash-mini);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-truck {
|
||||
--hero-truck: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M8.25%2018.75a1.5%201.5%200%200%201-3%200m3%200a1.5%201.5%200%200%200-3%200m3%200h6m-9%200H3.375a1.125%201.125%200%200%201-1.125-1.125V14.25m17.25%204.5a1.5%201.5%200%200%201-3%200m3%200a1.5%201.5%200%200%200-3%200m3%200h1.125c.621%200%201.129-.504%201.09-1.124a17.902%2017.902%200%200%200-3.213-9.193%202.056%202.056%200%200%200-1.58-.86H14.25M16.5%2018.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554%2048.554%200%200%200-10.026%200%201.106%201.106%200%200%200-.987%201.106v7.635m12-6.677v6.677m0%204.5v-4.5m0%200h-12"/></svg>');
|
||||
-webkit-mask: var(--hero-truck);
|
||||
mask: var(--hero-truck);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-truck-mini {
|
||||
--hero-truck-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20d="M6.5%203c-1.051%200-2.093.04-3.125.117A1.49%201.49%200%200%200%202%204.607V10.5h9V4.606c0-.771-.59-1.43-1.375-1.489A41.568%2041.568%200%200%200%206.5%203ZM2%2012v2.5A1.5%201.5%200%200%200%203.5%2016h.041a3%203%200%200%201%205.918%200h.791a.75.75%200%200%200%20.75-.75V12H2Z"/>%20%20<path%20d="M6.5%2018a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM13.25%205a.75.75%200%200%200-.75.75v8.514a3.001%203.001%200%200%201%204.893%201.44c.37-.275.61-.719.595-1.227a24.905%2024.905%200%200%200-1.784-8.549A1.486%201.486%200%200%200%2014.823%205H13.25ZM14.5%2018a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z"/></svg>');
|
||||
-webkit-mask: var(--hero-truck-mini);
|
||||
@ -590,6 +914,30 @@
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-user {
|
||||
--hero-user: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.75%206a3.75%203.75%200%201%201-7.5%200%203.75%203.75%200%200%201%207.5%200ZM4.501%2020.118a7.5%207.5%200%200%201%2014.998%200A17.933%2017.933%200%200%201%2012%2021.75c-2.676%200-5.216-.584-7.499-1.632Z"/></svg>');
|
||||
-webkit-mask: var(--hero-user);
|
||||
mask: var(--hero-user);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-users {
|
||||
--hero-users: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15%2019.128a9.38%209.38%200%200%200%202.625.372%209.337%209.337%200%200%200%204.121-.952%204.125%204.125%200%200%200-7.533-2.493M15%2019.128v-.003c0-1.113-.285-2.16-.786-3.07M15%2019.128v.106A12.318%2012.318%200%200%201%208.624%2021c-2.331%200-4.512-.645-6.374-1.766l-.001-.109a6.375%206.375%200%200%201%2011.964-3.07M12%206.375a3.375%203.375%200%201%201-6.75%200%203.375%203.375%200%200%201%206.75%200Zm8.25%202.25a2.625%202.625%200%201%201-5.25%200%202.625%202.625%200%200%201%205.25%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-users);
|
||||
mask: var(--hero-users);
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-x-circle {
|
||||
--hero-x-circle: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m9.75%209.75%204.5%204.5m0-4.5-4.5%204.5M21%2012a9%209%200%201%201-18%200%209%209%200%200%201%2018%200Z"/></svg>');
|
||||
-webkit-mask: var(--hero-x-circle);
|
||||
|
||||
@ -634,10 +634,27 @@ const ChartTooltip = {
|
||||
}
|
||||
}
|
||||
|
||||
// Warns before navigating away from pages with unsaved changes
|
||||
const DirtyGuard = {
|
||||
mounted() {
|
||||
this._beforeUnload = (e) => {
|
||||
if (this.el.dataset.dirty === "true") {
|
||||
e.preventDefault()
|
||||
e.returnValue = ""
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", this._beforeUnload)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
window.removeEventListener("beforeunload", this._beforeUnload)
|
||||
}
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
||||
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip},
|
||||
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
||||
@ -94,6 +94,14 @@
|
||||
<.icon name="hero-link" class="size-5" /> Providers
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/pages"}
|
||||
class={admin_nav_active?(@current_path, "/admin/pages")}
|
||||
>
|
||||
<.icon name="hero-document" class="size-5" /> Pages
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/admin/theme"}
|
||||
|
||||
378
lib/berrypod_web/live/admin/pages/editor.ex
Normal file
378
lib/berrypod_web/live/admin/pages/editor.ex
Normal file
@ -0,0 +1,378 @@
|
||||
defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.{BlockTypes, Defaults}
|
||||
|
||||
@impl true
|
||||
def mount(%{"slug" => slug}, _session, socket) do
|
||||
page = Pages.get_page(slug)
|
||||
allowed_blocks = BlockTypes.allowed_for(slug)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, page.title)
|
||||
|> assign(:slug, slug)
|
||||
|> assign(:page_data, page)
|
||||
|> assign(:blocks, page.blocks)
|
||||
|> assign(:allowed_blocks, allowed_blocks)
|
||||
|> assign(:dirty, false)
|
||||
|> assign(:show_picker, false)
|
||||
|> assign(:picker_filter, "")
|
||||
|> assign(:live_region_message, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("move_up", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx && idx > 0 do
|
||||
block = Enum.at(blocks, idx)
|
||||
block_name = block_display_name(block)
|
||||
new_pos = idx
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx - 1, block)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("move_down", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx && idx < length(blocks) - 1 do
|
||||
block = Enum.at(blocks, idx)
|
||||
block_name = block_display_name(block)
|
||||
new_pos = idx + 2
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx + 1, block)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_block", %{"id" => block_id}, socket) do
|
||||
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
block_name = block_display_name(block)
|
||||
new_blocks = Enum.reject(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} removed")}
|
||||
end
|
||||
|
||||
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx do
|
||||
original = Enum.at(blocks, idx)
|
||||
|
||||
copy = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => original["type"],
|
||||
"settings" => original["settings"] || %{}
|
||||
}
|
||||
|
||||
block_name = block_display_name(original)
|
||||
new_blocks = List.insert_at(blocks, idx + 1, copy)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} duplicated")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("show_picker", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_picker, true)
|
||||
|> assign(:picker_filter, "")}
|
||||
end
|
||||
|
||||
def handle_event("hide_picker", _params, socket) do
|
||||
{:noreply, assign(socket, :show_picker, false)}
|
||||
end
|
||||
|
||||
def handle_event("filter_picker", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, :picker_filter, value)}
|
||||
end
|
||||
|
||||
def handle_event("add_block", %{"type" => type}, socket) do
|
||||
block_def = BlockTypes.get(type)
|
||||
|
||||
if block_def do
|
||||
# Build default settings from schema
|
||||
default_settings =
|
||||
block_def
|
||||
|> Map.get(:settings_schema, [])
|
||||
|> Enum.into(%{}, fn field -> {field.key, field.default} end)
|
||||
|
||||
new_block = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => type,
|
||||
"settings" => default_settings
|
||||
}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, socket.assigns.blocks ++ [new_block])
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:show_picker, false)
|
||||
|> assign(:live_region_message, "#{block_def.name} added")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("save", _params, socket) do
|
||||
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns
|
||||
|
||||
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
|
||||
{:ok, _page} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:dirty, false)
|
||||
|> put_flash(:info, "Page saved")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to save page")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reset_defaults", _params, socket) do
|
||||
slug = socket.assigns.slug
|
||||
:ok = Pages.reset_page(slug)
|
||||
page = Pages.get_page(slug)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, page.blocks)
|
||||
|> assign(:dirty, false)
|
||||
|> put_flash(:info, "Page reset to defaults")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="page-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
|
||||
<.link
|
||||
navigate={~p"/admin/pages"}
|
||||
class="text-sm font-normal text-base-content/60 hover:underline"
|
||||
>
|
||||
← Pages
|
||||
</.link>
|
||||
<.header>
|
||||
{@page_data.title}
|
||||
<:actions>
|
||||
<button
|
||||
phx-click="reset_defaults"
|
||||
data-confirm="Reset this page to its default layout? Your changes will be lost."
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
<button
|
||||
phx-click="save"
|
||||
class={["admin-btn admin-btn-sm admin-btn-primary", !@dirty && "opacity-50"]}
|
||||
disabled={!@dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- ARIA live region for screen reader announcements --%>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{if @live_region_message, do: @live_region_message}
|
||||
</div>
|
||||
|
||||
<%!-- Unsaved changes indicator --%>
|
||||
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
|
||||
Unsaved changes
|
||||
</p>
|
||||
|
||||
<%!-- Block list --%>
|
||||
<div class="block-list" role="list" aria-label="Page blocks">
|
||||
<.block_card
|
||||
:for={{block, idx} <- Enum.with_index(@blocks)}
|
||||
block={block}
|
||||
idx={idx}
|
||||
total={length(@blocks)}
|
||||
/>
|
||||
|
||||
<div :if={@blocks == []} class="block-list-empty">
|
||||
<p>No blocks on this page yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Add block button --%>
|
||||
<div class="block-actions">
|
||||
<button phx-click="show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
||||
<.icon name="hero-plus" class="size-4" /> Add block
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Block picker modal --%>
|
||||
<.block_picker
|
||||
:if={@show_picker}
|
||||
allowed_blocks={@allowed_blocks}
|
||||
filter={@picker_filter}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_card(assigns) do
|
||||
block_type = BlockTypes.get(assigns.block["type"])
|
||||
assigns = assign(assigns, :block_type, block_type)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
class="block-card"
|
||||
role="listitem"
|
||||
aria-label={"#{@block_type && @block_type.name || @block["type"]}, position #{@idx + 1} of #{@total}"}
|
||||
id={"block-#{@block["id"]}"}
|
||||
>
|
||||
<span class="block-card-position">{@idx + 1}</span>
|
||||
|
||||
<span class="block-card-icon">
|
||||
<.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" />
|
||||
</span>
|
||||
|
||||
<span class="block-card-name">
|
||||
{(@block_type && @block_type.name) || @block["type"]}
|
||||
</span>
|
||||
|
||||
<span class="block-card-controls">
|
||||
<button
|
||||
phx-click="move_up"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} up"}
|
||||
disabled={@idx == 0}
|
||||
>
|
||||
<.icon name="hero-chevron-up-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_down"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} down"}
|
||||
disabled={@idx == @total - 1}
|
||||
>
|
||||
<.icon name="hero-chevron-down-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="duplicate_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Duplicate #{@block_type && @block_type.name}"}
|
||||
>
|
||||
<.icon name="hero-document-duplicate-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="remove_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm block-remove-btn"
|
||||
aria-label={"Remove #{@block_type && @block_type.name}"}
|
||||
data-confirm={"Remove #{@block_type && @block_type.name}?"}
|
||||
>
|
||||
<.icon name="hero-trash-mini" class="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_picker(assigns) do
|
||||
filter = String.downcase(assigns.filter)
|
||||
|
||||
filtered =
|
||||
assigns.allowed_blocks
|
||||
|> Enum.filter(fn {_type, def} ->
|
||||
filter == "" or String.contains?(String.downcase(def.name), filter)
|
||||
end)
|
||||
|> Enum.sort_by(fn {_type, def} -> def.name end)
|
||||
|
||||
assigns = assign(assigns, :filtered_blocks, filtered)
|
||||
|
||||
~H"""
|
||||
<div class="block-picker-overlay" phx-click="hide_picker">
|
||||
<div class="block-picker" phx-click-away="hide_picker">
|
||||
<div class="block-picker-header">
|
||||
<h3>Add a block</h3>
|
||||
<button
|
||||
phx-click="hide_picker"
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter blocks..."
|
||||
value={@filter}
|
||||
phx-keyup="filter_picker"
|
||||
phx-key=""
|
||||
class="admin-input block-picker-search"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="block-picker-grid">
|
||||
<button
|
||||
:for={{type, def} <- @filtered_blocks}
|
||||
phx-click="add_block"
|
||||
phx-value-type={type}
|
||||
class="block-picker-item"
|
||||
>
|
||||
<.icon name={def.icon} class="size-5" />
|
||||
<span>{def.name}</span>
|
||||
</button>
|
||||
|
||||
<p :if={@filtered_blocks == []} class="block-picker-empty">
|
||||
No matching blocks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_display_name(nil), do: "Block"
|
||||
|
||||
defp block_display_name(block) do
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{name: name} -> name
|
||||
_ -> block["type"]
|
||||
end
|
||||
end
|
||||
end
|
||||
75
lib/berrypod_web/live/admin/pages/index.ex
Normal file
75
lib/berrypod_web/live/admin/pages/index.ex
Normal file
@ -0,0 +1,75 @@
|
||||
defmodule BerrypodWeb.Admin.Pages.Index do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Pages
|
||||
|
||||
@page_groups [
|
||||
{"Marketing", ~w(home about contact)},
|
||||
{"Legal", ~w(delivery privacy terms)},
|
||||
{"Shop", ~w(collection pdp cart search)},
|
||||
{"Orders", ~w(checkout_success orders order_detail)},
|
||||
{"System", ~w(error)}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
pages = Pages.list_pages() |> Map.new(&{&1.slug, &1})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Pages")
|
||||
|> assign(:pages, pages)
|
||||
|> assign(:page_groups, @page_groups)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Pages
|
||||
<:subtitle>Customise the layout and content of every page on your shop.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="page-list">
|
||||
<div :for={{group_name, slugs} <- @page_groups} class="page-group">
|
||||
<h3 class="page-group-title">{group_name}</h3>
|
||||
<div class="page-group-cards">
|
||||
<.link
|
||||
:for={slug <- slugs}
|
||||
navigate={~p"/admin/pages/#{slug}"}
|
||||
class="page-card"
|
||||
>
|
||||
<span class="page-card-icon">
|
||||
<.icon name={page_icon(slug)} class="size-5" />
|
||||
</span>
|
||||
<span class="page-card-info">
|
||||
<span class="page-card-title">{@pages[slug].title}</span>
|
||||
<span class="page-card-meta">
|
||||
{length(@pages[slug].blocks)} {if length(@pages[slug].blocks) == 1,
|
||||
do: "block",
|
||||
else: "blocks"}
|
||||
</span>
|
||||
</span>
|
||||
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp page_icon("home"), do: "hero-home"
|
||||
defp page_icon("about"), do: "hero-user"
|
||||
defp page_icon("contact"), do: "hero-envelope"
|
||||
defp page_icon("delivery"), do: "hero-truck"
|
||||
defp page_icon("privacy"), do: "hero-shield-check"
|
||||
defp page_icon("terms"), do: "hero-document-text"
|
||||
defp page_icon("collection"), do: "hero-tag"
|
||||
defp page_icon("pdp"), do: "hero-cube"
|
||||
defp page_icon("cart"), do: "hero-shopping-cart"
|
||||
defp page_icon("search"), do: "hero-magnifying-glass"
|
||||
defp page_icon("checkout_success"), do: "hero-check-circle"
|
||||
defp page_icon("orders"), do: "hero-clipboard-document-list"
|
||||
defp page_icon("order_detail"), do: "hero-clipboard-document"
|
||||
defp page_icon("error"), do: "hero-exclamation-triangle"
|
||||
end
|
||||
@ -35,7 +35,7 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
error_page={@page.slug == "error"}
|
||||
>
|
||||
<main id="main-content" class={page_main_class(@page.slug)}>
|
||||
<div :for={block <- @page.blocks} data-block-type={block["type"]}>
|
||||
<div :for={block <- @page.blocks} :key={block["id"]} data-block-type={block["type"]}>
|
||||
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -229,6 +229,8 @@ defmodule BerrypodWeb.Router do
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
live "/pages", Admin.Pages.Index, :index
|
||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||
live "/redirects", Admin.Redirects, :index
|
||||
end
|
||||
|
||||
|
||||
@ -43,7 +43,11 @@ defmodule Mix.Tasks.GenerateAdminIcons do
|
||||
content = """
|
||||
/* Generated by mix generate_admin_icons — do not edit by hand */
|
||||
|
||||
@layer admin {
|
||||
|
||||
#{Enum.join(css_rules, "\n\n")}
|
||||
|
||||
} /* @layer admin */
|
||||
"""
|
||||
|
||||
File.mkdir_p!(Path.dirname(@output_path))
|
||||
|
||||
1
mix.exs
1
mix.exs
@ -62,6 +62,7 @@ defmodule Berrypod.MixProject do
|
||||
compile: false,
|
||||
depth: 1},
|
||||
{:swoosh, "~> 1.16"},
|
||||
{:gen_smtp, "~> 1.0"},
|
||||
{:req, "~> 0.5"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
|
||||
2
mix.lock
2
mix.lock
@ -30,6 +30,7 @@
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
@ -61,6 +62,7 @@
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"stripity_stripe": {:hex, :stripity_stripe, "3.2.0", "07c27f5f2ac87006945b5c997b99d1210e009e380ea78d339d025b11c9c745f5", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uri_query, "~> 0.2.0", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "f797936a9e9538370bae7dc73d73eafd7e44ecdc95b71c88492c43f6df094cb0"},
|
||||
|
||||
300
test/berrypod_web/live/admin/pages_test.exs
Normal file
300
test/berrypod_web/live/admin/pages_test.exs
Normal file
@ -0,0 +1,300 @@
|
||||
defmodule BerrypodWeb.Admin.PagesTest do
|
||||
use BerrypodWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.PageCache
|
||||
|
||||
setup do
|
||||
PageCache.invalidate_all()
|
||||
user = user_fixture()
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
describe "unauthenticated" do
|
||||
test "redirects to login", %{conn: conn} do
|
||||
{:error, redirect} = live(conn, ~p"/admin/pages")
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "page list" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "renders page list with groups", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/pages")
|
||||
|
||||
assert html =~ "Pages"
|
||||
assert html =~ "Marketing"
|
||||
assert html =~ "Legal"
|
||||
assert html =~ "Shop"
|
||||
assert html =~ "Orders"
|
||||
assert html =~ "System"
|
||||
end
|
||||
|
||||
test "shows all 14 pages", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages")
|
||||
|
||||
assert has_element?(view, ".page-card-title", "Home page")
|
||||
assert has_element?(view, ".page-card-title", "About")
|
||||
assert has_element?(view, ".page-card-title", "Contact")
|
||||
assert has_element?(view, ".page-card-title", "Product page")
|
||||
assert has_element?(view, ".page-card-title", "Error")
|
||||
end
|
||||
|
||||
test "shows block count per page", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages")
|
||||
|
||||
# Home has 4 default blocks
|
||||
assert has_element?(view, ".page-card-meta", "4 blocks")
|
||||
end
|
||||
|
||||
test "links to editor", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages")
|
||||
|
||||
view
|
||||
|> element("a[href='/admin/pages/home']")
|
||||
|> render_click()
|
||||
|
||||
assert_redirect(view, ~p"/admin/pages/home")
|
||||
end
|
||||
end
|
||||
|
||||
describe "page editor" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "renders editor with blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
assert has_element?(view, ".block-card-name", "Hero banner")
|
||||
assert has_element?(view, ".block-card-name", "Category navigation")
|
||||
assert has_element?(view, ".block-card-name", "Featured products")
|
||||
assert has_element?(view, ".block-card-name", "Image + text")
|
||||
end
|
||||
|
||||
test "shows position numbers", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
assert has_element?(view, ".block-card-position", "1")
|
||||
assert has_element?(view, ".block-card-position", "4")
|
||||
end
|
||||
|
||||
test "shows back link", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
assert has_element?(view, "a[href='/admin/pages']", "Pages")
|
||||
end
|
||||
|
||||
test "move block up reorders blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
# Get the second block (category_nav) and move it up
|
||||
page = Pages.get_page("home")
|
||||
second_block = Enum.at(page.blocks, 1)
|
||||
|
||||
render_click(view, "move_up", %{"id" => second_block["id"]})
|
||||
|
||||
# The ARIA live region announces the move
|
||||
assert has_element?(view, "[aria-live='polite']", "Category navigation moved to position 1")
|
||||
end
|
||||
|
||||
test "move block down reorders blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
first_block = Enum.at(page.blocks, 0)
|
||||
|
||||
render_click(view, "move_down", %{"id" => first_block["id"]})
|
||||
|
||||
# Hero banner should now be at position 2
|
||||
assert has_element?(view, "[aria-live='polite']", "Hero banner moved to position 2")
|
||||
end
|
||||
|
||||
test "move up disabled for first block", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
first_block = Enum.at(page.blocks, 0)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"button[phx-value-id='#{first_block["id"]}'][phx-click='move_up'][disabled]"
|
||||
)
|
||||
end
|
||||
|
||||
test "move down disabled for last block", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
last_block = Enum.at(page.blocks, -1)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"button[phx-value-id='#{last_block["id"]}'][phx-click='move_down'][disabled]"
|
||||
)
|
||||
end
|
||||
|
||||
test "remove block", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
block = Enum.at(page.blocks, 1)
|
||||
|
||||
render_click(view, "remove_block", %{"id" => block["id"]})
|
||||
|
||||
refute has_element?(view, ".block-card-name", "Category navigation")
|
||||
assert has_element?(view, ".block-card-position", "3")
|
||||
refute has_element?(view, ".block-card-position", "4")
|
||||
end
|
||||
|
||||
test "duplicate block", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
block = Enum.at(page.blocks, 0)
|
||||
|
||||
render_click(view, "duplicate_block", %{"id" => block["id"]})
|
||||
|
||||
# Should now have 5 blocks (position 5 exists)
|
||||
assert has_element?(view, ".block-card-position", "5")
|
||||
assert has_element?(view, "[aria-live='polite']", "Hero banner duplicated")
|
||||
end
|
||||
|
||||
test "add block via picker", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
render_click(view, "show_picker")
|
||||
assert has_element?(view, ".block-picker")
|
||||
|
||||
render_click(view, "add_block", %{"type" => "trust_badges"})
|
||||
|
||||
assert has_element?(view, ".block-card-name", "Trust badges")
|
||||
refute has_element?(view, ".block-picker")
|
||||
end
|
||||
|
||||
test "picker filter narrows results", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
render_click(view, "show_picker")
|
||||
render_keyup(view, "filter_picker", %{"value" => "hero"})
|
||||
|
||||
assert has_element?(view, ".block-picker-item", "Hero banner")
|
||||
refute has_element?(view, ".block-picker-item", "Trust badges")
|
||||
end
|
||||
|
||||
test "picker only shows blocks allowed on page", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
render_click(view, "show_picker")
|
||||
|
||||
assert has_element?(view, ".block-picker-item", "Hero banner")
|
||||
refute has_element?(view, ".block-picker-item", "Product hero")
|
||||
refute has_element?(view, ".block-picker-item", "Cart items")
|
||||
end
|
||||
|
||||
test "save persists blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
block = Enum.at(page.blocks, 1)
|
||||
render_click(view, "remove_block", %{"id" => block["id"]})
|
||||
|
||||
render_click(view, "save")
|
||||
|
||||
assert render(view) =~ "Page saved"
|
||||
|
||||
saved = Pages.get_page("home")
|
||||
assert length(saved.blocks) == 3
|
||||
types = Enum.map(saved.blocks, & &1["type"])
|
||||
refute "category_nav" in types
|
||||
end
|
||||
|
||||
test "reset to defaults restores original blocks", %{conn: conn} do
|
||||
{:ok, _} =
|
||||
Pages.save_page("home", %{
|
||||
title: "Home page",
|
||||
blocks: [%{"id" => "blk_test", "type" => "hero", "settings" => %{}}]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
# Should show only 1 block
|
||||
assert has_element?(view, ".block-card-position", "1")
|
||||
refute has_element?(view, ".block-card-position", "2")
|
||||
|
||||
render_click(view, "reset_defaults")
|
||||
|
||||
assert render(view) =~ "Page reset to defaults"
|
||||
# Should now have 4 default blocks
|
||||
assert has_element?(view, ".block-card-position", "4")
|
||||
end
|
||||
|
||||
test "dirty flag appears after changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
block = Enum.at(page.blocks, 0)
|
||||
render_click(view, "move_down", %{"id" => block["id"]})
|
||||
|
||||
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
||||
end
|
||||
|
||||
test "dirty flag clears after save", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
block = Enum.at(page.blocks, 0)
|
||||
render_click(view, "move_down", %{"id" => block["id"]})
|
||||
assert has_element?(view, ".admin-badge-warning")
|
||||
|
||||
render_click(view, "save")
|
||||
refute has_element?(view, ".admin-badge-warning")
|
||||
end
|
||||
|
||||
test "save button disabled when not dirty", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
|
||||
|
||||
assert has_element?(view, "button[disabled]", "Save")
|
||||
end
|
||||
end
|
||||
|
||||
describe "page editor for page-specific pages" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "PDP editor shows PDP blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/pdp")
|
||||
|
||||
assert has_element?(view, ".block-card-name", "Breadcrumb")
|
||||
assert has_element?(view, ".block-card-name", "Product hero")
|
||||
assert has_element?(view, ".block-card-name", "Trust badges")
|
||||
end
|
||||
|
||||
test "PDP picker shows PDP-specific blocks", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/pdp")
|
||||
|
||||
render_click(view, "show_picker")
|
||||
|
||||
assert has_element?(view, ".block-picker-item", "Product hero")
|
||||
assert has_element?(view, ".block-picker-item", "Hero banner")
|
||||
refute has_element?(view, ".block-picker-item", "Cart items")
|
||||
end
|
||||
|
||||
test "error page editor works", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/pages/error")
|
||||
|
||||
assert has_element?(view, ".block-card-name", "Hero banner")
|
||||
assert has_element?(view, ".block-card-name", "Featured products")
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user