Jake Vanderwerf
7 days ago 46d681c6b825d21b3f698d793c4e630c687d90ad
=Major CustomBlocks.php overhaul, expanding block support and customization from the editor. theme.json should now be updated on new themes to set brand colours, etc. Also note: major change to .col vs .row alignment: simplifying it to .top .bottom vs the confusion of the differences for .col/.row .start and .a-start
40 files modified
96 files added
15696 ■■■■■ changed files
assets/css/nav.min.css 2 ●●● patch | view | raw | blame | history
assets/js/concise/UtilityFunctions.js 63 ●●●●● patch | view | raw | blame | history
assets/js/concise/navigation.js 2 ●●● patch | view | raw | blame | history
assets/js/min/navigation.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/utility.min.js 2 ●●● patch | view | raw | blame | history
build/feed/view.asset.php 2 ●●● patch | view | raw | blame | history
build/feed/view.js 2 ●●● patch | view | raw | blame | history
build/fields/render.php 4 ●●●● patch | view | raw | blame | history
build/gmbreviews/render.php 4 ●●●● patch | view | raw | blame | history
build/summary/render.php 4 ●●●● patch | view | raw | blame | history
inc/admin/ContentTaxonomy.php 2 ●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 2639 ●●●● patch | view | raw | blame | history
inc/blocks/FeedBlock.php 12 ●●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 4 ●●●● patch | view | raw | blame | history
inc/blocks/MenuBlock.php 4 ●●●● patch | view | raw | blame | history
inc/blocks/SummaryBlock.php 2 ●●● patch | view | raw | blame | history
inc/forms/TaxonomySelector.php 10 ●●●● patch | view | raw | blame | history
inc/helpers/crud.php 2 ●●● patch | view | raw | blame | history
inc/helpers/media.php 2 ●●● patch | view | raw | blame | history
inc/helpers/renderFields.php 4 ●●●● patch | view | raw | blame | history
inc/helpers/ui.php 26 ●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 4 ●●●● patch | view | raw | blame | history
inc/managers/DashboardManager.php 8 ●●●● patch | view | raw | blame | history
inc/managers/DirectoryManager.php 10 ●●●● patch | view | raw | blame | history
inc/managers/IconsManager.php 4 ●●● patch | view | raw | blame | history
inc/managers/LoginManager.php 8 ●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 16 ●●●● patch | view | raw | blame | history
inc/managers/SEO/BreadcrumbManager.php 15 ●●●● patch | view | raw | blame | history
inc/managers/queue/executors/ContentExecutor.php 4 ●●●● patch | view | raw | blame | history
inc/meta/Form.php 45 ●●●●● patch | view | raw | blame | history
inc/templates.php 6 ●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 28 ●●●● patch | view | raw | blame | history
inc/ui/Checkout.php 4 ●●●● patch | view | raw | blame | history
inc/ui/Tabs.php 2 ●●● patch | view | raw | blame | history
inc/users/UserSettings.php 2 ●●● patch | view | raw | blame | history
inc/utility/Image.php 6 ●●●●● patch | view | raw | blame | history
jvb.php 7 ●●●●● patch | view | raw | blame | history
src/drawer-menu/block.json 27 ●●●●● patch | view | raw | blame | history
src/drawer-menu/edit.js 33 ●●●●● patch | view | raw | blame | history
src/drawer-menu/editor.scss patch | view | raw | blame | history
src/drawer-menu/index.js 10 ●●●●● patch | view | raw | blame | history
src/drawer-menu/index.php patch | view | raw | blame | history
src/drawer-menu/render.php 38 ●●●●● patch | view | raw | blame | history
src/drawer-menu/save.js 3 ●●●●● patch | view | raw | blame | history
src/drawer-menu/style.scss 88 ●●●●● patch | view | raw | blame | history
src/drawer-menu/view.js 1 ●●●● patch | view | raw | blame | history
src/faq/block.json 34 ●●●●● patch | view | raw | blame | history
src/faq/edit.js 145 ●●●●● patch | view | raw | blame | history
src/faq/editor.scss 99 ●●●●● patch | view | raw | blame | history
src/faq/index.js 11 ●●●●● patch | view | raw | blame | history
src/faq/index.php patch | view | raw | blame | history
src/faq/render.php patch | view | raw | blame | history
src/faq/style.scss 72 ●●●●● patch | view | raw | blame | history
src/faq/view.js 84 ●●●●● patch | view | raw | blame | history
src/feed/block.json 57 ●●●●● patch | view | raw | blame | history
src/feed/edit.js 256 ●●●●● patch | view | raw | blame | history
src/feed/editor.scss 128 ●●●●● patch | view | raw | blame | history
src/feed/index.js 39 ●●●●● patch | view | raw | blame | history
src/feed/index.php 2 ●●●●● patch | view | raw | blame | history
src/feed/render.php 4 ●●●● patch | view | raw | blame | history
src/feed/save.js 3 ●●●●● patch | view | raw | blame | history
src/feed/style.scss 956 ●●●●● patch | view | raw | blame | history
src/feed/view.js 747 ●●●●● patch | view | raw | blame | history
src/feed/viewOld.js 770 ●●●●● patch | view | raw | blame | history
src/fields/block.json 25 ●●●●● patch | view | raw | blame | history
src/fields/edit.js 29 ●●●●● patch | view | raw | blame | history
src/fields/editor.scss 20 ●●●●● patch | view | raw | blame | history
src/fields/index.js 39 ●●●●● patch | view | raw | blame | history
src/fields/index.php patch | view | raw | blame | history
src/fields/render.php 320 ●●●●● patch | view | raw | blame | history
src/fields/save.js 3 ●●●●● patch | view | raw | blame | history
src/fields/style.scss 20 ●●●●● patch | view | raw | blame | history
src/fields/view.js 1 ●●●● patch | view | raw | blame | history
src/forms/block.json 47 ●●●●● patch | view | raw | blame | history
src/forms/edit.js 319 ●●●●● patch | view | raw | blame | history
src/forms/editor.scss patch | view | raw | blame | history
src/forms/index.js 40 ●●●●● patch | view | raw | blame | history
src/forms/index.php patch | view | raw | blame | history
src/forms/render.php 55 ●●●●● patch | view | raw | blame | history
src/forms/save.js 23 ●●●●● patch | view | raw | blame | history
src/forms/style.scss 5572 ●●●●● patch | view | raw | blame | history
src/forms/view.js 112 ●●●●● patch | view | raw | blame | history
src/glossary/block.json 24 ●●●●● patch | view | raw | blame | history
src/glossary/edit.js 38 ●●●●● patch | view | raw | blame | history
src/glossary/editor.scss patch | view | raw | blame | history
src/glossary/index.js 33 ●●●●● patch | view | raw | blame | history
src/glossary/index.php patch | view | raw | blame | history
src/glossary/render.php 8 ●●●●● patch | view | raw | blame | history
src/glossary/style.scss 109 ●●●●● patch | view | raw | blame | history
src/glossary/view.js 184 ●●●●● patch | view | raw | blame | history
src/gmbreviews/block.json 68 ●●●●● patch | view | raw | blame | history
src/gmbreviews/edit.js 69 ●●●●● patch | view | raw | blame | history
src/gmbreviews/editor.scss patch | view | raw | blame | history
src/gmbreviews/index.js 11 ●●●●● patch | view | raw | blame | history
src/gmbreviews/index.php patch | view | raw | blame | history
src/gmbreviews/render.php 207 ●●●●● patch | view | raw | blame | history
src/gmbreviews/style.scss 122 ●●●●● patch | view | raw | blame | history
src/gmbreviews/view.js patch | view | raw | blame | history
src/index.php 3 ●●●●● patch | view | raw | blame | history
src/menu/block.json 24 ●●●●● patch | view | raw | blame | history
src/menu/edit.js 38 ●●●●● patch | view | raw | blame | history
src/menu/editor.scss patch | view | raw | blame | history
src/menu/index.js 33 ●●●●● patch | view | raw | blame | history
src/menu/index.php patch | view | raw | blame | history
src/menu/render.php 8 ●●●●● patch | view | raw | blame | history
src/menu/style.scss patch | view | raw | blame | history
src/menu/view.js 43 ●●●●● patch | view | raw | blame | history
src/summary/block.json 32 ●●●●● patch | view | raw | blame | history
src/summary/edit.js 29 ●●●●● patch | view | raw | blame | history
src/summary/editor.scss 20 ●●●●● patch | view | raw | blame | history
src/summary/index.js 39 ●●●●● patch | view | raw | blame | history
src/summary/index.php patch | view | raw | blame | history
src/summary/render.php 320 ●●●●● patch | view | raw | blame | history
src/summary/save.js 3 ●●●●● patch | view | raw | blame | history
src/summary/style.scss 20 ●●●●● patch | view | raw | blame | history
src/summary/view.js 1 ●●●● patch | view | raw | blame | history
src/timeline/block.json 23 ●●●●● patch | view | raw | blame | history
src/timeline/edit.js 38 ●●●●● patch | view | raw | blame | history
src/timeline/editor.scss patch | view | raw | blame | history
src/timeline/index.js 33 ●●●●● patch | view | raw | blame | history
src/timeline/index.php patch | view | raw | blame | history
src/timeline/render.php 8 ●●●●● patch | view | raw | blame | history
src/timeline/style.scss 135 ●●●●● patch | view | raw | blame | history
src/timeline/view.js patch | view | raw | blame | history
src/video/block.json 79 ●●●●● patch | view | raw | blame | history
src/video/edit.js 276 ●●●●● patch | view | raw | blame | history
src/video/editor.scss 141 ●●●●● patch | view | raw | blame | history
src/video/index.js 21 ●●●●● patch | view | raw | blame | history
src/video/index.php patch | view | raw | blame | history
src/video/render.php 1 ●●●● patch | view | raw | blame | history
src/video/save.js 23 ●●●●● patch | view | raw | blame | history
src/video/style.scss 178 ●●●●● patch | view | raw | blame | history
src/video/view.js 47 ●●●●● patch | view | raw | blame | history
templates/dashboard/sections/news.php 6 ●●●● patch | view | raw | blame | history
templates/dashboard/sections/notifications.php 2 ●●● patch | view | raw | blame | history
webpack.jvb.js 4 ●●●● patch | view | raw | blame | history
assets/css/nav.min.css
@@ -1 +1 @@
nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;padding:0;margin:0}nav li{width:100%;--justify:center;max-inline-size:none;padding:0;list-style:none}nav a,nav button{--justify:center;width:100%;white-space:nowrap;text-transform:uppercase;border-radius:0;background-color:transparent;text-decoration:none}nav a{padding:var(--padding)}nav .toggle{aspect-ratio:1;border:1px solid var(--base);color:var(--contrast)}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon-caret-down{transform:rotate(0);transition:transform var(--trans-base)}.open>.row>.toggle .icon-caret-down,.open>.toggle .icon-caret-down{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;right:0;max-height:0;transform:scaleY(0);transform-origin:top;width:100%;min-width:max-content;background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:max-height var(--trans-base),transform var(--trans-base);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.submenu a{height:var(--chipchip)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:1px solid var(--action-0);outline-offset:1px}nav.always{--dir:column;--justify:flex-end;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.always>ul{z-index:1;--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;max-height:100%;position:relative;right:-300vw;width:100vw;padding:var(--btn) 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{--wrap:wrap;--dir:row;height:max-content;--justify:flex-start;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;--justify:center;max-width:calc(100% - var(--btn))}nav.always .has-submenu>a{z-index:var(--z-3)}nav.always .has-submenu>button{width:var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;top:0;border:1px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>.toggle{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>.toggle{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always button .icon-x,nav.always.open button .icon-list{display:none}nav.always button .icon-list,nav.always.open button .icon-x{display:block;--w:32px}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-5)}nav#breadcrumbs ol{height:max-content;--wrap:wrap}nav#breadcrumbs li{width:max-content;height:var(--chip);--wrap:nowrap}nav#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}nav#breadcrumbs li:last-of-type::after{display:none}nav#breadcrumbs a{height:var(--chip)}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;color:var(--contrast);text-transform:none}nav#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed{position:fixed;left:0;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom{bottom:0;width:calc(100% - var(--btn))}nav.fixed ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.always.fixed>ul{padding-top:var(--btn)}nav.fixed li{flex:1}nav.fixed a{--gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:100vw;z-index:var(--z-5);background-color:rgba(var(--base-rgb),var(--op-45));max-width:none}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}nav.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset}nav.on-this-page.open button{order:0}nav.on-this-page ul{width:100%;--gap:0}nav.on-this-page a{padding:0}nav.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}nav.on-this-page #back-to-top span{display:none}nav.on-this-page .active a{background-color:var(--action-0);color:var(--action-contrast)}nav.letters,nav.letters a,nav.letters li,nav.letters ul{height:var(--chip)}nav.letters li{max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}nav.index ul{width:100%}nav.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}nav.index li.active,nav.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}nav.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}nav.index a{border-bottom:4px solid transparent}nav.index .active a{border-color:var(--action-0);color:var(--contrast)}nav.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;--align:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}nav.index.open a{--justify:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed,nav.condensed a,nav.condensed li,nav.condensed ul{height:max-content;width:max-content;--wrap:wrap;min-height:var(--chip)}nav.condensed{--gap:0 .25rem;width:100%}nav.condensed li+li::before{content:'·';padding:0 .25em}nav.condensed a{font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:transparent;color:var(--contrast);border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{padding-bottom:2px;touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button.active{cursor:default}nav.tabs button{font-family:var(--heading);font-size:var(--txt-x-small);border-bottom:4px solid transparent}nav.tabs button.active,nav.tabs button.active:hover{background-color:var(--action-0);color:var(--action-contrast);border-color:var(--base)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}.wp-site-blocks>header,body>header{--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}.dashboard-nav{width:100%}nav.filters{--dir:row;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}nav.term-navigation:has([hidden]){display:none}
nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul,ul.socials{display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;padding:0;margin:0}nav.col,nav.col ul{height:max-content}nav>ul{width:100%;overflow:auto hidden}nav li{width:max-content;--justify:center;max-inline-size:none;padding:0;list-style:none}nav.fill li{width:100%}nav a,nav button{--justify:center;width:100%;white-space:nowrap;text-transform:uppercase;border-radius:0;background-color:transparent;text-decoration:none}nav a{padding:var(--padding)}nav .toggle{aspect-ratio:1;border:1px solid var(--base);color:var(--contrast)}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon-caret-down{transform:rotate(0);transition:transform var(--trans-base)}.open>.row>.toggle .icon-caret-down,.open>.toggle .icon-caret-down{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;right:0;max-height:0;transform:scaleY(0);transform-origin:top;width:100%;min-width:max-content;background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:max-height var(--trans-base),transform var(--trans-base);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.submenu a{height:var(--chipchip)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:1px solid var(--action-0);outline-offset:1px}nav.always{overflow:visible;transition:width var(--trans-base);width:max-content}nav.always>ul{--dir:column;--align:center;--justify:flex-end;--gap:0;height:100vh;max-height:none;position:fixed;right:-300vw;bottom:0;width:100vw;padding:var(--btn) 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{width:100%}nav.always.fixed{width:var(--btn);height:var(--btn);bottom:0;right:0;overflow:hidden}nav.always.fixed .toggle.main{background-color:var(--base)}nav.always.fixed .toggle.main:focus,nav.always.fixed .toggle.main:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.mobile .toggle.main{width:var(--btn);transition:width var(--trans-base)}nav.mobile .icon-list,nav.mobile .icon-x{--w:32px}nav.mobile .icon-x,nav.mobile.open .icon-list{display:none}nav.mobile .icon-list,nav.mobile.open .icon-x{display:block}nav.mobile.open>ul{--dir:column;z-index:var(--z-9);background-color:rgba(var(--base-rgb),var(--op-6));width:100vw;height:100vh;overflow:hidden auto;right:0;bottom:0;position:fixed;padding:var(--btn) 0}nav.always>ul::before,nav.mobile.open>ul::before{content:'';z-index:-1;position:absolute;inset:0;filter:blur(5px)}nav.always.open .main.toggle,nav.mobile.open .main.toggle{position:fixed;bottom:0;left:0;width:100vw;z-index:var(--z-10);aspect-ratio:unset}nav.always>ul,nav.always>ul:before,nav.mobile.open>ul,nav.mobile.open>ul::before{background-color:rgba(var(--base-rgb),var(--op-6))}@media (max-width:767px){nav.col{height:var(--btn)}nav.mobile>ul{--dir:column;--align:center;--justify:flex-end;--gap:0;height:100%;max-height:none;position:relative;right:-300vw;width:100vw;padding:var(--btn) 0 0;overflow:hidden auto}nav.mobile.open>ul{right:0}}@media (min-width:768px){nav.mobile:not(.always) .toggle.main{display:none}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-5)}nav#breadcrumbs ol{height:max-content;--wrap:wrap}nav#breadcrumbs li{width:max-content;height:var(--chip);--wrap:nowrap}nav#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}nav#breadcrumbs li:last-of-type::after{display:none}nav#breadcrumbs a{height:var(--chip)}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;color:var(--contrast);text-transform:none}nav#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed{position:fixed;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom{left:0;bottom:0;width:calc(100% - var(--btn))}nav.fixed:not(.always) ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed:not(.always) li{flex:1}nav.fixed a{--align:center;--gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:100vw;z-index:var(--z-5);background-color:rgba(var(--base-rgb),var(--op-45));max-width:none}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}nav.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset}nav.on-this-page.open button{order:0}nav.on-this-page ul{width:100%;--gap:0}nav.on-this-page a{padding:0}nav.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}nav.on-this-page #back-to-top span{display:none}nav.on-this-page .active a{background-color:var(--action-0);color:var(--action-contrast)}nav.letters,nav.letters a,nav.letters li,nav.letters ul{height:var(--chip)}nav.letters li{max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}nav.index ul{width:100%}nav.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}nav.index li.active,nav.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}nav.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}nav.index a{border-bottom:4px solid transparent}nav.index .active a{border-color:var(--action-0);color:var(--contrast)}nav.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;--align:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}nav.index.open a{--justify:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed,nav.condensed a,nav.condensed li,nav.condensed ul{height:max-content;width:max-content;--wrap:wrap;min-height:var(--chip)}.condensed ul{--justify:center;--dir:row}nav.condensed{--gap:0 .25rem;width:100%;--justify:center}nav.condensed li+li::before{content:'·';padding:0 .25em}nav.condensed a{font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:transparent;color:var(--contrast);border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:flex-end;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x;width:100%}ul.socials li{list-style:none}.always ul.socials{width:100vw;--justify:stretch}.always ul.socials li{flex:1;--justify:center;--align:center}.always ul.socials a{display:inline-flex}ul.socials a{display:inline-block;font-size:var(--txt-x-small);padding:.25rem .5rem;max-width:none}ul.socials .icon{margin:0}ul.socials .icon+span:not(.screen-reader-text){margin-left:.5rem}nav.tabs{padding-bottom:2px;touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button.active{cursor:default}nav.tabs button{font-family:var(--heading);font-size:var(--txt-x-small);border-bottom:4px solid transparent}nav.tabs button.active,nav.tabs button.active:hover{background-color:var(--action-0);color:var(--action-contrast);border-color:var(--base)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}.wp-site-blocks>header,body>header{--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:var(--align,center);justify-content:var(--justify,space-between);padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}.dashboard-nav{width:100%}nav.filters{--dir:row;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}nav.term-navigation:has([hidden]){display:none}nav.pagination{width:100%}nav.pagination>a{min-width:var(--chipchip)}nav.pagination>ul{margin:0 auto}nav.pagination:not(:has(a+ul)){margin-left:var(--chipchip)}nav.pagination:not(:has(ul+a)){margin-right:var(--chipchip)}.pagination.condensed li+li::before{display:none}.pagination li.current{width:var(--chip_);height:var(--chip_);background-color:var(--action-0);border-radius:var(--radius);line-height:1}.pagination.condensed a{font-size:var(--txt-medium);width:var(--chip_);height:var(--chip_)}
assets/js/concise/UtilityFunctions.js
@@ -992,12 +992,75 @@
    { passive: true }
);
window.previousBGSize = 'Small';
window.bgSizes = {
    Small: 500,
    Med: 768,
    Large: 1024
};
window.bgObserver = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                let newSize = entry.target.dataset[`bg${window.previousBGSize}`];
                entry.target.style.backgroundImage = `url(${newSize})`;
                entry.target.dataset.bgImg = window.previousBGSize;
                window.bgObserver.unobserve(entry.target);
            }
        })
    },
    {
        root: null,
        rootMargin: '0px 0px -100px 0px',
        threshold: 0
    });
function updateBG() {
    let current = window.innerWidth;
    let newWidth = getBGWidth(current);
    if (newWidth) {
        window.previousBGSize = newWidth;
        document.querySelectorAll('[data-bg-img]:not([data-bg-img="'+window.previousBGSize+'"])').forEach(img => {
            window.bgObserver.observe(img);
        });
    }
}
function getBGWidth(width) {
    let prev = window.previousBGSize;
    let check = {
        Small: ['Med','Large'],
        Med: ['Large'],
        Large: false
    };
    if (!check[prev]) {
        return false;
    }
    let next = 'Small';
    check[prev].forEach(w => {
        if (width => window.bgSizes[w]) {
            next = w;
        }
    });
    return next;
}
updateBG();
// Debounced resize to recalc scrollable height
window.addEventListener('resize', () => {
    window.debouncer.schedule('recalc-max-scroll', () => {
        updateMaxScroll();
        updateScrollProgress(window.scrollY || docEl.scrollTop || 0);
    }, 20);
    window.debouncer.schedule('bg-resize', () => {
        updateBG();
    });
});
// Initial setup
assets/js/concise/navigation.js
@@ -17,7 +17,7 @@
        this.navs = new Map();
        document.querySelectorAll('nav:has(.submenu), nav:has(.toggle)').forEach(nav => {
            let navID = nav.id;
            if (navID === '') {
            if (navID === '' || this.navs.has(navID)) {
                navID = `nav-${this.counter}`;
                nav.id = navID;
                this.counter++;
assets/js/min/navigation.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,this.openSubmenu=null,this.releaseFocusTrap=null,this.clicked=new Set,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach(e=>{let t=e.id;""===t&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.counter++,n.forEach(e=>{e.id="submenu-"+this.counter,e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)),this.counter++}),this.navs.set(t,a)})}initListeners(){this.clickListener=this.handleClick.bind(this),this.keysListener=this.handleKeys.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(0===this.navs.size)return;let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav"),s=!this.clicked.has(e);return e.classList.contains("open")!==s&&this.toggleNav(s,e.id),void(s?this.clicked.add(e):this.clicked.delete(e))}let s=e.target.closest('[data-action="toggle-submenu"], .has-submenu .a');if(s){let e=s.closest("li"),t=!this.clicked.has(e),n=e.classList.contains("open")!==t;return t?this.clicked.add(e):this.clicked.delete(e),void(n&&this.toggleSubmenu(t,e))}if(!this.openNav)return;let n=!0;for(let[t,s]of this.navs)if(e.target.closest("#"+t)){n=!1;break}n&&this.toggleNav(!1,this.openNav)}handleHoverOn(e){let t=e.currentTarget;this.clicked.has(t)||t.closest("nav.sidebar")||(t.classList.contains("has-submenu")?this.toggleSubmenu(!0,t):"NAV"===t.tagName&&(t.classList.contains("mobile")||this.toggleNav(!0,t.id)))}handleHoverOff(e){let t=e.currentTarget;if(!this.clicked.has(t)&&!t.closest("nav.sidebar"))if(t.classList.contains("has-submenu"))this.toggleSubmenu(!1,t);else if("NAV"===t.tagName){if(t.classList.contains("mobile"))return;let e=this.navs.get(t.id),s=!0;for(let t of e.submenus)if(this.clicked.has(t)){s=!1;break}s&&this.toggleNav(!1,t.id)}}handleKeys(e){if(this.openNav)switch(e.key){case"Escape":this.closeAll();break;case"ArrowDown":this.focusNextItem(),e.preventDefault();break;case"ArrowUp":this.focusPrevItem(),e.preventDefault()}}closeAll(){let e=this.navs.get(this.openNav);e&&this.clicked.has(e.nav)&&this.clicked.delete(e.nav),this.toggleNav(!1,this.openNav)}focusNextItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t+1]||e[0]).focus()}focusPrevItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t-1]||e[e.length-1]).focus()}getFocusableItems(){return Array.from(document.querySelectorAll("nav.open a, nav.open button")).filter(e=>!e.disabled&&!e.closest("[hidden]")&&!e.closest("[inert]"))}toggleNav(e,t){let s=this.navs.get(t);if(s){if(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.keysListener),s.nav.classList.contains("mobile")&&(this.releaseFocusTrap=window.jvbA11y.trapFocus(s.nav))):(this.releaseFocusTrap&&(this.releaseFocusTrap(),this.releaseFocusTrap=null),this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.keysListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach(e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}),Array.from(s.submenus).forEach(e=>{this.clicked.has(e)&&this.clicked.delete(e)})),s.nav.classList.toggle("open",e),s.nav.classList.contains("mobile")){const t=s.nav.querySelector(":scope > ul");t&&(t.inert=!e)}s.nav.setAttribute("aria-expanded",e),e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus()}}toggleSubmenu(e,t){e&&this.openSubmenu&&this.openSubmenu!==t&&!this.openSubmenu.contains(t)&&this.toggleSubmenu(!1,this.openSubmenu),e?this.openSubmenu=t:this.openSubmenu===t&&(this.openSubmenu=null,this.clicked.delete(t));let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];e||s.focus();const i=t.querySelector(":scope > ul");i&&(i.inert=!e);let a=s.getAttribute("aria-label");window.jvbA11y.announce(e?`${a} expanded`:`${a} collapsed`),t.classList.toggle("open",e),s.setAttribute("aria-expanded",e),e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",function(){window.jvbNav=new e})})();
(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,this.openSubmenu=null,this.releaseFocusTrap=null,this.clicked=new Set,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach(e=>{let t=e.id;(""===t||this.navs.has(t))&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.counter++,n.forEach(e=>{e.id="submenu-"+this.counter,e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)),this.counter++}),this.navs.set(t,a)})}initListeners(){this.clickListener=this.handleClick.bind(this),this.keysListener=this.handleKeys.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(0===this.navs.size)return;let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav"),s=!this.clicked.has(e);return e.classList.contains("open")!==s&&this.toggleNav(s,e.id),void(s?this.clicked.add(e):this.clicked.delete(e))}let s=e.target.closest('[data-action="toggle-submenu"], .has-submenu .a');if(s){let e=s.closest("li"),t=!this.clicked.has(e),n=e.classList.contains("open")!==t;return t?this.clicked.add(e):this.clicked.delete(e),void(n&&this.toggleSubmenu(t,e))}if(!this.openNav)return;let n=!0;for(let[t,s]of this.navs)if(e.target.closest("#"+t)){n=!1;break}n&&this.toggleNav(!1,this.openNav)}handleHoverOn(e){let t=e.currentTarget;this.clicked.has(t)||t.closest("nav.sidebar")||(t.classList.contains("has-submenu")?this.toggleSubmenu(!0,t):"NAV"===t.tagName&&(t.classList.contains("mobile")||this.toggleNav(!0,t.id)))}handleHoverOff(e){let t=e.currentTarget;if(!this.clicked.has(t)&&!t.closest("nav.sidebar"))if(t.classList.contains("has-submenu"))this.toggleSubmenu(!1,t);else if("NAV"===t.tagName){if(t.classList.contains("mobile"))return;let e=this.navs.get(t.id),s=!0;for(let t of e.submenus)if(this.clicked.has(t)){s=!1;break}s&&this.toggleNav(!1,t.id)}}handleKeys(e){if(this.openNav)switch(e.key){case"Escape":this.closeAll();break;case"ArrowDown":this.focusNextItem(),e.preventDefault();break;case"ArrowUp":this.focusPrevItem(),e.preventDefault()}}closeAll(){let e=this.navs.get(this.openNav);e&&this.clicked.has(e.nav)&&this.clicked.delete(e.nav),this.toggleNav(!1,this.openNav)}focusNextItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t+1]||e[0]).focus()}focusPrevItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t-1]||e[e.length-1]).focus()}getFocusableItems(){return Array.from(document.querySelectorAll("nav.open a, nav.open button")).filter(e=>!e.disabled&&!e.closest("[hidden]")&&!e.closest("[inert]"))}toggleNav(e,t){let s=this.navs.get(t);if(s){if(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.keysListener),s.nav.classList.contains("mobile")&&(this.releaseFocusTrap=window.jvbA11y.trapFocus(s.nav))):(this.releaseFocusTrap&&(this.releaseFocusTrap(),this.releaseFocusTrap=null),this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.keysListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach(e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}),Array.from(s.submenus).forEach(e=>{this.clicked.has(e)&&this.clicked.delete(e)})),s.nav.classList.toggle("open",e),s.nav.classList.contains("mobile")){const t=s.nav.querySelector(":scope > ul");t&&(t.inert=!e)}s.nav.setAttribute("aria-expanded",e),e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus()}}toggleSubmenu(e,t){e&&this.openSubmenu&&this.openSubmenu!==t&&!this.openSubmenu.contains(t)&&this.toggleSubmenu(!1,this.openSubmenu),e?this.openSubmenu=t:this.openSubmenu===t&&(this.openSubmenu=null,this.clicked.delete(t));let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];e||s.focus();const i=t.querySelector(":scope > ul");i&&(i.inert=!e);let a=s.getAttribute("aria-label");window.jvbA11y.announce(e?`${a} expanded`:`${a} collapsed`),t.classList.toggle("open",e),s.setAttribute("aria-expanded",e),e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",function(){window.jvbNav=new e})})();
assets/js/min/utility.min.js
@@ -1 +1 @@
(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,()=>{e.remove()},500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,t=>e[t])}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",()=>{window.loadTemplates()}),window.loadTemplates=function(){document.querySelectorAll("template").forEach(e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach(e=>{window.templates.has(e)||window.templates.set(e,n)})}})},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach(e=>{e.classList.forEach(t=>{this.templates.has(t)||this.templates.set(t,e)})})}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",()=>{window.jvbTemplates.registerAll()}),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout(()=>n=!1,t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach(e=>{const n=t(e);n&&i.append(n)}),n(i),await new Promise(e=>requestAnimationFrame(e))}},window.prefixInput=function(e,t,n=null,i=!1,o=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const r=e.id,a=i?t:`${t}${e.name}`;let s=null;s=n?n.querySelector(`label[for="${r}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${r}"]`),s&&(s.htmlFor=a),e.id=a,o&&(e.name=a)},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise(i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval(()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())},n)})},window.eraseText=function(e,t=10){return new Promise(n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval(()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())},t)})},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise(e=>setTimeout(e,o)),s)&&(await window.eraseText(e,i),s);)await new Promise(e=>setTimeout(e,r))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,function(e){return e[1].toUpperCase()})},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i},window.sleep=async function(e=50){return new Promise(t=>setTimeout(t,e))};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",()=>this.cleanup())}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout(()=>{t(),this.timeouts.delete(e)},n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",()=>{o||(o=!0,requestAnimationFrame(l))},{passive:!0}),window.addEventListener("resize",()=>{window.debouncer.schedule("recalc-max-scroll",()=>{a(),s(window.scrollY||e.scrollTop||0)},20)}),a(),s(n),window.decodeHTMLEntities=function(e){return window.decodeHelper||(window.decodeHelper=document.createElement("textarea")),window.decodeHelper.innerHTML=e,window.decodeHelper.value},window.focusNextElement=function(){if(document.activeElement&&document.activeElement.form){var e=Array.prototype.filter.call(document.activeElement.form.querySelectorAll('a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'),function(e){return e.offsetWidth>0||e.offsetHeight>0||e===document.activeElement}),t=e.indexOf(document.activeElement);if(t>-1)(e[t+1]||e[0]).focus()}}})();
(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,()=>{e.remove()},500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,t=>e[t])}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",()=>{window.loadTemplates()}),window.loadTemplates=function(){document.querySelectorAll("template").forEach(e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach(e=>{window.templates.has(e)||window.templates.set(e,n)})}})},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach(e=>{e.classList.forEach(t=>{this.templates.has(t)||this.templates.set(t,e)})})}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",()=>{window.jvbTemplates.registerAll()}),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout(()=>n=!1,t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach(e=>{const n=t(e);n&&i.append(n)}),n(i),await new Promise(e=>requestAnimationFrame(e))}},window.prefixInput=function(e,t,n=null,i=!1,o=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const r=e.id,a=i?t:`${t}${e.name}`;let s=null;s=n?n.querySelector(`label[for="${r}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${r}"]`),s&&(s.htmlFor=a),e.id=a,o&&(e.name=a)},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise(i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval(()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())},n)})},window.eraseText=function(e,t=10){return new Promise(n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval(()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())},t)})},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise(e=>setTimeout(e,o)),s)&&(await window.eraseText(e,i),s);)await new Promise(e=>setTimeout(e,r))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,function(e){return e[1].toUpperCase()})},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i},window.sleep=async function(e=50){return new Promise(t=>setTimeout(t,e))};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",()=>this.cleanup())}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout(()=>{t(),this.timeouts.delete(e)},n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}function c(){window.innerWidth;let e=function(){let e=window.previousBGSize,t={Small:["Med","Large"],Med:["Large"],Large:!1};if(!t[e])return!1;let n="Small";return t[e].forEach(e=>{(t=>window.bgSizes[e])&&(n=e)}),n}();e&&(window.previousBGSize=e,document.querySelectorAll('[data-bg-img]:not([data-bg-img="'+window.previousBGSize+'"])').forEach(e=>{window.bgObserver.observe(e)}))}window.addEventListener("scroll",()=>{o||(o=!0,requestAnimationFrame(l))},{passive:!0}),window.previousBGSize="Small",window.bgSizes={Small:500,Med:768,Large:1024},window.bgObserver=new IntersectionObserver(e=>{e.forEach(e=>{if(e.isIntersecting){let t=e.target.dataset[`bg${window.previousBGSize}`];e.target.style.backgroundImage=`url(${t})`,e.target.dataset.bgImg=window.previousBGSize,window.bgObserver.unobserve(e.target)}})},{root:null,rootMargin:"0px 0px -100px 0px",threshold:0}),c(),window.addEventListener("resize",()=>{window.debouncer.schedule("recalc-max-scroll",()=>{a(),s(window.scrollY||e.scrollTop||0)},20),window.debouncer.schedule("bg-resize",()=>{c()})}),a(),s(n),window.decodeHTMLEntities=function(e){return window.decodeHelper||(window.decodeHelper=document.createElement("textarea")),window.decodeHelper.innerHTML=e,window.decodeHelper.value},window.focusNextElement=function(){if(document.activeElement&&document.activeElement.form){var e=Array.prototype.filter.call(document.activeElement.form.querySelectorAll('a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'),function(e){return e.offsetWidth>0||e.offsetHeight>0||e===document.activeElement}),t=e.indexOf(document.activeElement);if(t>-1)(e[t+1]||e[0]).focus()}}})();
build/feed/view.asset.php
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => 'c3aa1c027f932096017e');
<?php return array('dependencies' => array(), 'version' => 'b9d79a303cb5aad9e29a');
build/feed/view.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback(()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()},{timeout:2e3}):setTimeout(()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()},100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map(e=>e.value):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map(e=>e.value):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map(e=>e.dataset.taxonomy):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach(e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}}),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach(e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)})}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach(e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()}),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter(t=>t.dataset.taxonomy===e)[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter(e=>e!==t),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach(t=>{t&&t.forEach(t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)})})}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)})}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach(e=>{this.createTermElement(e)}),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find(t=>t.dataset.taxonomy===e);return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach(e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)})}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach(i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)});let i=!1;return e.forEach((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}}),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach(t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])});for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach(e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)});const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()})}initStore(){let e=this.orderbyFilters.filter(e=>!["date","modified","title","random"].includes(e)),t=[];e.forEach(e=>{t.push({name:e,keyPath:e})});const i=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))})}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=null!=e?e:this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,e=>this.createItemElement(e),t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)},5).then(()=>{}),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map(e=>parseInt(e.trim())).filter(e=>e)}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some(t=>Object.keys(e.images).map(e=>parseInt(e)).includes(parseInt(t)))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach(s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)}),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");if(!l)continue;let h=window.decodeHTMLEntities(r.title);[l.href,l.title,l.textContent]=[r.url,`See more ${h}`,h],e.append(n)}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=window.decodeHTMLEntities(t)}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number-1} Tx`),s&&(s.textContent=e.number-1),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach(e=>e.remove())}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=window.decodeHTMLEntities(s.name))}}),e.define("emptyState"),this.contentTypes.forEach(i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})})}}document.addEventListener("DOMContentLoaded",async function(){window.auth.subscribe(t=>{"auth-loaded"===t&&(window.feedBlock=new e)})})})();
(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback(()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()},{timeout:2e3}):setTimeout(()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()},100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map(e=>e.value):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map(e=>e.value):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map(e=>e.dataset.taxonomy):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach(e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}}),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach(e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)})}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach(e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()}),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){return this.selector.getFieldId(Array.from(this.ui.taxonomies).filter(t=>t.dataset.taxonomy===e)[0]??null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter(e=>e!==t),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach(t=>{t&&t.forEach(t=>{const i=t.dataset.for?.split(",")??[];t.hidden=i.length>0&&!i.includes(e),t.hidden&&t.checked&&(t.checked=!1)})})}updateOrderOptions(e){if(this.ui.orderbyWrap){let t=this.ui.orderbyWrap.dataset.forOrder.split(",")??[];this.ui.orderbyWrap.hidden=!t.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)})}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach(e=>{this.createTermElement(e)}),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find(t=>t.dataset.taxonomy===e);return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach(e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)})}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach(i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)});let i=!1;return e.forEach((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}}),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach(t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])});for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach(e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)});const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()})}initStore(){let e=this.orderbyFilters.filter(e=>!["date","modified","title","random"].includes(e)),t=[];e.forEach(e=>{t.push({name:e,keyPath:e})});const i=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe((e,t)=>{"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=!this.store.lastResponse?.has_more??!0))})}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=e??this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,e=>this.createItemElement(e),t=>{this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),this.store.lastResponse?.has_more??!1)},5).then(()=>{}),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map(e=>parseInt(e.trim())).filter(e=>e)}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some(t=>Object.keys(e.images).map(e=>parseInt(e)).includes(parseInt(t)))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach(s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)}),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){let s=i.images[t]??!1;s&&([e.src,e.srcset,e.alt]=[s.tiny,`${s.tiny} 50w, ${s.small} 300w, ${s.medium} 1024w`,s["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){let r=t.taxonomies[i][s]??!1;if(!r)continue;let a=o.cloneNode(!0),n=a.querySelector("a");if(!n)continue;let l=window.decodeHTMLEntities(r.title);[n.href,n.title,n.textContent]=[r.url,`See more ${l}`,l],e.append(a)}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=window.decodeHTMLEntities(t)}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number-1} Tx`),s&&(s.textContent=e.number-1),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach(e=>e.remove())}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=window.decodeHTMLEntities(s.name))}}),e.define("emptyState"),this.contentTypes.forEach(i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${r.fields.post_title??"Item"}`),o&&t.addTimelineElements(r,e)}}})})}}document.addEventListener("DOMContentLoaded",async function(){window.auth.subscribe(t=>{"auth-loaded"===t&&(window.feedBlock=new e)})})})();
build/fields/render.php
@@ -120,7 +120,7 @@
    </header>
    <section>
        <details class="bio-info">
            <summary class="row btw">
            <summary class="row x-btw">
                <h2>About <?= ($artist['name'] !== '') ? $artist['name'] : strtok($artist['display_name'], ' ')?></h2>
            </summary>
            <div class="columns stack-small">
@@ -242,7 +242,7 @@
    </header>
    <section>
        <details class="bio-info">
            <summary class="row btw">
            <summary class="row x-btw">
                <h2>Learn More About <?=$current->name?></h2>
            </summary>
            <div class="map">
build/gmbreviews/render.php
@@ -134,7 +134,7 @@
                                <?= apply_filters('wpautop', $comment) ?>
                            </div>
                        <?php } ?>
                        <cite class="row start nowrap">
                        <cite class="row left nowrap">
                            <?php if (!empty($profilePhoto)) { ?>
                                <img src="<?=esc_url($profilePhoto)?>"
                                     alt="<?=esc_attr($reviewer)?>"
@@ -145,7 +145,7 @@
                                </div>
                            <?php } ?>
                            <div class="row start wrap">
                            <div class="row left wrap">
                                <?php if ($showRating && $rating > 0) { ?>
                                    <div class="stars" title="<?= $rating ?> out of 5 stars">
                                        <?php
build/summary/render.php
@@ -120,7 +120,7 @@
    </header>
    <section>
        <details class="bio-info">
            <summary class="row btw">
            <summary class="row x-btw">
                <h2>About <?= ($artist['name'] !== '') ? $artist['name'] : strtok($artist['display_name'], ' ')?></h2>
            </summary>
            <div class="columns stack-small">
@@ -242,7 +242,7 @@
    </header>
    <section>
        <details class="bio-info">
            <summary class="row btw">
            <summary class="row x-btw">
                <h2>Learn More About <?=$current->name?></h2>
            </summary>
            <div class="map">
inc/admin/ContentTaxonomy.php
@@ -290,7 +290,7 @@
            <?php foreach ($results['details'] as $taxonomy => $detail): ?>
                <details>
                    <summary class="row btw">
                    <summary class="row x-btw">
                        <strong><?= esc_html(ucfirst($taxonomy)) ?></strong>
                        <?php if ($detail['errors'] === 0): ?>
                            <span style="color: green;">✓ Success</span>
inc/blocks/CustomBlocks.php
@@ -6,6 +6,7 @@
use JVBase\managers\Cache;
use JVBase\managers\LoginManager;
use JVBase\managers\SEO\BreadcrumbManager;
use JVBase\utility\Image;
use WP_Block;
use WP_Query;
@@ -16,7 +17,16 @@
class CustomBlocks
{
    protected Cache $cache;
    protected array $shouldRender = ['core/query'];
    protected static ?WP_Query $currentLoop = null;
    protected static ?int $currentQueryId = null;
    protected static array $counters = [];
    protected static ?WP_Query $originalQuery = null;
    protected array $ignore = ['align','alt','area','backgroundColor','borderColor','buttonText','buttonPosition','buttonUseIcon','categories','className','columns','contentPosition','customOverlayColor','dimRatio','displayAsDropdown','displayAuthor','displayFeaturedImage','displayPostContent','displayPostContentRadio','displayPostDate','excerptLength','featuredImageAlign','fontSize','gradient','height','iconColor','iconColorValue','iconColorValue','iconBackgroundColor','iconBackgroundColorValue','id','imageFill','isDark','isLink','isSearchFieldHidden','isStackedOnMobile','isUserOverlayColor','kind','label','largestFontSize','layout','level','mediaId','mediaLink','mediaSizeSlug','mediaType','metadata','minHeight','minHeightUnit','opacity','opensInNewTab','order','orderBy','ordered','overlayMenu','placeholder','postLayout','postsToShow','query', 'queryId','ref','rel','shouldSyncIcon','showEmpty','showHierarchy','showLabel','showLabels','showOnlyTopLevel','showPostCounts','showTagCounts','size','sizeSlug','slug','smallestFontSize','tagName','taxonomy','term','textAlign','textColor','theme','title','type','url','useFeaturedImage','width','widthUnit',];
    //For custom style output for nested links, etc
    protected static array $pendingStyles = [];
    protected static array $pendingClass = [];
    public function __construct()
    {
        $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
@@ -67,6 +77,13 @@
                'label' => __('Callout Alt', 'jvb')
            ]
        );
        register_block_style(
            'core/separator',
            [
                'name'  =>'logo',
                'label' => __('With Logo', 'jvb')
            ]
        );
    }
    protected function checkMethods(?string $content, array $block, ?WP_Block $parent = null, bool $isPrerender = false):?string
    {
@@ -79,7 +96,8 @@
        if (function_exists($function)) {
            return $function($block, $content, $parent);
        } else if (method_exists($this, $method)) {
            return $this->$method($block, $content, $parent);
            $content = $this->$method($block, $content, $parent);
            return $isPrerender ? $this->maybeOutputCustomStyles().$content : $content;
        } elseif (!empty($blockName) && JVB_TESTING) {
            if (!in_array($block['blockName'], $this->getIgnore($isPrerender))) {
                jvbDump('No method found for '.print_r($block['blockName'], true));
@@ -95,7 +113,13 @@
            ];
            if ($isPrerender) {
                $base = array_merge($base, [
                    'core/query-pagination',
                    'core/query-pagination-previous',
                    'core/query-pagination-next',
                    'core/query-pagination-numbers',
                    'core/query',
                    'core/calendar',
                    'core/archives',
                ]);
            } else {
                $base = array_merge($base, [
@@ -151,10 +175,10 @@
        }
        $icon = '';
        if (str_contains($url[1], 'google.com/maps')) {
            $icon = 'google-logo';
            $icon = jvbIcon('google-logo');
        }
        if (str_contains($url[1], 'maps.apple.com')) {
            $icon = 'apple-logo';
            $icon = jvbIcon('apple-logo');
        }
        if ($icon !== '') {
@@ -163,7 +187,7 @@
                $this->getClassesAndStyles($block['attrs']??[]),
                esc_url($url[1]),
                esc_html($label[1]),
                jvbIcon($icon)
                $icon
            );
        }
@@ -198,12 +222,18 @@
    public function prerender_core_columns(array $block, ?string $content, ?WP_Block $parent):?string
    {
        $tagName = array_key_exists('tagName', $block['attrs']) ? $block['attrs']['tagName'] : 'section';
        jvbDump($block, 'columns');
        $attrs = $block['attrs']??[];
        $tagName = array_key_exists('tagName', $attrs) ? $attrs['tagName'] : 'section';
        $classes = ['row', 'nowrap'];
        if (!array_key_exists('isStackedOnMobile', $attrs) || $attrs['isStackedOnMobile'] === true){
            $classes[] = 'stack-small';
        }
        return sprintf(
            '<%s%s>%s</%s>',
            $tagName,
            $this->getClassesAndStyles($block['attrs']??[], ['row nowrap']),
            $this->getClassesAndStyles($attrs, $classes),
            $this->innerBlocks($block).'</section>',
            $tagName
        );
@@ -228,12 +258,36 @@
    //core_home_link
    //core_more
    //core_nextpage
    public function prerender_core_nextpage(array $block, ?string $content, ?WP_Block $parent):?string
    {
        return str_replace('</a>', '</a></li>',str_replace('<a', '<li><a', wp_link_pages([
            'before'        => '<nav class="pagination x-btw"><ul>',
            'after'         => '</ul></nav>',
            'nextpagelink'  => __('<span>Next </span>'.jvbIcon('caret-circle-right'), 'jvb'),
            'previouspagelink'  => __(jvbIcon('caret-circle-left').'<span> Previous</span>', 'jvb'),
            'next_or_number'=> 'next',
            'echo'          => false
        ])));
    }
    public function prerender_core_separator(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'separator');
//      jvbDump($parent, 'Parent');
        return '<hr'.$this->getClassesAndStyles($block['attrs']??[]).'>';
        $attrs = $block['attrs']??[];
        $logo = '';
        if (array_key_exists('className', $attrs) && $attrs['className'] === 'is-style-logo'){
            $logo = apply_filters('jvbSeparatorLogo', 'logo');
            if (!empty($logo)) {
                $logo = jvbIcon($logo);
            }
        }
        return sprintf(
            '<hr%s>',
            $this->getClassesAndStyles($attrs),
//          $logo
        );
    }
    public function prerender_core_spacer(array $block, ?string $content, ?WP_Block $parent):?string
@@ -341,29 +395,60 @@
//      jvbDump($block, 'media text');
//      jvbDump($parent, 'Parent');
        $ID = $this->imageID('', $block);
        $attrs = $block['attrs']??[];
        $size = array_key_exists('mediaSizeSlug', $block['attrs']??[]) ? $block['attrs']['mediaSizeSlug'] : 'large';
        $size = array_key_exists('mediaSizeSlug', $attrs) ? $attrs['mediaSizeSlug'] : 'large';
        $imgLink = ($ID) ? $this->imageLink(true, $ID, 'tiny', $size) : '';
        $inner = $this->innerBlocks($block);
        $classes = ['media-text', 'row'];
        if (array_key_exists('isStackedOnMobile', $block['attrs']??[])) {
            $classes[] = 'nowrap';
        $classes = ['media-text', 'row', 'nowrap'];
        if (!array_key_exists('isStackedOnMobile', $attrs) || $attrs['isStackedOnMobile'] === true) {
            $classes[] = 'stack-small';
        }
        $content = '<div'.$this->getClassesAndStyles($block['attrs']??[], $classes).'>';
        $content .= (array_key_exists(
            'mediaPosition',
            $block['attrs']??[]
        ) && $block['attrs']['mediaPosition'] == 'right') ?
            '<div>'.$inner.'</div><figure>'.$imgLink.'</figure>' :
            '<figure>'.$imgLink.'</figure><div>'.$inner.'</div>';
        $content .= '</div>';
        return $content;
        $inside = array_key_exists('mediaPosition', $attrs) && $attrs['mediaPosition'] === 'right'
            ? sprintf(
                '<div>%s</div><figure>%s</figure>',
                $inner, $imgLink
            ) : sprintf(
                '<figure>%s</figure><div>%s</div>',
                $imgLink, $inner
            );
        return sprintf(
            '<div%s>%s</div>',
            $this->getClassesAndStyles($attrs, $classes),
            $inside
        );
    }
    //core_video
    public function prerender_core_video(array $block, ?string $content, ?WP_Block $parent):?string
    {
        jvbDump($block, 'video');
//      jvbDump($parent, 'Parent');
        $ID = $this->imageID('', $block);
        if (!$ID) {
            return '';
        }
        jvbDump($ID);
        $title = (get_the_title($ID) !== '') ? '<b>'.get_the_title($ID).'</b>' : '';
        $caption = (wp_get_attachment_caption($ID)) ?
            '<figcaption>' .
            $title .
            wp_get_attachment_caption($ID) .
            '</figcaption>' :
            '<figcaption>' . $title . '</figcaption>';
        $size = array_key_exists('sizeSlug', $block['attrs']??[]) ? $block['attrs']['sizeSlug'] : 'large';
        return '<figure'.
            $this->getClassesAndStyles($block['attrs']??[]).'>'.
            $this->imageLink(true, $ID, 'tiny', $size) .
            $caption.'</figure>';
    }
    /**
     * Reusable blocks
@@ -379,10 +464,8 @@
    //prerender_core_classic
    public function prerender_core_heading(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'heading');
//      jvbDump($parent, 'Parent');
        $level = (array_key_exists('level', $block['attrs']??[])) ? $block['attrs']['level'] : '2';
        $content = $this->innerBlocks($block);
        $content = $this->inside($block);
        $id = sanitize_title(wp_strip_all_tags($this->stripTagContents('small', $content)));
        return '<h'.$level.' id="'.$id.'"'.$this->getClassesAndStyles($block['attrs']??[]).'>'.
               $content.
@@ -408,9 +491,12 @@
    {
//      jvbDump($block, 'paragraph');
//      jvbDump($parent, 'Parent');
        return '<p'.$this->getClassesAndStyles($block['attrs']??[]).'>'.
               $this->innerBlocks($block).
               '</p>';
        $inside = $this->inside($block);
        return empty($inside) ? '' : sprintf(
        '<p%s>%s</p>',
            $this->getClassesAndStyles($block['attrs']??[]),
           $inside
        );
    }
    public function prerender_core_quote(array $block, ?string $content, ?WP_Block $parent): ?string
    {
@@ -492,33 +578,50 @@
    }
    //core_pattern
    public function prerender_core_site_logo(array $block, ?string $content, ?WP_Block $parent):?string
    public function prerender_core_site_logo(array $block, ?string $content, ?WP_Block $parent = null):?string
    {
//      jvbDump($block, 'site logo');
//      jvbDump($parent, 'Parent');
        $attrs = $block['attrs']??[];
        $open = $close = '';
        if (!is_home() && !is_front_page()) {
            $open = '<a href="'.get_home_url().'" rel="home">';
        if ((!is_home() && !is_front_page()) && (!array_key_exists('isLink', $attrs) || $attrs['isLink'] === true)) {
            $open = '<a href="'.get_home_url().'" rel="home" class="logo">';
            $close = '</a>';
        }
        $img = get_theme_mod('custom_logo');
        $img = $this->image($img, 'tiny', 'thumbnail');
        $img = str_replace('<img', '<img'.$this->getClassesAndStyles($block['attrs']??[]), $img);
        $img = sprintf(
            '<figure%s>%s</figure>',
            $this->getClassesAndStyles($attrs, ['logo']),
            $this->image($img, 'tiny', 'thumbnail')
        );
        return $open.$img.$close;
    }
    //core_site_title_tagline
    public function prerender_core_site_tagline(array $block, ?string $content, ?WP_Block $parent):?string
    {
        $tagline = get_bloginfo('description');
        return empty($tagline) ? '' : sprintf(
            '<p%s>%s</p>',
            $this->getClassesAndStyles($block['attrs']??[], ['tagline']),
            $tagline
        );
    }
    public function prerender_core_site_title(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'site title');
//      jvbDump($parent, 'Parent');
        $tag = (array_key_exists('level', $block['attrs']??[])) ? $block['attrs']['level'] : 1;
        $attrs = $block['attrs']??[];
        $tag = (array_key_exists('level', $attrs)) ? $attrs['level'] : 1;
        $tag = ($tag == 0) ? 'p' : 'h'.$tag;
        $open = $close = '';
        if (!is_front_page()) {
            $open = '<a href="' . get_home_url() . '" rel="home">';
        if (!is_front_page() && (!array_key_exists('isLink', $attrs) || $attrs['isLink'] === true)) {
            $open = sprintf(
                '<a href="%s" rel="home">',
                get_home_url()
            );
            $close = '</a>';
        }
        $class = ($tag === 'p') ?
@@ -526,11 +629,15 @@
            $this->getClassesAndStyles($block['attrs']??[]);
        return '<'.$tag.$class.'>'.
               $open.
               get_bloginfo('name').
               $close.
               '</'.$tag.'>';
        return sprintf(
            '<%s%s>%s%s%s</%s>',
            $tag,
            $class,
            $open,
            get_bloginfo('name'),
            $close,
            $tag
        );
    }
    /**
@@ -557,44 +664,70 @@
    {
//      jvbDump($block, 'navigation');
//      jvbDump($parent, 'Parent');
//      jvbDump($block, 'navigation');
        $ID = (array_key_exists('ref', $block['attrs']??[])) ? $block['attrs']['ref'] : false;
        if (empty($block['innerBlocks']) && $ID && get_post($ID)) {
            $block['innerBlocks'] = parse_blocks(get_post($ID)->post_content);
        }
        $attrs = $block['attrs']??[];
        $toggle = (array_key_exists('overlayMenu', $block['attrs']??[])
                   && $block['attrs']['overlayMenu'] == 'never') ?
            '':
            '<button class="toggle main"
            data-action="toggle-menu"
            aria-label="Open Menu"
            aria-controls="navigation-' .$ID. '"
            aria-expanded="false">'.
            jvbIcon('list', ['title'=>'Toggle Menu']).
            jvbIcon('x', ['title'=>'Toggle Menu']).
        '</button>';
        $class = ($toggle === '') ?
            $this->getClassesAndStyles($block['attrs']??[], ['mobile']) :
            $this->getClassesAndStyles($block['attrs']??[]);
        $helpmenu = (get_the_title($ID) === 'Main') ?
            '<nav><ul>'.jvbNotificationMenu().jvbHelpMenu().'</ul></nav>' :
            '';
        $toggle = '';
        $classes = [];
        if (!array_key_exists('overlayMenu', $attrs) || $attrs['overlayMenu'] !== 'never') {
            $toggle = sprintf(
                '<button class="toggle main"
                data-action="toggle-menu"
                aria-label="Open Menu"
                aria-controls="navigation-%d"
                aria-expanded="false">%s%s</button>',
                $ID,
                jvbIcon('list'),
                jvbIcon('x')
            );
            $classes[] = 'mobile';
            if (array_key_exists('overlayMenu', $attrs) && $attrs['overlayMenu'] === 'always') {
                $classes[] = 'always';
            }
        }
        if (!array_key_exists('layout', $attrs)) {
            $classes[] = 'left';
            $classes[] = 'row';
        }
        $class = $this->getClassesAndStyles($attrs, $classes);
        $helpmenu = '';
        $title = get_the_title($ID);
        $isMain = false;
        if ($title === 'Main') {
            $isMain = true;
            $helpmenu = sprintf(
                '<nav><ul>%s%s</ul></nav>',
                jvbNotificationMenu(),
                jvbHelpMenu()
            );
        }
        //Allows to add custom items to a menu, based on the menu name
        $helpmenu = apply_filters('jvbMenuExtraAfter', $helpmenu, get_the_title($ID));
        $main = trim(apply_filters('jvbMenuExtra', $this->innerBlocks($block), get_the_title($ID), $block));
        $helpmenu = apply_filters('jvbMenuExtraAfter', $helpmenu, $title, $ID);
        $main = trim(apply_filters('jvbMenuExtra', $this->innerBlocks($block), $title, $block));
        $main = str_starts_with($main, '<ul') ? $main : '<ul>'.$main.'</ul>';
        $main = str_starts_with($main, '<ul') ? $main : sprintf('<ul>%s</ul>',$main);
        return '<nav'.$class.' id="navigation-' . $ID . '"aria-label="Navigation">
            <span class="screen-reader-text">
        $skipToContent = $isMain ? '<span class="screen-reader-text">
                <a href="#content">Skip to Content</a>
            </span>' .
               $toggle .
                $main.
           '</nav>'.$helpmenu;
            </span>' : '';
        return sprintf(
            '<nav%s id="navigation-%d"aria-label="Navigation">
            %s%s%s</nav>%s',
            $class,
            $ID,
            $skipToContent,
            $toggle,
            $main,
            $helpmenu
        );
    }
    public function prerender_core_navigation_link(array $block, ?string $content, ?WP_Block $parent):?string
@@ -605,20 +738,20 @@
        if (!array_key_exists('attrs', $block)) {
            return '';
        }
        $url = (str_starts_with($block['attrs']['url'],'/')) ?
            home_url($block['attrs']['url']) :
            $block['attrs']['url'];
        $attrs = $block['attrs']??[];
        $url = (str_starts_with($attrs['url'],'/')) ?
            home_url($attrs['url']) :
            $attrs['url'];
        $current = (home_url($wp->request.'/') == $url);
        $temp = $block['attrs']??[];
        unset($temp['url']);
        $attrs['url'] = $url;
        $classes = ($current) ?
            $this->getClassesAndStyles($temp, ['current']):
            $this->getClassesAndStyles($temp);
            $this->getClassesAndStyles($attrs, ['current']):
            $this->getClassesAndStyles($attrs);
        $aria = '';
        if ($current) {
            $aria = ' aria-current="page"';
        }
        $linkOpen = $this->build_navigation_link($block['attrs']??[], $aria);
        $linkOpen = $this->buildNavigationLink($attrs, $aria);
        return '<li'.$classes.'>'.$linkOpen.$block['attrs']['label'].'</a></li>';
@@ -634,22 +767,32 @@
            $block['attrs']['url'];
        $current = (home_url($wp->request) == $url);
        $temp = $block['attrs']??[];
        unset($temp['url']);
        $attrs = $block['attrs']??[];
        $attrs['url'] = $url;
        $classes = ($current) ?
            $this->getClassesAndStyles($temp, ['has-submenu', 'current']):
            $this->getClassesAndStyles($temp, ['has-submenu']);
            $this->getClassesAndStyles($attrs, ['has-submenu', 'current']):
            $this->getClassesAndStyles($attrs, ['has-submenu']);
        $aria = '';
        if ($current) {
            $aria = ' aria-current="page"';
        }
        $id = sanitize_title($block['attrs']['label']);
        $linkOpen = $this->build_navigation_link($block['attrs'], $aria);
        $content = '<li'.$classes.'>'.$linkOpen.$block['attrs']['label'].
                   '</a><button class="toggle" data-action="toggle-submenu" title="Toggle Submenu" aria-label="Open '.$block['attrs']['label'].' Submenu" aria-expanded="false" aria-controls="'.$id.'-submenu">'.
                   jvbIcon('caret-down', ['title'=>'Toggle Submenu']).
                   '</button><ul class="submenu" id='.$id.'-submenu">';
        $linkOpen = $this->buildNavigationLink($attrs, $aria);
        $content = sprintf(
            '<li%s>%s%s</a>
                        <button class="toggle" data-action="toggle-submenu" title="Toggle Submenu" aria-label="Open %s Submenu" aria-expanded="false" aria-controls="%s-submenu">
                            %s
                        </button>
                        <ul class="submenu" id=%s-submenu">',
            $classes,
            $linkOpen,
            $attrs['label'],
            $attrs['label'],
            $id,
            jvbIcon('caret-down', ['title'=>'Toggle Submenu']),
            $id
        );
        $content .= $this->innerBlocks($block);
        $content .= '</ul></li>';
@@ -657,9 +800,8 @@
        return $content;
    }
    protected function build_navigation_link(array $attrs, string $aria):string
    protected function buildNavigationLink(array $attrs, string $aria):string
    {
        global $wp;
        $url =(str_starts_with($attrs['url'],'/')) ?
            home_url($attrs['url']) :
            $attrs['url'];
@@ -694,8 +836,88 @@
     * Theme Query Blocks
     */
    //core_post_author
    public function prerender_core_post_author(array $block, ?string $content, ?WP_Block $parent):?string
    {
        $attrs = $block['attrs'] ?? [];
        $size = 96;
        if (array_key_exists('avatarSize',$attrs) && is_int($attrs['avatarSize'])) {
            $size = $attrs['avatarSize'];
        }
        $byline = $aOpen = $aClose = $avatar = $bio = '';
        global $post;
        $user = get_userdata($post->post_author);
        if (!array_key_exists('showAvatar', $attrs) || $this->checkAttrs('showAvatar', $attrs)){
            $avatar = get_avatar($post->post_author, $size);
        }
        if (!array_key_exists('showBio', $attrs) || $this->checkAttrs('showBio', $attrs)) {
            $bio = wpautop($user->description);
        }
        $target = '';
        if (array_key_exists('linkTarget', $attrs) && $attrs['linkTarget']=== '_blank') {
            $target = ' target="_blank"';
        }
        if ($this->checkAttrs('isLink', $attrs)) {
            $aOpen = sprintf(
                '<a href="%s"%s>',
                get_author_posts_url($post->post_author),
                $target
            );
            $aClose = '</a>';
        }
        if (array_key_exists('byline', $attrs)) {
            $byline = sprintf(
                '<small>%s</small> — ',
                $attrs['byline']
            );
        }
        $name = $user->display_name;
        return sprintf(
            '<div%s>%s%s%s<p>%s%s%s%s</p>%s</div>',
            $this->getClassesAndStyles($attrs, ['row','nowrap']),
            $aOpen,
            $avatar,
            $aClose,
            $aOpen,
            $byline,
            $name,
            $aClose,
            $bio
        );
    }
    //core_post_author_biography
    //core_post_author_name
    public function prerender_core_post_author_name(array $block, ?string $content, ?WP_Block $parent):?string
    {
        $attrs = $block['attrs']??[];
        global $post;
        $aOpen = $aClose = '';
        if ($this->checkAttrs('isLink', $attrs)) {
            $aOpen = sprintf(
                '<a href="%s" rel="author">',
                get_author_posts_url($post->post_author)
            );
            $aClose = '</a>';
        }
        $author = get_userdata($post->post_author);
        return sprintf(
            '<p%s>%s%s%s</p>',
            $this->getClassesAndStyles($attrs, ['author']),
            $aOpen,
            $author->display_name,
            $aClose
        );
    }
    public function prerender_core_post_content(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'post content');
@@ -707,9 +929,21 @@
        if ($content == '') {
            if(is_singular()) {
                global $post;
                global $post, $page;
                $block['innerBlocks'] = parse_blocks($post->post_content);
                $pages = explode('<!--nextpage-->', $post->post_content);
                $currentContent = $pages[max(0, $page - 1)] ?? $pages[0];
                if ($page > 1 && !str_contains($currentContent, '<!--nextpage-->')) {
                    $currentContent = str_replace('<!-- /wp:nextpage -->','', $currentContent);
                    $currentContent .= '
                    <!-- wp:nextpage -->
                    <!--nextpage-->
                    <!-- /wp:nextpage -->';
                }
                $block['innerBlocks'] = parse_blocks($currentContent);
                $result = $this->innerBlocks($block);
            }else {
                $result = '';
@@ -723,31 +957,118 @@
    //core_post_date
    public function prerender_core_post_date(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'post date');
//      return null;
        $attrs = $block['attrs']??[];
        $postDate = null;
        $itemProp = 'datePublished';
        $format = array_key_exists('format', $attrs) ? $attrs['format'] : 'M d, Y';
        $dateFormat = null;
        if (array_key_exists('displayType', $attrs)) {
            switch ($attrs['displayType']) {
                case 'displayType':
                    $postDate = get_post_modified_time('c');
                    $dateFormat = get_post_modified_time($format);
                    $itemProp = 'dateModified';
                    break;
            }
        }
        $postDate = is_null($postDate) ? get_the_date('c') : $postDate;
        $dateFormat = is_null($dateFormat) ? get_the_date($format) : $dateFormat;
        $aOpen = $aClose = '';
        if ($this->checkAttrs('isLink', $attrs) && !is_singular()) {
            $aOpen = sprintf(
                '<a href="%s">',
                get_the_permalink()
            );
            $aClose = '</a>';
        }
//      jvbDump($parent, 'Parent');
        $postDate = get_the_date('c');
        return '<time datetime="'.$postDate.'" itemprop="datePublished"'.$this->getClassesAndStyles($block['attrs']??[]).'>'.get_the_date().'</time>';
        return sprintf(
            '<time datetime="%s" itemprop="%s"%s>%s%s%s</time>',
            $postDate,
            $itemProp,
            $this->getClassesAndStyles($attrs),
            $aOpen,
            $dateFormat,
            $aClose
        );
    }
    //core_post_excerpt
    public function prerender_core_post_excerpt(array $block, ?string $content, ?WP_Block $parent):?string
    {
        return wpautop(get_the_excerpt());
        $attrs = $block['attrs']??[];
        $moreText = array_key_exists('moreText', $attrs) ? $attrs['moreText'] : 'Read more '.jvbIcon('arrow-circle-right');
        $showMoreOnNewLine = !array_key_exists('showMoreOnNewLine', $attrs) || $this->checkAttrs('showMoreOnNewLine', $attrs);
//      jvbDump($block);
//      jvbDump($showMoreOnNewLine);
        $excerpt = array_filter(explode('<p>',wpautop(get_the_excerpt())));
        $classes = $this->getClassesAndStyles($attrs);
        $excerpt = array_map(function ($line) use ($classes) {
            return sprintf(
                '<p%s>%s',
                $classes,
                $line
            );
        }, $excerpt);
        if (!empty($moreText)) {
            if ($showMoreOnNewLine) {
                $excerpt[] = sprintf(
                    '<p%s><a href="%s" class="read-more">%s</a></p>',
                    $classes,
                    get_the_permalink(),
                    $moreText
                );
            } else {
                $last = array_key_last($excerpt);
                $excerpt[$last] = str_replace('</p>', sprintf('<a href="%s" class="read-more">%s</a>',
                get_the_permalink(),
                $moreText), $excerpt[$last]);
            }
        }
        return implode('',$excerpt);
    }
    public function prerender_core_post_featured_image(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'featured image');
//      jvbDump($parent, 'Parent');
        global $post;
        $attrs = $block['attrs']??[];
        $ID = get_post_thumbnail_id($post->ID);
        $aOpen = $aClose = '';
        if(!is_single($post->ID)) {
        $aspectRatio = $aOpen = $aClose = '';
        if(!is_single($post->ID) && $this->checkAttrs('isLink', $attrs)) {
            $aOpen = '<a href="'.get_the_permalink($post->ID).'">';
            $aClose = '</a>';
        }
        if (array_key_exists('aspectRatio', $attrs)) {
            $aspectRatio = $attrs['aspectRatio'];
        }
        return '<figure'.$this->getClassesAndStyles($block['attrs']??[]).'>'.$aOpen.
               apply_filters('jvbCoreFeaturedImage', $this->image($ID), $post->post_type).
                $aClose.'</figure>';
        $img = apply_filters('jvbCoreFeaturedImage', '', $post->post_type, $attrs);
        if (empty($img)) {
            $img = $this->image($ID);
            $img = empty($aspectRatio) ? $img : str_replace('<img', '<img style="aspect-ratio:'.$aspectRatio.';"', $img);
        }
        return !empty($img) ? sprintf(
            '<figure%s>%s%s%s</figure>',
            $this->getClassesAndStyles($attrs),
            $aOpen,
            $img,
            $aClose,
        ):'';
    }
    //core_post_navigation_link
    public function prerender_core_post_navigation_link(array $block, ?string $content, ?WP_Block $parent):?string
@@ -801,23 +1122,27 @@
        );
    }
    //core_post_template
    public function render_core_post_template(array $block, string $content):string
    public function prerender_core_post_template(array $block, ?string $content):?string
    {
        global $wp_query;
        $inner = '';
        $block['innerBlocks'][0]['attrs']['tagName'] = 'li';
        if ($wp_query->have_posts()) {
            while ($wp_query->have_posts()) {
                $wp_query->the_post();
                $inner .= $this->innerBlocks($block);
            }
            wp_reset_postdata();
        if (!static::$currentLoop) {
            jvbDump('No loop stored');
            return $content;
        }
        if (static::$currentLoop->have_posts()) {
            while (static::$currentLoop->have_posts()) {
                static::$currentLoop->the_post();
                $inner .= sprintf(
                    '<li>%s</li>',
                    $this->innerBlocks($block, '','',$block)
                );
            }
        }
        return sprintf(
            '<ul class="loop">%s</ul>',
            '<ul%s>%s</ul>',
            $this->getClassesAndStyles($block['attrs']??[], ['loop']),
            $inner
        );
    }
@@ -828,16 +1153,17 @@
            return '';
        }
        $terms = get_the_terms(get_the_ID(), $block['attrs']['term']);
        $attrs = $block['attrs']??[];
        $out = '';
        if ($terms && !is_wp_error($terms)) {
            $out = sprintf(
                '<ul%s>',
                $this->getClassesAndStyles($block['attrs'], ['term-list', 'row', 'start'])
                $this->getClassesAndStyles($attrs, ['term-list', 'row', 'left'])
            );
                if (array_key_exists('prefix', $block['attrs']??[])) {
                if (array_key_exists('prefix', $attrs)) {
                    $out .= sprintf(
                        '<li class="prefix">%s</li>',
                        $block['attrs']['prefix']
                        $attrs['prefix']
                    );
                }
                foreach($terms as $term) {
@@ -847,10 +1173,10 @@
                        html_entity_decode($term->name)
                    );
                }
            if (array_key_exists('suffix', $block['attrs'])) {
            if (array_key_exists('suffix', $attrs)) {
                $out .= sprintf(
                    '<li class="suffix">%s</li>',
                    $block['attrs']['suffix']
                    $attrs['suffix']
                );
            }
            $out .= '</ul>';
@@ -860,145 +1186,388 @@
    //core_post_time_to_read
    public function prerender_core_post_title(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'post content');
//      jvbDump($parent, 'Parent');
        $open = $close = '';
        if (array_key_exists('isLink', $block['attrs']??[])) {
            $rel = (array_key_exists('rel', $block['attrs']??[])) ?
        $attrs = $block['attrs']??[];
        if ($this->checkAttrs('isLink', $attrs)) {
            $rel = (array_key_exists('rel', $attrs)) ?
                ' rel="'.$block['attrs']['rel'].'"' :
                '';
            $target = (array_key_exists('linkTarget', $block['attrs']??[])) ?
            $target = (array_key_exists('linkTarget', $attrs)) ?
                ' target="'.$block['attrs']['linkTarget'].'"' :
                '';
            $open = '<a href="' . get_the_permalink() . '"' . $rel . $target . '>';
            $close = '</a>';
        }
        if (is_singular(BASE.'partner')) {
            $open .= '<small>edmonton.ink partner:</small> ';
        }
        $level = (array_key_exists('attrs', $block) &&
                  array_key_exists('level', $block['attrs'])) ?
            $block['attrs']['level'] :
            2;
        return '<h'.$level.$this->getClassesAndStyles($block['attrs']??[]).'>'.
               $open.get_the_title().$close.
               '</h'.$level.'>';
        $level = $attrs['level']??2;
        $title = (!static::$currentLoop && !is_singular())
            ? get_the_title(get_queried_object_id())
            : get_the_title();
        return sprintf(
            '<h%s%s>%s%s%s</h%s>',
            $level,
            $this->getClassesAndStyles($attrs),
            $open,
            $title,
            $close,
            $level
        );
    }
    public function render_core_query(array $block, string $content): string
    public function prerender_core_query(array $block, ?string $content):?string
    {
        global $wp_query;
        $inherit = $block['attrs']['inherit'] ?? false;
//      $queryID = $block['attrs']['queryId'] ?? null;
//      $inherit = $block['attrs']['inherit'] ?? false;
//
//      if ($inherit) {
//          global $wp_query;
//          $loop = $wp_query;
//      } else {
//          $args = [];
//          foreach (($block['attrs']['query'] ?? []) as $key => $value) {
//              if (empty($value)) {
//                  continue;
//              }
//              switch ($key) {
//                  case 'postType':
//                      if ($value === BASE.'progress'){
//                          $args['post_parent'] = 0;
//                      }
//                      $args['post_type'] = $value;
//                      break;
//                  case 'perPage':
//                      $args['posts_per_page'] = $value;
//                      break;
//                  case 'orderBy':
//                      $args['orderby'] = $value;
//                      break;
//                  case 'taxQuery':
//                      $taxQuery = [];
//                      foreach ($value as $tax => $terms) {
//                          $taxQuery[] = [
//                              'taxonomy'  => $tax,
//                              'terms'     => $terms
//                          ];
//                      }
//                      if (!empty($taxQuery)) {
//                          $args['tax_query'] = $taxQuery;
//                          if (count($taxQuery) > 1) {
//                              $args['tax_query']['relation'] = 'OR';
//                          }
//                      }
//                      break;
//                  case 'sticky':
//                      if ($value === 'ignore') {
//                          $args['ignore_sticky_posts'] = true;
//                      } else if ($value === 'exclude'){
//                          $args['post__not_in'] = get_option('sticky_posts');
//                      } else if ($value === 'only') {
//                          $args['include'] = get_option('sticky_posts');
//                      }
//                      break;
//                  case 'search':
//                      $args['s'] = $value;
//                      break;
//                  default:
//                      $args[$key] = $value;
//                      break;
//
//              }
//          }
//          $search = 'query-' . $queryID;
//          foreach ($_GET as $key => $value) {
//              if (str_contains($key, $search)) {
//                  $key = str_replace($search, '', $key);
//                  if ($key === 'page') {
//                      $args['paged'] = (int)$value;
//                  }
//              }
//          }
//          $loop = new WP_Query($args);
//      }
//
//      $inner = '';
//      foreach ($block['innerBlocks'] as $innerBlock) {
//          switch ($innerBlock['blockName']) {
//              case 'core/post-template':
//                  $inner .= '<section class="item-grid">';
//                  if ($loop->have_posts()) {
//                      while ($loop->have_posts()) {
//                          $loop->the_post();
//                          $postType = get_post_type();
//                          $inner .= '<div class="item ' . jvbNoBase($postType) . '">' . $this->innerBlocks($innerBlock) . '</div>';
//                      }
//                  }
//                  $inner .= '</section>';
//                  break;
//          }
//      }
//
//      // Reset only after a custom query, not the main query
//      if (!$inherit) {
//          wp_reset_postdata();
//      }
        if ($inherit) {
            static::$currentLoop = $wp_query;
        } else {
            static::$currentLoop = new WP_Query($this->buildQueryArgs($block['attrs']));
        }
        static::$currentQueryId = $block['attrs']['queryId'] ?? null;
        $tagName = $block['attrs']['tagName'] ?? 'div';
//      return sprintf(
//          '<%s class="loop">%s</%s>',
//          $tagName,
//          $this->innerBlocks($block),
//          $tagName
//      );
        return $this->innerBlocks($block);
        static::$originalQuery = $wp_query;
        $inside = $this->innerBlocks($block);
        if (str_contains($inside, 'loop')) {
            $classes = $this->getClassesAndStyles($block['attrs'] ?? [], ['loop']);
            $classes = str_replace(' class="', '', $classes);
            $classes = strtok($classes, '"');
            $inside = str_replace('loop', $classes, $inside);
        }
        static::$currentQueryId = null;
        static::$currentLoop = null;
        wp_reset_postdata();
        return $inside;
    }
//  public function render_core_query(array $block, string $content): string
//  {
//      $inside = $this->innerBlocks($block);
//      if (str_contains($inside, 'loop')) {
//          $classes = $this->getClassesAndStyles($block['attrs']??[], ['loop']);
//          $classes = str_replace(' class="', '', $classes);
//          $classes = strtok($classes, '"');
//
//          $inside = str_replace('loop', $classes, $inside);
//      }
//      return $inside;
//  }
        protected function buildQueryArgs(array $attrs): array
        {
            $queryID = $attrs['queryId'] ?? null;
            $args = [];
            foreach (($attrs['query'] ?? []) as $key => $value) {
                if (empty($value)) continue;
                switch ($key) {
                    case 'postType':   $args['post_type']       = $value; break;
                    case 'perPage':    $args['posts_per_page']  = $value; break;
                    case 'orderBy':    $args['orderby']         = $value; break;
                    case 'sticky':
                        match ($value) {
                            'ignore'  => $args['ignore_sticky_posts'] = true,
                            'exclude' => $args['post__not_in'] = get_option('sticky_posts'),
                            'only'    => $args['post__in']     = get_option('sticky_posts'),
                            default   => null
                        };
                        break;
                    case 'taxQuery':
                        $taxQuery = array_map(fn($tax, $terms) => [
                            'taxonomy' => $tax, 'terms' => $terms
                        ], array_keys($value), $value);
                        if (count($taxQuery) > 1) $taxQuery['relation'] = 'OR';
                        $args['tax_query'] = $taxQuery;
                        break;
                    case 'search':     $args['s']    = $value; break;
                    default:           $args[$key]   = $value; break;
                }
            }
            // Handle pagination from query string
            $search = 'q-' . $queryID.'-';
            foreach ($_GET as $key => $value) {
                if (str_contains($key, $search) && str_replace($search, '', $key) === 'page') {
                    $args['paged'] = (int)$value;
                }
            }
            return $args;
        }
        protected function buildPaginationUrl(int $page): string
        {
            $param = 'q-' . static::$currentQueryId . '-page';
            $url = remove_query_arg($param);
            return $page > 1 ? add_query_arg($param, $page, $url) : $url;
        }
        protected function getCurrentPage(): int
        {
            $param = 'q-' . static::$currentQueryId . '-page';
            return isset($_GET[$param]) ? (int)$_GET[$param] : 1;
        }
//  public function render_core_query(array $block, string $content): string
//  {
//
////        $queryID = $block['attrs']['queryId'] ?? null;
////        $inherit = $block['attrs']['inherit'] ?? false;
////
////        if ($inherit) {
////            global $wp_query;
////            $loop = $wp_query;
////        } else {
////            $args = [];
////            foreach (($block['attrs']['query'] ?? []) as $key => $value) {
////                if (empty($value)) {
////                    continue;
////                }
////                switch ($key) {
////                    case 'postType':
////                        if ($value === BASE.'progress'){
////                            $args['post_parent'] = 0;
////                        }
////                        $args['post_type'] = $value;
////                        break;
////                    case 'perPage':
////                        $args['posts_per_page'] = $value;
////                        break;
////                    case 'orderBy':
////                        $args['orderby'] = $value;
////                        break;
////                    case 'taxQuery':
////                        $taxQuery = [];
////                        foreach ($value as $tax => $terms) {
////                            $taxQuery[] = [
////                                'taxonomy'  => $tax,
////                                'terms'     => $terms
////                            ];
////                        }
////                        if (!empty($taxQuery)) {
////                            $args['tax_query'] = $taxQuery;
////                            if (count($taxQuery) > 1) {
////                                $args['tax_query']['relation'] = 'OR';
////                            }
////                        }
////                        break;
////                    case 'sticky':
////                        if ($value === 'ignore') {
////                            $args['ignore_sticky_posts'] = true;
////                        } else if ($value === 'exclude'){
////                            $args['post__not_in'] = get_option('sticky_posts');
////                        } else if ($value === 'only') {
////                            $args['include'] = get_option('sticky_posts');
////                        }
////                        break;
////                    case 'search':
////                        $args['s'] = $value;
////                        break;
////                    default:
////                        $args[$key] = $value;
////                        break;
////
////                }
////            }
////            $search = 'query-' . $queryID;
////            foreach ($_GET as $key => $value) {
////                if (str_contains($key, $search)) {
////                    $key = str_replace($search, '', $key);
////                    if ($key === 'page') {
////                        $args['paged'] = (int)$value;
////                    }
////                }
////            }
////            $loop = new WP_Query($args);
////        }
////
////        $inner = '';
////        foreach ($block['innerBlocks'] as $innerBlock) {
////            switch ($innerBlock['blockName']) {
////                case 'core/post-template':
////                    $inner .= '<section class="item-grid">';
////                    if ($loop->have_posts()) {
////                        while ($loop->have_posts()) {
////                            $loop->the_post();
////                            $postType = get_post_type();
////                            $inner .= '<div class="item ' . jvbNoBase($postType) . '">' . $this->innerBlocks($innerBlock) . '</div>';
////                        }
////                    }
////                    $inner .= '</section>';
////                    break;
////            }
////        }
////
////        // Reset only after a custom query, not the main query
////        if (!$inherit) {
////            wp_reset_postdata();
////        }
//
//      $tagName = $block['attrs']['tagName'] ?? 'div';
////        return sprintf(
////            '<%s class="loop">%s</%s>',
////            $tagName,
////            $this->innerBlocks($block),
////            $tagName
////        );
//      return $this->innerBlocks($block);
//  }
    //core_query_no_results
    public function prerender_core_query_no_results(array $block, ?string $content):?string
    {
        if (!static::$currentLoop || static::$currentLoop->have_posts()) {
            return '';
        }
        $inside = $this->innerBlocks($block);
        return empty($inside) ? '' : sprintf(
            '<div%s>%s</div>',
            $this->getClassesAndStyles($block['attrs']??[], ['no-results']),
            $inside
        );
    }
    //core_query_pagination
    public function prerender_core_query_pagination(array $block, ?string $content):?string
    {
        return sprintf(
            '<nav%s>%s</nav>',
            $this->getClassesAndStyles($block['attrs']??[], ['pagination', 'condensed','btw']),
            $this->innerBlocks($block)
        );
    }
    //core_query_pagination_next
    public function prerender_core_query_pagination_next(array $block, ?string $content, ?WP_Block $parent):?string
    {
        if (!static::$currentLoop) return '';
        $currentPage = $this->getCurrentPage();
        $maxPages = static::$currentLoop->max_num_pages;
        if ($currentPage >= $maxPages) return '';
        $nextLabel = $rArrow = '';
        $type = get_post_type_object(get_post_type())->label;
        if ($parent) {
            $attrs = $parent->attributes;
            if (array_key_exists('paginationArrow', $attrs)){
                $rArrow = match($attrs['paginationArrow']) {
                    'chevron'   => jvbIcon('caret-circle-right'),
                    default => jvbIcon('arrow-circle-right')
                };
            }
            if (!array_key_exists('showLabel', $attrs) || $attrs['showLabel'] === true) {
                $nextLabel = 'Next '.$type;
            }
        } else {
            $rArrow = jvbIcon('caret-circle-right');
        }
        $aOpen = sprintf(
            '<a class="nav next" href="%s" title="Next %s">',
            $this->buildPaginationUrl($currentPage + 1),
            $type
        );
        $aClose = '</a>';
        return sprintf(
            '%s%s%s%s',
            $aOpen,
            $nextLabel,
            $rArrow,
            $aClose
        );
    }
    //core_query_pagination_numbers
    public function prerender_core_query_pagination_numbers(array $block, ?string $content):?string
    {
        if (!static::$currentLoop) return '';
        $currentPage = $this->getCurrentPage();
        $maxPages = (int)static::$currentLoop->max_num_pages;
        $attrs = $block['attrs']??[];
        if ($maxPages <= 1) return '';
        $midSize = $attrs['midSize'] ?? 2;
        $endSize = 1;
        $items = '';
        $gap = false;
        for ($i = 1; $i <= $maxPages; $i++) {
            if (($i <= min($endSize + 1, $maxPages)) ||
                ($i >= max(1, $currentPage - $midSize) && $i <= min($maxPages, $currentPage + $midSize)) ||
                ($i >= max(1, $maxPages - $endSize) && $i <= $maxPages)) {
                $gap = true;
                $items .= ($i === $currentPage)
                    ? sprintf('<li aria-current="page" class="current">%d</li>', $i)
                    : sprintf('<li><a href="%s">%d</a></li>', $this->buildPaginationUrl($i), $i);
            } elseif ($gap) {
                $gap = false;
                $items .= sprintf(
                    '<li class="dots"><span>%s</span></li>',
                    jvbIcon('dots-three')
                );
            }
        }
        return sprintf('<ul%s>%s</ul>',
            $this->getClassesAndStyles($attrs, ['row', 'nowrap']),
            $items
        );
    }
    //core_query_pagination_previous
    public function prerender_core_query_pagination_previous(array $block, ?string $content, ?WP_Block $parent):?string
    {
        if (!static::$currentLoop) return '';
        $currentPage = $this->getCurrentPage();
        $maxPages = static::$currentLoop->max_num_pages;
        if ($currentPage <= 1) return '';
        $nextLabel = $rArrow = '';
        $type = get_post_type_object(get_post_type())->label;
        if ($parent) {
            $attrs = $parent->attributes;
            if (array_key_exists('paginationArrow', $attrs)){
                $rArrow = match($attrs['paginationArrow']) {
                    'chevron'   => jvbIcon('caret-circle-left'),
                    default => jvbIcon('arrow-circle-left')
                };
            }
            if (!array_key_exists('showLabel', $attrs) || $attrs['showLabel'] === true) {
                $nextLabel = 'Previous '.$type;
            }
        } else {
            $rArrow = jvbIcon('caret-circle-left');
        }
        $aOpen = sprintf(
            '<a class="nav prev" href="%s" title="Previous %s">',
            $this->buildPaginationUrl($currentPage - 1),
            $type
        );
        $aClose = '</a>';
        return sprintf(
            '%s%s%s%s',
            $aOpen,
            $nextLabel,
            $rArrow,
            $aClose
        );
    }
    public function prerender_core_query_title(array $block, ?string $content, ?WP_Block $parent):?string
    {
        jvbDump($block);
        jvbDump($block, 'query title');
        $attr = $block['attrs'];
        $name = '';
        $showPrefix = $attr['showPrefix']??false;
@@ -1050,7 +1619,7 @@
                    $before = apply_filters('jvbAboveHeader', '');
                    if (!empty($before)) {
                        $before = sprintf(
                            '<aside class="pre header row btw">%s</aside>',
                            '<aside class="pre header row x-btw">%s</aside>',
                            $before
                        );
                    }
@@ -1059,7 +1628,7 @@
                    $after = apply_filters('jvbBelowHeader', $after);
                    if (!empty($after)) {
                        $after = sprintf(
                            '<aside class="sub header row btw">%s</aside>',
                            '<aside class="sub header row x-btw">%s</aside>',
                            $after
                        );
                    }
@@ -1114,14 +1683,14 @@
//
//              $beforeHeader = apply_filters('jvbAboveHeader', $beforeHeader);
//              if ($beforeHeader !== '') {
//                  $beforeHeader = '<aside class="pre header row btw">'.$beforeHeader.'</aside>';
//                  $beforeHeader = '<aside class="pre header row x-btw">'.$beforeHeader.'</aside>';
//              }
//                $themeSwitch = jvbDarkModeToggle();
//                $breadcrumbs = BreadcrumbManager::getInstance()->renderNavigation();
//              $afterHeader = apply_filters('jvbBelowHeader', $afterHeader);
//
//              if ($afterHeader !== '') {
//                  $afterHeader = '<aside class="sub header row btw">'.$afterHeader.'</aside>';
//                  $afterHeader = '<aside class="sub header row x-btw">'.$afterHeader.'</aside>';
//              }
//              $footerText = '<div class="scroll-progress"><div class="bar"></div>
//</div>';
@@ -1149,21 +1718,325 @@
     * Widgets Blocks
     */
    //core_archives
    public function render_core_archives(array $block, string $content):string
    {
        jvbDump($block, 'archives');
        $attrs = $block['attrs']??[];
        $isDropdown = $this->checkAttrs('displayAsDropdown', $attrs);
        $replace = strtok($content,'>').'>';
        $content = str_replace($replace, '', $content);
        if ($isDropdown) {
            $content = sprintf(
                '<div%s>%s',
                $this->getClassesAndStyles($attrs, ['archive dropdown']),
                $content
            );
        } else {
            $content = sprintf(
                '<ul%s>%s',
                $this->getClassesAndStyles($attrs, ['archive-list']),
                $content
            );
        }
        return $content;
    }
    //core_calendar
    public function render_core_calendar(array $block, string $content):string
    {
        $content = $this->inside($block, false, $content);
        $replace = strtok($content, '>').'>';
        $content = str_replace($replace, '', $content);
        return sprintf(
            '<table%s>%s',
            $this->getClassesAndStyles($block['attrs']??[], ['calendar']),
            $content
        );
    }
    //core_categories
    public function prerender_core_categories(array $block, ?string $content, ?WP_Block $parent):?string
    {
        $attrs = $block['attrs']??[];
        $args = [
            'taxonomy'      => 'category',
            'hide_empty'    => !$this->checkAttrs('showEmpty', $attrs)
        ];
        $showHierarchy = $this->checkAttrs('showHierarchy', $attrs);
        if ($this->checkAttrs('showOnlyTopLevel', $attrs) || $showHierarchy){
            $args['parent'] = 0;
        }
        $terms = $this->getTerms($args, $showHierarchy);
        if (!$terms){
            return '';
        }
        $showPostCounts = $this->checkAttrs('showPostCounts', $attrs);
        $isDropdown = $this->checkAttrs('displayAsDropdown', $attrs);
        if ($isDropdown) {
            $this->counter('core_categories');
        }
        $tax = get_taxonomy($args['taxonomy']);
        $taxonomyName = $tax->label??'Categories';
        $taxonomySingular = $tax->labels->singular_name??'Category';
        $inner = $this->buildTermList($terms, $taxonomyName, $taxonomySingular, $isDropdown, $showPostCounts);
        if ($isDropdown) {
            return sprintf(
                '<div%s>%s</div>',
                $this->getClassesAndStyles($attrs, ['taxonomy-dropdown']),
                $inner
            );
        }
        return sprintf(
            '<ul%s>%s</ul>',
            $this->getClassesAndStyles($attrs, ['taxonomy-list', jvbNoBase($args['taxonomy'])]),
            $inner
        );
    }
        public function getTerms(array $args, bool $showHierarchy = false):array|false
        {
            $terms = get_terms($args);
            if (!$terms || is_wp_error($terms)) {
                return false;
            }
            $terms = array_map(function ($term) {
                return (array) $term;
            }, $terms);
            if ($showHierarchy) {
                $terms = array_map(function ($term) use ($args) {
                    $args['parent'] = $term['term_id'];
                    $children = $this->getTerms($args, true);
                    $term['children'] = $children?:[];
                    return $term;
                }, $terms);
            }
            return $terms;
        }
        protected function buildTermList(array $terms, string $taxonomyName, string $taxonomySingular, bool $isDropdown, bool $showPostCounts, bool $isOpening = true, int $level = 0):string
        {
            $out = '';
            if ($isOpening) {
                $out = $isDropdown ?
                    sprintf(
                        '<label for="taxonomy-select-%s">%s</label>
                        <select name="%s_name" id="taxonomy-select-%s"><option value="">Select %s</option>',
                        static::$counters['core_categories'],
                        $taxonomyName,
                        str_replace('-', '_',sanitize_title(strtolower($taxonomyName))),
                        static::$counters['core_categories'],
                        $taxonomyName
                    ) :
                    '';
            } elseif (!$isDropdown) {
                $out .= '<ul>';
            }
            $prefix = '';
            if ($isDropdown) {
                $base = '&emsp;';
                for ($i = 1; $i <= $level; $i++) {
                    $prefix .= $base;
                }
                $prefix .= empty($prefix) ? '' : '- ';
            }
            $theTerms = array_map(function ($term) use ($taxonomyName, $taxonomySingular, $isDropdown, $showPostCounts, $prefix, $level) {
                if ($isDropdown) {
                    return sprintf(
                        '<option value="%s">%s%s%s</option>%s',
                        $term['slug'],
                        $prefix,
                        $term['name'],
                        $showPostCounts ? ' ('.$term['count'].')' : '',
                        empty($term['children']??[]) ? '' : $this->buildTermList($term['children'], $taxonomyName, $taxonomySingular, $isDropdown, $showPostCounts, false, $level+1)
                    );
                }
                return sprintf(
                    '<li><a href="%s">%s%s</a>%s</li>',
                    get_term_link($term['term_id']),
                    $term['name'],
                    $showPostCounts ? ' <span class="count">'.$term['count'].'</span>' : '',
                    empty($term['children']??[]) ? '' : $this->buildTermList($term['children'], $taxonomyName, $taxonomySingular, $isDropdown, $showPostCounts, false, $level+1)
                );
            }, $terms);
            $out .= implode('', $theTerms);
            if ($isOpening) {
                $out .= $isDropdown ?
                    '</select>' :
                    '';
            } else if (!$isDropdown) {
                $out .= '</ul>';
            }
            return $out;
        }
    //core_html
    //core_latest_comments
    //core_latest_posts
    public function prerender_core_latest_posts(array $block, ?string $content, ?WP_Block $parent):?string {
        $attrs = $block['attrs']??[];
//      jvbDump($block, 'latest posts');
        $args = [];
        $title = 'Latest Posts';
        $args['order'] = array_key_exists('order', $attrs) ? strtoupper($attrs['order']) : 'DESC';
        $args['orderby'] = array_key_exists('orderBy', $attrs) ? $attrs['orderBy'] : 'date';
        $args['posts_per_page'] = array_key_exists('postsToShow', $attrs) ? $attrs['postsToShow'] : 5;
        if (array_key_exists('categories', $attrs)) {
            $list = jvbCommaList(array_column($attrs['categories'], 'name'));
            $args['tax_query'] = [];
            $args['tax_query'][] = [
                'taxonomy'  => 'category',
                'terms'     => array_column($attrs['categories'], 'id')
            ];
            $title .= ' in '.$list;
        }
        $posts = new WP_Query($args);
        if (!$posts->have_posts()) {
            return '';
        }
        $posts = array_map(function ($post) use ($attrs) {
            $img = $this->checkAttrs('displayFeaturedImage', $attrs)
                ? $this->image(get_post_thumbnail_id($post->ID), 'tiny', 'thumbnail')
                : '';
            $author = $this->checkAttrs('displayAuthor', $attrs)
                ? sprintf(
                    '<a href="%s">%s</a>',
                    get_author_posts_url($post->post_author),
                    get_userdata($post->post_author)->display_name
                )
                : '';
            $date = $this->checkAttrs('displayPostDate', $attrs)
                ? sprintf(
                    '<time datetime="%s">%s</time>',
                    date('Y-m-d', strtotime($post->post_date)),
                    date_i18n('M j, Y', strtotime($post->post_date))
                )
                : '';
            $authorDate = $author;
            if (!empty($authorDate) && !empty($date)) {
                $authorDate .= ' | '.$date;
            } else if (!empty($date)) {
                $authorDate = $date;
            }
            $excerpt = '';
            if ($this->checkAttrs('displayPostContent', $attrs)) {
                if (array_key_exists('excerptLength', $attrs)) {
                    $excerpt = wp_trim_words(get_the_content($post->ID), $attrs['excerptLength'], '...');
                } else {
                    $excerpt = get_the_excerpt($post->ID);
                }
            }
            if (!empty($excerpt)) {
                $excerpt = wpautop($excerpt);
            }
            return sprintf(
                '<li>%s<p><a href="%s">%s</a>%s</p>%s</li>',
                $img,
                get_the_permalink($post->ID),
                $post->post_title,
                !empty($authorDate) ? ' <small>— '.$authorDate.'</small>' : '',
                $excerpt
            );
        }, $posts->posts);
        wp_reset_postdata();
        return sprintf(
            '<ul%s>%s</ul>',
//          $title,
            $this->getClassesAndStyles($attrs, ['post-list']),
            implode('', $posts)
        );
    }
    //core_page_list
    //core_page_list_item
    //core_rss
    public function prerender_core_page_list(array $block, ?string $content, ?WP_Block $parent):?string{
        $attrs = $block['attrs']??[];
        $parent = array_key_exists('parentPageID', $attrs) ? $attrs['parentPageID'] : 0;
        $pages = new WP_Query([
            'post_type'         => 'page',
            'posts_per_page'    => -1,
            'parent'            => $parent
        ]);
        if (!$pages->have_posts()) {
            return '';
        }
        $inside = [];
        foreach($pages->posts as $page) {
            jvbDump($page);
            $inside[] = sprintf(
                '<li><a href="%s">%s</a>',
                get_the_permalink($page->ID),
                $page->post_title
            );
        }
        wp_reset_postdata();
        return sprintf(
            '<ul%s>%s</ul>',
            $this->getClassesAndStyles($attrs, ['page-list']),
            implode('',$inside)
        );
    }
    //core_page_list_item (doesn't seem to be a thing)
//  public function prerender_core_page_list_item(array $block, ?string $content, ?WP_Block $parent):?string{
//      return $content;
//  }
    //core_
//  public function prerender_core_rss(array $block, ?string $content, ?WP_Block $parent):?string
//  {
//      jvbDump($block, 'rss');
//      return $content;
//  }
    //core_search
    public function prerender_core_search(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'search');
        $attrs = $block['attrs']??[];
        $label = array_key_exists('label', $attrs) && !empty($attrs['label']) ? $attrs['label'] : '';
        if (array_key_exists('showLabel', $attrs) && $attrs['showLabel'] === false) {
            $label = '';
        }
        $placeholder = array_key_exists('placeholder', $attrs) ? $attrs['placeholder'] : 'Search...';
        $buttonText = array_key_exists('buttonText', $attrs) && !empty($attrs['buttonText']) ? $attrs['buttonText'] : '';
        $isInside = array_key_exists('buttonPosition', $attrs) && $attrs['buttonPosition'] === 'button-inside';
        $hideInput = $this->checkAttrs('isSearchFieldHidden', $attrs) || (array_key_exists('buttonPosition', $attrs) && $attrs['buttonPosition'] === 'button-only');
        return str_replace('<div class="search-container row left nowrap"', sprintf(
            '<div%s',
            $this->getClassesAndStyles($attrs, ['search-container', 'row', 'left', 'nowrap'])
        ), jvbSearch($placeholder, uniqid(), $label, $buttonText, $isInside, $hideInput));
    }
    //core_shortcode
    public function prerender_core_social_link(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'social link');
//      jvbDump($parent, 'Parent');
        $parentAttrs = false;
        if ($parent) {
            $parentAttrs = $parent->attributes;
        }
        $attrs = $block['attrs']??[];
        $url = $attrs['url']??'';
        $service = $attrs['service']?:'';
@@ -1172,16 +2045,100 @@
        if (!$icon) {
            $icon = jvbIcon('link');
        }
        return '<li><a href="'.$url.'" target="_blank" rel="nofollow" title="Find us on '.ucfirst($service).'">'.$icon.'<span class="screen-reader-text">Find us on '.ucfirst($service).'</span></a></li>';
        $serviceName = $this->getServiceName($service);
        $label = $parentAttrs && (!array_key_exists('className', $parentAttrs) || !str_contains($parentAttrs['className'], 'logos-only'))
                ? sprintf(
                    '<span>%s</span>',
                    $serviceName
                )
                : sprintf(
                    '<span class="screen-reader-text">Find us on %s</span>',
                $serviceName
            );
        $pillShaped = $parentAttrs && (array_key_exists('className', $parentAttrs) && str_contains($parentAttrs['className'], 'pill-shape'))
            ? 'style="border-radius:var(--radius-outer);"'
            : '';
        return sprintf(
            '<li><a href="%s" target="_blank" rel="nofollow" title="Find us on %s"%s>%s%s</a></li>',
            $url,
            $serviceName,
            $pillShaped,
            $icon,
            $label
        );
    }
        private function getServiceName(string $service) {
            return match($service){
                'wordpress' => 'WordPress',
                default => ucfirst($service)
            };
        }
    public function prerender_core_social_links(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'social links');
//      jvbDump($block['attrs']??[], 'social links');
//      jvbDump($parent, 'Parent');
        return '<ul class="socials">'.$this->innerBlocks($block).'</ul>';
        return sprintf(
            '<ul%s>%s</ul>',
            $this->getClassesAndStyles($block['attrs']??[], ['socials']),
            $this->innerBlocks($block, '','',$block)
        );
    }
    //core_tag_cloud
    public function prerender_core_tag_cloud(array $block, ?string $content, ?WP_Block $parent):?string
    {
//      jvbDump($block, 'tag cloud');
        $attrs = $block['attrs']??[];
        $taxonomy = (array_key_exists('taxonomy', $attrs) && !empty($attrs['taxonomy']))
            ? $attrs['taxonomy']
            : 'post_tag';
        $showCounts = $this->checkAttrs('showTagCounts', $attrs);
        $terms = get_terms([
            'taxonomy'      => $taxonomy,
            'hide_empty'    => true,
        ]);
        if (!$terms || is_wp_error($terms)) {
            return '';
        }
        $inside = '';
        foreach ($terms as $term) {
            $url = get_term_link($term->term_id, $taxonomy);
            $count = $showCounts ?
                sprintf(
                    '<span class="count">%d</span>',
                    $term->count
                ) :
                '';
            $size = match(true) {
                $term->count <= 2 => 'small',
                $term->count <= 5 => 'x-small',
                $term->count <= 10 => 'medium',
                $term->count <= 15 => 'x-medium',
                $term->count <= 20 => 'large',
                $term->count <= 25 => 'x-large',
                $term->count <= 30 => 'xx-large',
                $term->count > 30 => 'xxx-large',
            };
            $fontSize = 'font-size: var(--txt-'.$size.');';
            $inside .= sprintf(
                '<li class="%s");"><a href="%s" rel="tag">%s%s</a></li>',
                $size,
//              $fontSize,
                $url,
                $term->name,
                $count
            );
        }
        return sprintf(
            '<ul%s>%s</ul>',
            $this->getClassesAndStyles($attrs, ['term-list','cloud', jvbNoBase($taxonomy)]),
            $inside
        );
    }
    /**
@@ -1208,13 +2165,21 @@
        return trim($clean);
    }
    public function innerBlocks(array $block, string $before = '', string $after = '', bool $prerender = true):string
    public function innerBlocks(array $block, string $before = '', string $after = '', ?array $parent = null):string
    {
        if ($parent) {
            $parent = new WP_Block($parent);
        }
        $content = '';
        foreach ($block['innerBlocks'] as $b) {
            $rendered = $parent
                ? $this->checkMethods(null, $b, $parent, true)
                : render_block($b);
            $content .= sprintf('%s%s%s',
                $before,
                render_block($b),
                $rendered,
                $after
            );
        }
@@ -1392,15 +2357,36 @@
            $classes[] = 'col';
        }
        // Merge with passed classes and styles
        $styles = array_merge($attr_styles, $styles);
        $classes = array_merge($attr_classes, $classes);
        if (!empty(static::$pendingClass)) {
            $classes = array_merge($classes, static::$pendingClass);
            static::$pendingClass = [];
        }
        $classes = array_unique($classes);
        $data = $this->getDataset($attrs);
        // Build attribute strings
        $class_string = !empty($classes) ? ' class="' . implode(' ', $classes) . '"' : '';
        $style_string = !empty($styles) ? ' style="' . implode(';', $styles) . '"' : '';
        $data_string = '';
        if (!empty($data)) {
            foreach ($data as $d => $v) {
                if ($d === 'bg-small') {
                    $data_string .= ' data-bg-img';
                }
                $data_string .= sprintf(
                    ' data-%s="%s"',
                    $d,
                    $v
                );
            }
        }
        $return = trim($class_string . $style_string);
        $return = trim($class_string . $style_string . $data_string);
        return ($return=='')? '' : ' '.$return;
    }
    /**
@@ -1430,18 +2416,18 @@
        $classes = [];
        foreach ($attrs as $key => $value) {
            $class = $this->getClass($key, $value, $attrs);
            if (is_array($class)) {
                $classes = array_merge($classes, $class);
            } else {
                $classes[] = $class;
            }
            if (is_string($class)) {
                $class = explode(' ', $class);
            }
            $classes = array_merge($classes, $class);
        }
        return array_filter($classes, function ($class) {
        return array_unique(array_filter($classes, function ($class) {
            return $class!=='' && !str_starts_with($class, 'wp');
        });
        }));
    }
    protected function getClass(string $key, string|bool|array|int $value, array $attrs):string|array
    {
        //TODO: gradient
        switch ($key) {
            //Any additional classes the user adds
            case 'className':
@@ -1451,112 +2437,38 @@
                    default => str_replace('is-style-', '', $value),
                };
            case 'contentPosition':
                $classes = [];
                $pos = explode(' ', $value);
                foreach($pos as $p) {
                    switch ($p) {
                        case 'top':
                            $classes[] = 'a-start';
                            break;
                        case 'right':
                            $classes[] = 'end';
                            break;
                        case 'bottom':
                            $classes[] = 'a-end';
                            break;
                        case 'left':
                            $classes[] = 'start';
                            break;
                    }
                }
                return implode(' ', $classes);
                return $this->getContentPosition($value);
            case 'term':
            case 'taxonomy':
                return jvbNoBase($value);
            //Layout attributes
            case 'layout':
                $classes = [];
                $type = 'row';
                if (array_key_exists('type', $value)) {
                    $type = 'col';
//                    if ($value['type'] === 'constrained') {
//                        $classes[] = 'container col';
//                    }
                }
                if (array_key_exists('orientation', $value)) {
                    $type = 'col';
                    if ($value['orientation'] === 'vertical') {
                        $classes[] = 'col';
                        if (in_array('row', $classes)) {
                            $index = array_search('row', $classes);
                            unset($classes[$index]);
                        }
                    }
                }else if (array_key_exists('type', $value) && $value['type'] === 'flex') {
                    $classes[] = 'row';
                    if (in_array('col', $classes)) {
                        $index = array_search('col', $classes);
                        unset($classes[$index]);
                    }
                }
//jvbDump($type);
//jvbDump($value);
//              $check = [$value, $attrs];
//              foreach ($check as $ch) {
//
//              }
                if (!array_key_exists('justifyContent', $value) && !array_key_exists('contentPosition', $attrs)) {
                    $classes[] = ($type === 'row') ? 'start' : 'a-start';
                }
                if (array_key_exists('justifyContent', $value)  && !array_key_exists('contentPosition', $attrs)) {
                    if (in_array($value['justifyContent'], ['left', 'right','space-between'])) {
//                      jvbDump($type);
                        switch ($value['justifyContent']) {
                            case 'right':
                                $classes[] = ($type === 'row') ? 'end' : 'a-end';
                                break;
                            case 'space-between':
                                $classes[] = 'btw';
                                break;
                        }
                    }
                }
                if (array_key_exists('flexWrap', $value)) {
                    if ($value['flexWrap'] === 'nowrap') {
                        $classes[] = 'nowrap';
                    }
                }
                return implode(' ', $classes);
                return $this->getLayout($value, $attrs);
            case 'align':
                return !empty($value) ? 'align-'.$value : '';
            case 'verticalAlignment':
                return !empty($value) ? 'v-align-'.$value : '';
            case 'isStackedMobile':
                switch ($value) {
                    case 'bottom':
                        $value = 'btm';
                        break;
                    case 'center':
                        $value = 'y-mid';
                    default:
                }
                return !empty($value) ? $value : '';
            case 'isStackedOnMobile':
                return ($value === true) ? 'stack-small' : '';
            case 'justifyContent':
                return !empty($value) ? 'j-'.$value : '';
            case 'orientation':
                return $value==='column' ? 'column' : '';
            case 'width':
                return $this->getWidth($value);
            case 'dimRatio':
                if (is_numeric($value)) {
                    $width = match (true) {
                        $value < 25 => '25',
                        $value < 33 => '33',
                        $value <= 50 => '50',
                        $value < 66 => '66',
                        $value < 75 => '75',
                        default => 'full',
                    };
                    switch ($key) {
                        case 'width':
                            return 'width-'.$width;
                        case 'dimRatio':
                            return 'overlay-'.$width;
                    }
                }
                return '';
                return $this->getDimRatio($value);
            case 'overlayColor':
                return $value;
                break;
            //Typography
            case 'textAlign':
                return !empty($value) ? 'text-'.$value : '';
@@ -1571,171 +2483,285 @@
            //Style base:
            case 'style':
                $classes = [];
                //Margin and Padding
                if (array_key_exists('spacing', $value)) {
                    foreach (['margin' => 'm', 'padding'=>'p'] as $search => $c) {
                        if (array_key_exists($search, $value['spacing'])) {
                            $directions = [];
                            // Collect ONLY preset spacing values for classes
                            foreach ($value['spacing'][$search] as $direction => $size) {
                                $presetSize = $this->getPresetSpacing($size);
                                if ($presetSize) {
                                    $directions[$direction] = $presetSize;
                                }
                                // Non-preset values are skipped here and handled by inline styles below
                            }
                            if (empty($directions)) {
                                continue;
                            }
                            // Check what directions we have
                            $hasTop = isset($directions['top']);
                            $hasBottom = isset($directions['bottom']);
                            $hasLeft = isset($directions['left']);
                            $hasRight = isset($directions['right']);
                            // Check if axes match
                            $xMatch = $hasLeft && $hasRight && $directions['left'] === $directions['right'];
                            $yMatch = $hasTop && $hasBottom && $directions['top'] === $directions['bottom'];
                            // All 4 directions exist and match → p-3
                            if ($hasTop && $hasBottom && $hasLeft && $hasRight &&
                                count(array_unique($directions)) === 1) {
                                $classes[] = $c . '-' . reset($directions);
                            }
                            // Both axes match → px-3 py-2
                            elseif ($xMatch && $yMatch) {
                                $classes[] = $c . 'x-' . $directions['left'];
                                $classes[] = $c . 'y-' . $directions['top'];
                            }
                            // Only X axis matches → px-3 (+ individual for top/bottom)
                            elseif ($xMatch) {
                                $classes[] = $c . 'x-' . $directions['left'];
                                if ($hasTop) {
                                    $classes[] = $c . 't-' . $directions['top'];
                                }
                                if ($hasBottom) {
                                    $classes[] = $c . 'b-' . $directions['bottom'];
                                }
                            }
                            // Only Y axis matches → py-3 (+ individual for left/right)
                            elseif ($yMatch) {
                                $classes[] = $c . 'y-' . $directions['top'];
                                if ($hasLeft) {
                                    $classes[] = $c . 'l-' . $directions['left'];
                                }
                                if ($hasRight) {
                                    $classes[] = $c . 'r-' . $directions['right'];
                                }
                            }
                            // No matches - individual directions
                            else {
                                foreach ($directions as $direction => $size) {
                                    $dir = match($direction) {
                                        'top' => 't',
                                        'bottom' => 'b',
                                        'left' => 'l',
                                        'right' => 'r',
                                        default => $direction
                                    };
                                    $classes[] = $c . $dir . '-' . $size;
                                }
                            }
                        }
                    }
                }
                if (array_key_exists('fontSize', $value)) {
                    if (in_array($value['fontSize'], ['small', 'large', 'extra-large', 'huge'])) {
                        $classes[] = 'font-'.$value['fontSize'];
                    }
                    if (in_array('fontWeight', $value)) {
                        $classes[] = 'text-'.$value['fontWeight'];
                    }
                    if (in_array('textTransform', $value)) {
                        if (in_array($value['textTransform'], ['uppercase', 'capitalize', 'lowercase'])) {
                            $classes[] = $value['textTransform'];
                        }
                    }
                }
                return implode(' ', $classes);
                return $this->getPresetStyles($value);
            case 'fontSize':
                $classes[] = 'font-'.$value;
                return implode(' ', $classes);
            case 'isStackedOnMobile':
                return ($value === true) ? 'stack-small' : '';
            case 'width':
                if (is_numeric($value)) {
                    $width = match (true) {
                        $value < 25 => '25',
                        $value < 33 => '33',
                        $value <= 50 => '50',
                        $value < 66 => '66',
                        $value < 75 => '75',
                        default => 'full',
                    };
                    switch ($key) {
                        case 'width':
                            return 'width-'.$width;
                        case 'dimRatio':
                            return 'overlay-'.$width;
                    }
            case 'postLayout':
                $classes[] = 'item-grid';
                if (isset($attrs['columns']) && $attrs['columns']!== 3){
                    $classes[] = sprintf(
                        'split-%d',
                        $attrs['columns']
                    );
                }
                return '';
                return $classes;
            default:
                $ignore = [
                    'useFeaturedImage',
                    'opacity',
                    'borderColor',
                    'backgroundColor',
                    'textColor',
                    'minHeight',
                    'minHeightUnit',
                    'isDark',
                    'sizeSlug',
                    'isUserOverlayColor',
                    'customOverlayColor',
                    'dimRatio',
                    'placeholder',
                    'alt',
                    'imageFill',
                    'mediaSizeSlug',
                    'isLink',
                    'kind',
                    'label',
                    'type',
                    'id',
                    'url',
                    'label',
                    'shouldSyncIcon',
                    'rel',
                    'opensInNewTab',
                    'title',
                    'ref',
                    'overlayMenu',
                    'slug',
                    'theme',
                    'tagName',
                    'level',
                    'ordered',
                    'area',
                    'mediaId',
                    'mediaLink',
                    'mediaType',
                    'height', //maybe still need?
                ];
                if (!is_admin() &&!in_array($key, $ignore)) {
                if (JVB_TESTING && !is_admin() &&!in_array($key, $this->ignore)) {
//                  TESTING
//                  jvbDump($key, 'getClass');
//                  jvbDump($attrs);
                    jvbDump($attrs, '[getClass] '.$key);
                }
                return '';
        }
    }
        /*** CLASS HELPERS ***/
        private function getContentPosition(string $value):string
        {
            $classes = [];
            $pos = explode(' ', $value);
            foreach($pos as $p) {
                switch ($p) {
                    case 'top':
                        $classes[] = 'top';
                        break;
                    case 'right':
                        $classes[] = 'right';
                        break;
                    case 'bottom':
                        $classes[] = 'btm';
                        break;
                    case 'left':
                        $classes[] = 'left';
                        break;
                }
            }
            return implode(' ', $classes);
        }
        private function getLayout(array $value, array $attrs):array
        {
//          jvbDump($value, 'getLayout');
            $classes = [];
            $type = 'row';
            $isRow = true;
            //Determine type
            if ((array_key_exists('type', $value) && !in_array($value['type'], ['flex', 'grid'])) ||
                (array_key_exists('orientation', $value) && $value['orientation'] === 'vertical')) {
                $type = 'col';
                $isRow = false;
            } elseif (array_key_exists('type', $value) && $value['type'] === 'grid') {
                $type = 'item-grid';
                $isRow = false;
                if (array_key_exists('columnCount', $value) && $value['columnCount']!== 3) {
                    $classes[] = sprintf(
                        'split-%s',
                        $value['columnCount']
                    );
                }
            }
            if (array_key_exists('justifyContent', $value)  && !array_key_exists('contentPosition', $attrs)) {
                switch ($value['justifyContent']) {
                    case 'right':
                        $classes[] = 'right';
                        break;
                    case 'center':
                        $classes[] = 'x-mid';
                        break;
                    case 'space-between':
                        $classes[] = 'x-btw';
                        break;
                    case 'left':
                        $classes[] = 'left';
                        break;
                    case 'space-evenly':
                        $classes[] = 'x-even';
                        break;
                    case 'space-around':
                        $classes[] = 'x-around';
                        break;
                    case 'stretch':
                        $classes[] = 'stretch';
                }
            } else {
                $classes[] = 'left';
            }
            if (array_key_exists('verticalAlignment', $value)) {
                switch ($value['verticalAlignment']) {
                    case 'bottom':
                        $classes[] = 'btm';
                        break;
                    case 'top':
                        $classes[] = 'top';
                        break;
                    case 'center':
                        $classes[] = 'y-mid';
                        break;
                    case 'space-between':
                        $classes[] = 'y-btw';
                        break;
                    case 'space-around':
                        $classes[] = 'y-around';
                        break;
                    case 'space-even':
                        $classes[] = 'y-even';
                }
            }
            if (array_key_exists('flexWrap', $value)) {
                if ($value['flexWrap'] === 'nowrap') {
                    $classes[] = 'nowrap';
                }
            }
            $classes[] = $type;
            return $classes;
        }
        private function getWidth(string $value):string
        {
            $value = (int)str_replace('%', '', $value);
            return sprintf(
                'width-%d',
                match (true) {
                    $value <= 25 => '25',
                    $value <= 33 => '33',
                    $value <= 50 => '50',
                    $value <= 66 => '66',
                    $value <= 75 => '75',
                    default => 'full',
                }
            );
        }
        private function getDimRatio(string $value):string
        {
            if (is_numeric($value)) {
                return sprintf(
                    'op-%d',
                    match (true) {
                        $value <= 14 => '1',
                        $value <= 28 => '2',
                        $value <= 42 => '3',
                        $value <= 56 => '45',
                        $value <= 70 => '4',
                        $value <= 84 => '5',
                        default => '6',
                    }
                );
            }
            return '';
        }
        private function getPresetStyles(array $value):string
        {
            $classes = [];
            //Margin and Padding
            if (array_key_exists('spacing', $value)) {
                $classes = array_merge($classes, $this->buildSpacingClasses($value));
            }
            if (array_key_exists('color', $value)) {
                if (array_key_exists('duotone', $value['color'])) {
                    $preset = explode('|', $value['color']['duotone']);
                    $preset = $preset[array_key_last($preset)];
                    if (str_contains($preset, '-')) {
                        $preset = explode('-', $preset);
                    } else {
                        $preset = [$preset];
                    }
                    $classes[] = 'duotone';
                    foreach ($preset as $p) {
                        $classes[] = $p;
                    }
                }
            }
            if (array_key_exists('fontSize', $value)) {
                if (in_array($value['fontSize'], ['small', 'large', 'extra-large', 'huge'])) {
                    $classes[] = 'font-'.$value['fontSize'];
                }
                if (in_array('fontWeight', $value)) {
                    $classes[] = 'text-'.$value['fontWeight'];
                }
                if (in_array('textTransform', $value)) {
                    if (in_array($value['textTransform'], ['uppercase', 'capitalize', 'lowercase'])) {
                        $classes[] = $value['textTransform'];
                    }
                }
            }
            return implode(' ', $classes);
        }
            private function buildSpacingClasses(array $value):array
            {
                $classes = [];
                foreach (['margin' => 'm', 'padding'=>'p'] as $search => $c) {
                    if (array_key_exists($search, $value['spacing'])) {
                        $directions = [];
                        // Collect ONLY preset spacing values for classes
                        foreach ($value['spacing'][$search] as $direction => $size) {
                            $presetSize = $this->getPresetSpacing($size);
                            if ($presetSize) {
                                $directions[$direction] = $presetSize;
                            }
                            // Non-preset values are skipped here and handled by inline styles below
                        }
                        if (empty($directions)) {
                            continue;
                        }
                        // Check what directions we have
                        $hasTop = isset($directions['top']);
                        $hasBottom = isset($directions['bottom']);
                        $hasLeft = isset($directions['left']);
                        $hasRight = isset($directions['right']);
                        // Check if axes match
                        $xMatch = $hasLeft && $hasRight && $directions['left'] === $directions['right'];
                        $yMatch = $hasTop && $hasBottom && $directions['top'] === $directions['bottom'];
                        // All 4 directions exist and match → p-3
                        if ($hasTop && $hasBottom && $hasLeft && $hasRight &&
                            count(array_unique($directions)) === 1) {
                            $classes[] = $c . '-' . reset($directions);
                        }
                        // Both axes match → px-3 py-2
                        elseif ($xMatch && $yMatch) {
                            $classes[] = $c . 'x-' . $directions['left'];
                            $classes[] = $c . 'y-' . $directions['top'];
                        }
                        // Only X axis matches → px-3 (+ individual for top/bottom)
                        elseif ($xMatch) {
                            $classes[] = $c . 'x-' . $directions['left'];
                            if ($hasTop) {
                                $classes[] = $c . 't-' . $directions['top'];
                            }
                            if ($hasBottom) {
                                $classes[] = $c . 'b-' . $directions['bottom'];
                            }
                        }
                        // Only Y axis matches → py-3 (+ individual for left/right)
                        elseif ($yMatch) {
                            $classes[] = $c . 'y-' . $directions['top'];
                            if ($hasLeft) {
                                $classes[] = $c . 'l-' . $directions['left'];
                            }
                            if ($hasRight) {
                                $classes[] = $c . 'r-' . $directions['right'];
                            }
                        }
                        // No matches - individual directions
                        else {
                            foreach ($directions as $direction => $size) {
                                $dir = match($direction) {
                                    'top' => 't',
                                    'bottom' => 'b',
                                    'left' => 'l',
                                    'right' => 'r',
                                    default => $direction
                                };
                                $classes[] = $c . $dir . '-' . $size;
                            }
                        }
                    }
                }
                return $classes;
            }
    protected function getInlineStyles(array $attrs):array
    {
@@ -1745,258 +2771,74 @@
        $styles = [];
        foreach ($attrs as $key => $value) {
            $style = $this->getStyle($key, $value, $attrs);
            if (is_string($style)) {
                $style = [$style];
            }
            $styles = array_merge($styles, $style);
        }
        return $styles;
    }
    protected function getStyle(string $key, string|bool|array|int $value, array $attrs):array
    protected function getStyle(string $key, string|bool|array|int $value, array $attrs):array|string
    {
        $styles = [];
        switch ($key) {
            // Font family settings
            case 'fontFamily':
                if ($value === 'body') {
                    $styles[] = 'font-family: "Open Sans", system-ui, -apple-system, sans-serif';
                } elseif ($value === 'heading') {
                    $styles[] = 'font-family: "Josefin Sans", system-ui, -apple-system, sans-serif';
                } elseif (!empty($value)) {
                    $styles[] = 'font-family: '.$value;
                }
                break;
            case 'size':
                return $this->getIconSizeStyle($value);
            case 'fontFamily':
                return $this->getFontFamilyStyle($value);
            // Icon color (for icon blocks)
            case 'iconColorValue':
                if (!empty($value)) {
                    $styles[] = 'color: '.$value;
                }
                break;
                return $this->getColorStyle($value);
            // Minimum height settings
            case 'minHeight':
                if (!empty($value) && isset($attrs['minHeightUnit'])) {
                    $styles[] = 'min-height: '.$value.$attrs['minHeightUnit'];
                } elseif (!empty($value)) {
                    $styles[] = 'min-height: '.$value.'px'; // Default to px if no unit specified
                }
                break;
                return $this->getMinHeightStyle($value, $attrs);
            // Background URL (for cover, media blocks)
            case 'url':
                if (!empty($value) && str_starts_with($value, 'http')) {
                    $styles[] = 'background-image: url('.$value.')';
                    return 'background-image: url('.$value.')';
                }
                break;
            // Focal point for background images
            case 'focalPoint':
                $x = (array_key_exists('x', $attrs['focalPoint'])) ? $attrs['focalPoint']['x'] * 100 : 'center';
                $y = (array_key_exists('y', $attrs['focalPoint'])) ? $attrs['focalPoint']['y'] * 100 : 'center';
                $styles[] = 'background-position:'.$x.' '.$y.';';
                break;
                return $this->getFocalPointStyle($value);
            // Complex style object
            case 'style':
                // Border styles
                if (isset($value['border'])) {
                    $border = $value['border'];
                return $this->extractStyles($value, $attrs);
                    if (isset($border['radius'])) {
                        $styles[] = 'border-radius: '.$border['radius'];
                    }
                    if (isset($border['width'])) {
                        $styles[] = 'border-width: '.$border['width'];
                    }
                    if (isset($border['style']) && isset($border['width']) && !empty($border['style'])) {
                        $styles[] = 'border-style: '.$border['style'];
                    }
                    if (isset($border['color'])) {
                        $styles[] = 'border-color: '.$border['color'];
                    }
                }
                // Color styles
                if (isset($value['color'])) {
                    $color = $value['color'];
                    if (isset($color['background'])) {
                        $styles[] = 'background-color: '.$color['background'];
                    }
                    if (isset($color['text'])) {
                        $styles[] = 'color: '.$color['text'];
                    }
                    if (isset($color['gradient'])) {
                        $styles[] = 'background: '.$color['gradient'];
                    }
                }
                // Layout styles
                if (isset($value['layout'])) {
                    foreach ($value['layout'] as $layout => $option) {
                        switch ($layout) {
                            case 'selfStretch':
                                if ($option === 'fixed' && isset($value['layout']['selfStretchValue'])) {
                                    $styles[] = 'width: '.$value['layout']['selfStretchValue'];
                                }
                                break;
                        }
                    }
                }
                // Typography styles
                if (isset($value['typography'])) {
                    $typography = $value['typography'];
                    if (isset($typography['fontSize'])) {
                        $styles[] = 'font-size: '.$typography['fontSize'];
                    }
                    if (isset($typography['fontWeight'])) {
                        $styles[] = 'font-weight: '.$typography['fontWeight'];
                    }
                    if (isset($typography['textDecoration'])) {
                        $styles[] = 'text-decoration: '.$typography['textDecoration'];
                    }
                    if (isset($typography['textTransform'])) {
                        $styles[] = 'text-transform: '.$typography['textTransform'];
                    }
                    if (isset($typography['letterSpacing'])) {
                        $styles[] = 'letter-spacing: '.$typography['letterSpacing'];
                    }
                    if (isset($typography['lineHeight'])) {
                        $styles[] = 'line-height: '.$typography['lineHeight'];
                    }
                }
                // Spacing styles
                if (isset($value['spacing'])) {
                    $spacing = $value['spacing'];
                    // Don't duplicate margin/padding that's handled by classes
                    // Only add specific CSS values here that wouldn't work well as classes
                    if (isset($spacing['margin'])) {
                        foreach ($spacing['margin'] as $direction => $size) {
                            // If not a preset value, add as inline style
                            if (!str_contains($size, 'var:preset')) {
                                $styles[] = 'margin-'.$direction.': '.$size;
                            }
                        }
                    }
                    if (isset($spacing['padding'])) {
                        foreach ($spacing['padding'] as $direction => $size) {
                            // If not a preset value, add as inline style
                            if (!str_contains($size, 'var:preset')) {
                                $styles[] = 'padding-'.$direction.': '.$size;
                            }
                        }
                    }
                }
                break;
            case 'dimRatio':
                $ratio = (ceil($value /25) *25);
                $s = 'background-color: rgba(var(--base-rgb), ';
                switch ($ratio) {
                    case 0:
                        $s .= 'var(--rgb-subtle-hover));';
                        break;
                    case 25:
                        $s .= 'var(--rgb-light));';
                        break;
                    case 50:
                        $s .= 'var(--rgb-medium));';
                        break;
                    default:
                        $s .= 'var(--rgb-heavy));';
                        break;
                }
                $styles[] = $s;
                break;
                return $this->getDimRatioStyle($value, $attrs);
            // Custom styles (any other attributes that need inline styling)
            case 'backgroundType':
                if ($value === 'video' && isset($attrs['backgroundUrl'])) {
                    // Don't set a background image for videos - it will be handled by the video element
                } elseif (isset($attrs['backgroundUrl'])) {
                    $styles[] = 'background-image: url('.$attrs['backgroundUrl'].')';
                    return 'background-image: url('.$attrs['backgroundUrl'].')';
                }
                break;
            case 'backgroundColor':
            case 'borderColor':
            case 'textColor':
                $type = ($key === 'backgroundColor') ? 'background-color:' : (($key === 'borderColor') ? 'border-color:' : 'color:');
                $defaults = apply_filters('jvbColours', ['base', 'contrast', 'action', 'secondary']);
                $continue = true;
                foreach ($defaults as $default) {
                    if (str_starts_with($value, $default)) {
                        $continue = false;
                        $styles[] = $type.'var(--'.$value.')';
                    }
                $type = str_replace('Color', '-color', $key);
                $type = str_replace('text-', '', $type);
                if (isset($attrs['border']['width']) && $key === 'borderColor') {
                    break;
                }
                if ($continue) {
                    $styles[] = $type.$value;
                }
                break;
                return $this->getColorStyle($value, $type);
            // Any other attributes that need direct styling
            default:
                $ignore = [
                    'useFeaturedImage',
                    'opacity',
                    'textAlign',
                    'minHeightUnit',
                    'isDark',
                    'isUserOverlayColor',
                    'contentPosition',
                    'sizeSlug',
                    'customOverlayColor',
                    'alt',
                    'placeholder',
                    'imageFill',
                    'mediaSizeSlug',
                    'isLink',
                    'kind',
                    'label',
                    'type',
                    'id',
                    'url',
                    'label',
                    'shouldSyncIcon',
                    'rel',
                    'opensInNewTab',
                    'title',
                    'ref',
                    'overlayMenu',
                    'slug',
                    'theme',
                    'tagName',
                    'level',
                    'ordered',
                    'area',
                    'className',
                    'fontSize',
                    'layout',
                    'align',
                    'mediaId',
                    'mediaLink',
                    'mediaType',
                    'isStackedOnMobile',
                    'width',
                    'height', // maybe still need?
                ];
                if (!is_admin() && !in_array($key, $ignore)) {
                if (JVB_TESTING && !is_admin() && !in_array($key, $this->ignore)) {
                    //TESTING
//                  jvbDump($key, 'getStyle');
//                  jvbDump($attrs);
                    jvbDump($attrs, '[getStyle] '.$key);
                }
                // No default inline styles
                break;
@@ -2004,6 +2846,367 @@
        return $styles;
    }
        private function getIconSizeStyle(string $value):array
        {
            $values = explode(' ', $value);
            $styles = [];
            foreach ($values as $v) {
                switch ($value) {
                    case 'has-small-icon-size':
                        $styles[] = '--w:var(--txt-x-small)';
                        break;
                    case 'has-large-icon-size':
                        $styles[] = '--w:var(--txt-large)';
                        break;
                    case 'has-huge-icon-size':
                        $styles[] = '--w:var(--txt-xx-large)';
                        break;
                    default:
                        if (JVB_TESTING) {
                            jvbDump($value, 'No preset found for size: '.print_r($value, true));
                        }
                }
            }
            return $styles;
        }
        private function getFontFamilyStyle(string $value):string
        {
            return match($value) {
                'body'      => 'font-family: var(--body)',
                'heading'   => 'font-family: var(--heading)',
                default     => sprintf('font-family: %s', $value)
            };
        }
        private function getColorStyle(string $value, ?string $type = 'color'):string
        {
            if (!in_array($type, ['color', 'background','background-color','border-color'])) {
                $type = null;
            }
            return sprintf(
                '%s%s',
                $type ? $type.': ' : '',
                $this->getColor($value)
            );
        }
        private function getMinHeightStyle(string $value, array $attrs):string
        {
            $out = '';
            if (!empty($value)) {
                if (isset($attrs['minHeightUnit'])) {
                    $out = sprintf(
                        'min-height: %s%s',
                        $value,
                        $attrs['minHeightUnit']
                    );
                } else {
                    $out = sprintf(
                        'min-height: %spx',
                        $value
                    );
                }
            }
            return $out;
        }
        private function getFocalPointStyle(array $value):string
        {
            jvbDump($value, 'Focal Point');
            $x = array_key_exists('x', $value) ? $value['x'] * 100 : 'center';
            $y = array_key_exists('y', $value) ? $value['y'] * 100 : 'center';
            $y = $x === $y ? '' : ' '.$y;
            return sprintf(
                'background-position:%s%s',
                $x,
                $y
            );
        }
        private function extractStyles(array $value, array $attrs):array
        {
            $styles = [];
            foreach ($value as $k => $v) {
                switch ($k) {
                    case 'border':
                        $styles = array_merge($styles, $this->getBorderStyle($v, $attrs));
                        break;
                    case 'color':
                        if (isset($v['background'])) {
                            $styles[] = $this->getColorStyle($v['background'], 'background-color');
                        }
                        if (isset($v['text'])) {
                            $styles[] = $this->getColorStyle($v['text']);
                        }
                        if (isset($v['gradient'])) {
                            jvbDump($v, 'Gradient');
                        }
                        break;
                    case 'layout':
                        $styles = array_merge($styles, $this->getLayoutStyle($v, $attrs));
                        break;
                    case 'typography':
                        $styles = array_merge($styles, $this->getTypographyStyle($v, $attrs));
                        break;
                    case 'spacing':
                        if (isset($v['blockGap'])) {
                            if (is_array($v['blockGap'])) {
                                $inner = [];
                                foreach ($v['blockGap'] as $gap) {
                                    $inner[] = sprintf(
                                        'var(--sp%s)',
                                        $this->getPresetSpacing($gap)
                                    );
                                }
                                if (!empty($inner)) {
                                    $styles[] = sprintf(
                                        '--gap: %s',
                                        implode(' ', $inner)
                                    );
                                }
                            } else {
                                $styles[] = '--gap: var(--sp'.$this->getPresetSpacing($v['blockGap']).')';
                            }
                        }
                        // Don't duplicate margin/padding that's handled by classes
                        // Only add specific CSS values here that wouldn't work well as classes
                        if (isset($v['margin'])) {
                            foreach ($v['margin'] as $direction => $size) {
                                if (!str_contains($size, 'var:preset')) {
                                    $styles[] = 'margin-'.$direction.': '.$size;
                                }
                            }
                        }
                        if (isset($v['padding'])) {
                            foreach($v['padding'] as $dir => $size) {
                                if (!str_contains($size, 'var:preset')) {
                                    $styles[] = 'padding-'.$dir.': '.$size;
                                }
                            }
                        }
                        break;
                    case 'background':
                        if (array_key_exists('backgroundImage', $v)) {
                            $data = Image::getData($v['backgroundImage']['id']);
                            if (!empty($data) && array_key_exists('tiny', $data)) {
                                $styles[] = sprintf(
                                    'background-image: url(%s)',
                                    $data['tiny']
                                );
                            }
                        }
                        break;
                    case 'dimensions':
                        foreach ($v as $sk => $sv) {
                            if ($sk === 'minHeight') {
                                $styles[] = sprintf(
                                    'min-height: %s',
                                    $sv
                                );
                            } else {
                                jvbDump('No config set for dimension '.$sk.': '.print_r($sv, true));
                            }
                        }
                        break;
                    case 'elements':
                        if (!empty($v)) {
                            // Generate a unique class tied to this block instance
                            $uid = 'b-'.substr(md5(serialize($attrs)), 0, 8);
                            $this->extractElementStyles($v, $uid);
                            // We need the uid added as a class — store it for getClassesAndStyles to pick up
                            static::$pendingClass[] = $uid;
                        }
                        break;
                    default:
                        jvbDump('No config set for '.$k.': '.print_r($v, true));
                }
            }
            return $styles;
        }
            private function getBorderStyle(array $border, array $attrs):array
            {
                $styles = [];
                if (isset($border['radius'])) {
                    $styles[] = 'border-radius: '.$border['radius'];
                }
                if (isset($border['width']) && isset($attrs['borderColor'])) {
                    $st = $border['style'] ?? 'solid';
                    $styles[] = sprintf(
                        'border: %s %s %s',
                        $border['width'],
                        $st,
                        $this->getColor($attrs['borderColor'])
                    );
                } elseif (isset($border['width'])) {
                    $styles[] = 'border-width: '.$border['width'];
                } elseif (isset($border['style'])) {
                    $styles[] = 'border-style: '.$border['style'];
                }
                return $styles;
            }
            private function getLayoutStyle(array $layout, array $attrs):array
            {
                $styles = [];
//              jvbDump($layout);
                foreach ($layout as $l => $option) {
                    switch ($l) {
                        case 'selfStretch':
                            if ($option === 'fixed' && isset($layout['selfStretchValue'])) {
                                $styles[] = 'width: '.$layout['selfStretchValue'];
                            } elseif ($option === 'fill') {
                                $styles[] = 'flex:1';
                            }
                            break;
                        default:
                            $ignore = [
                                'selfStretchValue',
                                'flexSize',
                            ];
                            if (JVB_TESTING && !in_array($l, $ignore)) {
                                jvbDump($l, 'No layout style set for: ');
                            }
//                          case 'type':
//                              if ($option === 'grid' && $value['layout']['columnCount'] !== 3) {
//                                  $styles[] = sprintf(
//                                      'grid-template-columns: repeat(1fr, %s)',
//                                      $value['layout']['columnCount']
//                                  );
//                              }
//                              break;
                    }
                }
                return $styles;
            }
            private function getTypographyStyle(array $typography, array $attrs):array
            {
                $styles = [];
                if (isset($typography['fontSize'])) {
                    $styles[] = 'font-size: '.$typography['fontSize'];
                }
                if (isset($typography['fontWeight'])) {
                    $styles[] = 'font-weight: '.$typography['fontWeight'];
                }
                if (isset($typography['textDecoration'])) {
                    $styles[] = 'text-decoration: '.$typography['textDecoration'];
                }
                if (isset($typography['textTransform'])) {
                    $styles[] = 'text-transform: '.$typography['textTransform'];
                }
                if (isset($typography['letterSpacing'])) {
                    $styles[] = 'letter-spacing: '.$typography['letterSpacing'];
                }
                if (isset($typography['lineHeight'])) {
                    $styles[] = 'line-height: '.$typography['lineHeight'];
                }
                return $styles;
            }
            private function extractElementStyles(array $elements, string $uid):void
            {
                foreach ($elements as $element => $states) {
                    $selector = match($element) {
                        'link'    => "a",
                        'heading' => "h1,h2,h3,h4,h5,h6",
                        'button'  => "button,.button",
                        default   => $element,
                    };
                    foreach ($states as $state => $rules) {
                        $css = [];
                        $fullSelector = str_starts_with($state, ':')
                            ? ".{$uid} {$selector}{$state}"
                            : ".{$uid} {$selector}";
                        if (isset($rules['color']['text'])) {
                            $css[] = 'color: '.$this->getColor($rules['color']['text']);
                        }
                        if (isset($rules['color']['background'])) {
                            $css[] = 'background-color: '.$this->getColor($rules['color']['background']);
                        }
                        if (!empty($css)) {
                            static::$pendingStyles[] = $fullSelector.' { '.implode('; ', $css).' }';
                        }
                    }
                }
            }
    public function maybeOutputCustomStyles(): string
    {
        if (empty(static::$pendingStyles)) return '';
        $out = '<style>'.implode(' ', static::$pendingStyles).'</style>';
        static::$pendingStyles = [];
        return $out;
    }
        private function getDimRatioStyle(int $value, array $attrs):string
        {
            //TODO: This likely isn't working correctly
            jvbDump($value, 'dimRatio');
            jvbDump($attrs, 'dimRatio attrs');
            $s = '';
            if (!array_key_exists('overlayColor', $attrs)) {
                $s = 'background-color: rgba(var(--base-rgb), ';
                if ($value <= 14) {
                    $s .= 'var(--op-1));';
                } elseif ($value <= 28) {
                    $s .= 'var(--op-2));';
                } elseif ($value <= 42) {
                    $s .= 'var(--op-3));';
                } elseif ($value <= 56) {
                    $s .= 'var(--op-45));';
                } elseif ($value <= 70) {
                    $s .= 'var(--op-4));';
                } elseif ($value <= 84) {
                    $s .= 'var(--op-5));';
                } else {
                    $s .= 'var(--op-6));';
                }
            }
            return $s;
        }
        protected function getDataset(array $attrs):array
        {
            $dataset = [];
            if (array_key_exists('style', $attrs)) {
                if (array_key_exists('background', $attrs['style'])){
                    if (array_key_exists('backgroundImage', $attrs['style']['background'])) {
                        $id = $attrs['style']['background']['backgroundImage']['id']??false;
                        if ($id) {
                            $data = Image::getData($id);
                            $dataset['bg-small'] = $data['small'];
                            $dataset['bg-med'] = $data['medium'];
                            $dataset['bg-large'] = $data['large'];
                        }
                    }
                }
            }
            return $dataset;
        }
    public function formatImage(int $ID = 0, string $start = 'tiny', string $replace = 'large'):string
    {
@@ -2016,4 +3219,28 @@
        return jvbFormatImage($ID, $start, $replace);
    }
    protected function checkAttrs(string $test, array $attrs):bool
    {
        return array_key_exists($test, $attrs) && $attrs[$test]===true;
    }
    protected function getColor(string $value):string
    {
        $defaults = apply_filters('jvbColours', ['base', 'contrast', 'action', 'secondary']);
        foreach ($defaults as $default) {
            if (str_starts_with($value, $default)) {
                return 'var(--'.$value.')';
            }
        }
        return $value;
    }
    protected function counter(string $key):void
    {
        if (!array_key_exists($key, static::$counters)) {
            static::$counters[$key] = 1;
        } else {
            static::$counters[$key]++;
        }
    }
}
inc/blocks/FeedBlock.php
@@ -193,8 +193,8 @@
            <?php if ($hasMany) {
                //If we have multiple content, only show the content first
                ?>
                <details class="col a-start">
                <summary class="row btw">
                <details class="col left">
                <summary class="row x-btw">
                    <span class="label">SHOWING: </span>
                    <?php
                    $labels = [];
@@ -249,7 +249,7 @@
                if (!empty ($this->config['taxonomies'])) {
                ?>
                    <div class="filters">
                        <div class="filter-group row start">
                        <div class="filter-group row left">
                            <span class="label">FILTER BY:</span>
                            <?php
@@ -288,8 +288,8 @@
                        </div>
                    </div>
                <?php } ?>
                    <div class="row btw nowrap">
                        <div class="order-by filter-group row start w-full">
                    <div class="row x-btw nowrap">
                        <div class="order-by filter-group row left w-full">
                            <span class="label">ORDER BY:</span>
                            <?php
                            //TODO: Get content types that can be sorted alphabetically
@@ -340,7 +340,7 @@
                        </div>
                        <div class="order-direction filter-group row start w-full" data-for-order="date,modified,title<?= $custom === '' ? '' : ','.$custom?>">
                        <div class="order-direction filter-group row left w-full" data-for-order="date,modified,title<?= $custom === '' ? '' : ','.$custom?>">
                            <span class="label">ORDER:</span>
                            <input type="radio" id="order-desc" class="btn" name="order" value="desc" data-filter="order" checked>
                            <label for="order-desc" title="Sort Descending (A-Z, 1-10)" class="row">
inc/blocks/FormBlock.php
@@ -324,7 +324,7 @@
        // Render navigation if multiple sections
        if (count($sections) > 1) {
            echo '<nav class="tabs row start" role="tablist">';
            echo '<nav class="tabs row left" role="tablist">';
            $i = 1;
            foreach ($sections as $slug => $section) {
                $active_class = $i === 1 ? ' active' : '';
@@ -367,7 +367,7 @@
            }
            // Add step navigation buttons
            echo '<div class="step-navigation row btw">';
            echo '<div class="step-navigation row x-btw">';
            if ($i > 0) {
                echo '<button type="button" class="button secondary prev-step" data-action="prev-step">';
inc/blocks/MenuBlock.php
@@ -194,14 +194,14 @@
        ?>
        <div class="menu-item<?= !empty($variations) ? ' variable' : '' ?>" data-section="<?=$slug?>">
            <div class="header row btw">
            <div class="header row x-btw">
                <h3><?= $values['post_title']?></h3>
                <p class="price"><?= $priceRange ?></p>
            </div>
            <div class="description">
                <?= Render::renderFrom($meta, 'post_excerpt')?>
            </div>
            <div class="info row end">
            <div class="info row right">
                <?php
                if (empty($variations)) {
                    Form::renderFrom($meta,
inc/blocks/SummaryBlock.php
@@ -174,7 +174,7 @@
        $open = $this->isOpen ? ' open' : '';
        ?>
        <details class="info"<?=$open?>>
            <summary class="row btw"><?= $this->detailsTitle ?></summary>
            <summary class="row x-btw"><?= $this->detailsTitle ?></summary>
            <?php
            foreach ($this->details as $key => $details) {
                if ($details === '') {
inc/forms/TaxonomySelector.php
@@ -111,8 +111,8 @@
                <div class="items-wrap">
                    <!-- Common/Favorite terms section -->
                    <details class="favourite-terms" hidden>
                        <summary class="title row btw">Your Go Tos:</summary>
                        <ul class="favourite-list row btw"></ul>
                        <summary class="title row x-btw">Your Go Tos:</summary>
                        <ul class="favourite-list row x-btw"></ul>
                    </details>
                    <!-- Pagination info -->
@@ -130,7 +130,7 @@
                        { <span>loading items</span> }
                    </p>
                    <!-- Terms list -->
                    <ul class="items-container col start" role="listbox" aria-label="Available terms">
                    <ul class="items-container col top" role="listbox" aria-label="Available terms">
                        <!-- Terms will be populated here -->
                    </ul>
@@ -149,7 +149,7 @@
                <!-- Create new term section -->
                <details class="create-term" hidden>
                    <summary class="row btw">Add New Term</summary>
                    <summary class="row x-btw">Add New Term</summary>
                    <div class="create-new-term-section">
                        <form class="create-term" data-nocache data-form-id="create-term" data-save="terms">
                            <div class="form-group">
@@ -229,7 +229,7 @@
        ?>
        <div class="jvb-selector <?= esc_attr($this->name) ?>"
             id="<?= esc_attr($this->id) ?>"<?= $hidden ?>>
            <div class="field-group-header row btw">
            <div class="field-group-header row x-btw">
                <label for="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete">
                    <?= ($this->config['icon']) ? jvbIcon($this->config['icon']) : '' ?>
                    <span><?= $this->config['label'] ?></span>
inc/helpers/crud.php
@@ -314,7 +314,7 @@
    }
    echo '<div class="container">';
    $nav = '<nav class="tabs row start" role="tablist">';
    $nav = '<nav class="tabs row left" role="tablist">';
    $i = 1;
    foreach ($registrar->getSections() as $slug => $section) {
        $nav .= '<button type="button" class="tab';
inc/helpers/media.php
@@ -25,7 +25,7 @@
                <img src="" alt="" class="image">
                <img src="" alt="" class="image-right">
                <details>
                    <summary class="row btw">DETAILS</summary>
                    <summary class="row x-btw">DETAILS</summary>
                    <div class="item-info"></div>
                </details>
            </div>
inc/helpers/renderFields.php
@@ -319,7 +319,7 @@
                    $date = new Date('M j, Y', strtotime($review['date']));
                }
                $out .= '<cite class="row btw">';
                $out .= '<cite class="row x-btw">';
                if ($review['rating'] !== 'none') {
                    $out .= jvbFormatStarRating($review['rating']);
                }
@@ -449,7 +449,7 @@
                    </div>
                </div>
                <details>
                    <summary class="row btw">
                    <summary class="row x-btw">
                        Extra Fields
                    </summary>
                    <div class="fields"></div>
inc/helpers/ui.php
@@ -19,8 +19,8 @@
    }
    ?>
    <aside id="queue" class="left col start btw main" aria-expanded="false" >
        <div class="m-actions row start nowrap">
    <aside id="queue" class="left col top main" aria-expanded="false" >
        <div class="m-actions row left nowrap">
            <button class="refresh row" title="Check now">
                <?= jvbIcon('arrows-clockwise', ['title'=> 'Check now']) ?>
                <span class="countdown row indicator" title="Will refresh again...">5</span>
@@ -28,7 +28,7 @@
            <div class="popup row"><span></span></div>
        </div>
        <div class="header col start">
        <div class="header col top">
            <h2>Queue Status</h2>
            <nav class="filters">
                <?php
@@ -55,9 +55,9 @@
                ?>
            </nav>
        </div>
        <div class="qitems item-grid col a-start nowrap">
        <div class="qitems item-grid col left nowrap">
        </div>
        <div class="queue-actions row btw nowrap">
        <div class="queue-actions row x-btw nowrap">
            <button class="dismiss-all">Clear Completed</button>
            <button class="retry-all">Retry Failed</button>
        </div>
@@ -70,20 +70,20 @@
    </button>
    <template class="queueItem">
        <div class="item">
            <div class="header row btw">
            <div class="header row x-btw">
                <span class="type"></span>
                <span class="status row"><?= jvbIcon('arrows-clockwise') ?><span class="screen-reader-text"></span></span>
            </div>
            <?php jvbRenderProgressBar('',false,false) ?>
            <div class="info">
                <div class="details"></div>
                <div class="time row start">
                <div class="time row left">
                    <?= jvbIcon('clock') ?>
                    <span class="started">Started: <time></time></span>
                    <span class="completed" hidden><span>Completed: </span><time></time></span>
                </div>
            </div>
            <div class="actions row end">
            <div class="actions row right">
                <button class="retry" data-action="retry"><span>Retry</span><?= jvbIcon('arrows-clockwise')?></button>
                <button class="cancel" data-action="cancel"><span>Cancel</span><?= jvbIcon('x-square')?></button>
                <button class="refresh" data-action="refresh" title="Refresh to see changes"><span>Refresh</span><?= jvbIcon('arrows-clockwise')?></button>
@@ -212,9 +212,9 @@
 * Outputs the search bar (likely don't need anymore)
 * @return string
 */
function jvbSearch(string $placeholder = 'Search...', string $id = 'search'):string
function jvbSearch(string $placeholder = 'Search...', string $id = 'search', string $label = '', string $buttonText = '', bool $buttonInside = false, bool $hideSearch = false):string
{
    return Form::search($placeholder, $id);
    return Form::search($placeholder, $id, $label, $buttonText, $buttonInside, $hideSearch);
}
@@ -271,7 +271,7 @@
    </aside>
    <template class="notificationPopup">
        <div class="toast" role="status" aria-live="polite">
            <div class="toast-content row btw">
            <div class="toast-content row x-btw">
                <p></p>
                <button type="button" class="close-toast" aria-label="Close">
                    <?= jvbIcon('x') ?>
@@ -362,7 +362,7 @@
        echo '';
        return '';
    }
    $header = '<nav class="tabs row start" role="tablist">';
    $header = '<nav class="tabs row left" role="tablist">';
    $content = '';
    $i = 0;
@@ -434,7 +434,7 @@
        <div class="bar">
            <div class="fill"></div>
        </div>
        <div class="row btw">
        <div class="row x-btw">
            %s
            <div class="details">
                %s
inc/integrations/Integrations.php
@@ -2945,7 +2945,7 @@
        ?>
        <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>"
             data-service="<?php echo esc_attr($this->service_name); ?>">
            <div class="header row btw">
            <div class="header row x-btw">
                <h3><?php echo esc_html($this->title); ?></h3>
                <div class="setup">
                    <?php if ($is_connected): ?>
@@ -3056,7 +3056,7 @@
                }
                ?>
            </div>
            <div class="actions row btw wrap">
            <div class="actions row x-btw wrap">
                <?php
                foreach ($this->buttons as $action => $label) {
                    if (!$is_connected && $action !== 'save_credentials') {
inc/managers/DashboardManager.php
@@ -704,9 +704,9 @@
        <?php
        $menu = new Navigation('sidebar');
        $menuClasses = ['col', 'a-start', 'nowrap'];
        $menuClasses = ['col', 'left', 'nowrap'];
        $itemClasses = ['col'];
        $menu->addClass('col a-start')->hasToggle()->defaultMenuClasses($menuClasses);
        $menu->addClass('col left')->hasToggle()->defaultMenuClasses($menuClasses);
        $menu->defaultItemClasses($itemClasses);
        $pages = $this->getUserAllowedPages()?:[];
        //Dashboard
@@ -1005,7 +1005,7 @@
    {
        ?>
        <div class="approvals container">
            <nav class="tabs row start" role="tablist">
            <nav class="tabs row left" role="tablist">
                <button type="button" class="tab active" data-tab="summary" role="tab" aria-selected="true">
                   <?= jvbDashIcon('infinity')?>All
                </button>
@@ -1077,7 +1077,7 @@
        }
        ob_start();
        ?>
        <nav class="tabs row start" role="tablist">
        <nav class="tabs row left" role="tablist">
        <?php
        $i=1;
        $content = Registrar::getRegistered('post');
inc/managers/DirectoryManager.php
@@ -373,7 +373,7 @@
                    $config = Registrar::getInstance($slug);
                    $aOpen = '<a href="'.$directory['url'].'" title="See our list of '.$directory['title'].'">';
                    $aClose = '</a>';
                    $cache .= '<li class="directory col start">
                    $cache .= '<li class="directory col left">
                        '. $aOpen.jvbIcon($config->getIcon() !== '' ? $config->getIcon() :'list-dashes').$directory['title'].$aClose;
                    if (!empty($directory['description'])) {
                        $cache .= '<div class="description">';
@@ -412,7 +412,7 @@
        );
        if ($current !== '' && array_key_exists($current, $this->directories())) {
            $open = ($open) ? ' open' : '';
            $cache = '<details'.$open.'><summary class="row btw">Other '.$this->referAs(true).':</summary>'.
            $cache = '<details'.$open.'><summary class="row x-btw">Other '.$this->referAs(true).':</summary>'.
                     str_replace('id="'.$current.'"', 'id="'.$current.'" class="current"', $cache)
                     .'</details>';
        }
@@ -613,7 +613,7 @@
            $children =$this->renderListChunk($taxonomy, $term->term_id);
            $out .= '<li>';
            if ($children !== '') {
                $out .= '<details class="term"><summary class="row btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.html_entity_decode($term->name).'">'.$term->name.'</a></summary>';
                $out .= '<details class="term"><summary class="row x-btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.html_entity_decode($term->name).'">'.$term->name.'</a></summary>';
                $out .= $children;
                $out .= '</details>';
            } else {
@@ -668,7 +668,7 @@
        $out = '<ul class="list-none">';
        foreach ($list as $letter => $items) {
            $out .= '<li id="starts-with-'.$letter.'" class="row a-start btw nowrap"><h3>'.strtoupper($letter).'</h3><ul>';
            $out .= '<li id="starts-with-'.$letter.'" class="row top x-btw nowrap"><h3>'.strtoupper($letter).'</h3><ul>';
            foreach ($items as $item) {
                $extra = '';
                if (!empty($item['extra'])) {
@@ -683,7 +683,7 @@
                $item_html = apply_filters('jvb_directory_render_item', '', $item, $type, $extra);
                if (empty($item_html)) {
                    $item_html = '<li class="row btw">
                    $item_html = '<li class="row x-btw">
                    <a href="'.$item['url'].'" title="More about '.$item['name'].'">
                        '.$item['name'].'</a>'.$extra.'
                </li>';
inc/managers/IconsManager.php
@@ -625,7 +625,7 @@
    /**
     * Get raw SVG content for CSS mask-image
     */
    protected function getRawSvg(string $name, ?string $style = null): ?string
    public function getRawSvg(string $name, ?string $style = null): ?string
    {
        if (!$style) {
            $style = $this->style;
@@ -678,6 +678,8 @@
                        $css .= 'details.all-filters summary::after,';
                    } elseif ($icon === 'link') {
                        $css .= 'input[type=url],';
                    } elseif ($icon === apply_filters('jvbSeparatorLogo', 'logo')) {
                        $css .= 'hr.logo::before,';
                    }
                    $css .= ".icon-{$icon}{$styleClass}{";
                    $css .= "--icon:url('data:image/svg+xml;base64,{$svg}');";
inc/managers/LoginManager.php
@@ -549,7 +549,7 @@
    {
        $form = $this->action.'form';
        ?>
        <section class="login-box col btw">
        <section class="login-box col y-btw">
            <h1><?=$this->labels['title']?></h1>
            <?= $this->labels['description'] ?>
@@ -568,7 +568,7 @@
            }
            ?>
            <div class="options row btw">
            <div class="options row x-btw">
                <?php
                switch ($this->action) {
                    case 'login': ?>
@@ -593,7 +593,7 @@
            </div>
        </section>
        <div class="navigation row btw">
        <div class="navigation row x-btw">
            <a href="<?= get_home_url() ?>">Home</a>
            <?php
            $privacy = get_privacy_policy_url();
@@ -635,7 +635,7 @@
                %s
                %s
                %s
                 <div class="row btw nowrap">
                 <div class="row x-btw nowrap">
                    <button type="submit" class="button button-primary button-large">%s</button>
                    %s
                </div>
inc/managers/ReferralManager.php
@@ -1441,7 +1441,7 @@
        <section class="copy">
            <h4>Your Referral Link</h4>
            <div class="copy-group row btw nowrap">
            <div class="copy-group row x-btw nowrap">
                <code id="referral-link" class="copy-target"><?= esc_url($share_url) ?></code>
                <button type="button" class="copy-btn" data-target="referral-link" title="Copy referral link">
                    <?= jvbIcon('copy'); ?>
@@ -1452,7 +1452,7 @@
            <h4>Your Code</h4>
            <div class="copy-group row btw nowrap">
            <div class="copy-group row x-btw nowrap">
                <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code>
                <button type="button" class="copy-btn" data-target="referral-code" title="Copy referral code">
                    <?= jvbIcon('copy'); ?>
@@ -1471,19 +1471,19 @@
        </section>
        <section class="stats-summary">
            <div class="row btw">
            <div class="row x-btw">
                <span class="stat-label">Total Referrals</span>
                <span class="stat-value" data-stat="total">-</span>
            </div>
            <div class="row btw">
            <div class="row x-btw">
                <span class="stat-label">Successful</span>
                <span class="stat-value" data-stat="treated">-</span>
            </div>
            <div class="row btw">
            <div class="row x-btw">
                <span class="stat-label">Pending</span>
                <span class="stat-value" data-stat="pending">-</span>
            </div>
            <div class="row btw highlight">
            <div class="row x-btw highlight">
                <span class="stat-label">Available Rewards</span>
                <span class="stat-value" data-stat="rewards">$0.00</span>
            </div>
@@ -2692,7 +2692,7 @@
        <details open>
            <summary>Your Code</summary>
            <h3>Share Link</h3>
            <div class="row btw nowrap">
            <div class="row x-btw nowrap">
                <code id="referral-link" class="copy-target"><?= home_url('/?ref=' . $referral_code) ?></code>
                <button type="button" class="copy-btn" data-target="referral-link" title="Copy referral link">
                    <?= jvbIcon('copy'); ?>
@@ -2700,7 +2700,7 @@
                </button>
            </div>
            <h3>Share Code</h3>
            <div class="row btw nowrap">
            <div class="row x-btw nowrap">
                <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code>
                <button type="button" class="copy-btn" data-target="referral-code" title="Copy referral code">
                    <?= jvbIcon('copy'); ?>
inc/managers/SEO/BreadcrumbManager.php
@@ -25,7 +25,6 @@
    private function __construct()
    {
        $this->cache = Cache::for('breadcrumbs', MONTH_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
        $this->cache->flush();
        if (JVB_TESTING) {
            $this->cache->flush();
        }
@@ -50,7 +49,9 @@
            return [];
        }
        switch (true) {
            case is_singular():
                $key = get_queried_object_id();
                break;
@@ -62,6 +63,10 @@
                $obj = get_queried_object();
                $key = $obj->taxonomy;
                break;
            case is_home():
                $obj = get_queried_object();
                $key = $obj->post_type;
                break;
            default:
                $key = 'home';
                break;
@@ -93,7 +98,7 @@
        $obj = get_queried_object();
        if (is_tax()) {
            $crumbs = $this->addTaxonomyCrumbs($crumbs, $obj);
        } elseif (is_singular()) {
        } elseif (is_singular() || is_home()) {
            $crumbs = $this->addArchiveCrumbs($crumbs, $obj);
            $hierarchy = $this->addSingularCrumbs($crumbs, $obj);
            $crumbs = $crumbs + $hierarchy;
@@ -211,6 +216,9 @@
     */
    private function addArchiveCrumbs(array $crumbs, object $obj): array
    {
        if (is_singular('page') || is_home()) {
            return $crumbs;
        }
        $type = is_singular() ? $obj->post_type : $obj->name;
        $name = jvbNoBase($type);
@@ -235,8 +243,9 @@
                'url'   => get_post_type_archive_link($type)
            ];
        } else {
            $postTypeObject = get_post_type_object($type);
            $crumbs[] = [
                'name'  => $obj->label,
                'name'  => $postTypeObject->label,
                'url'   => get_post_type_archive_link($type)
            ];
        }
inc/managers/queue/executors/ContentExecutor.php
@@ -348,6 +348,10 @@
                    }
                }
            }
            $isUpdate = $meta->get('is_update');
            if (!(bool) $isUpdate) {
                $meta->set('number', $index);
            }
            if ($lastKey === $index) {
                $latestTimestamp = strtotime($post->post_date);
inc/meta/Form.php
@@ -463,7 +463,7 @@
        if (!array_key_exists('class', $config)) {
            $config['class'] = [];
        }
        $config['class'][] ='row btw';
        $config['class'][] ='row x-btw';
        $checked = filter_var($value, FILTER_VALIDATE_BOOLEAN);
@@ -784,7 +784,7 @@
                                </div>
                            </div>
                            <div class="selection-actions row btw" hidden>
                            <div class="selection-actions row x-btw" hidden>
                                <button type="button" data-action="add-to-group">
                                    %sGroup
                                </button>
@@ -848,7 +848,7 @@
                protected static function renderUploadItemActions(?int $attachmentId = null):string
                {
                    return sprintf(
                        '<div class="item-actions row btw">
                        '<div class="item-actions row x-btw">
                        <div class="btn">
                            <input type="radio" class="featured btn" name="featured" id="featured%s" hidden>
                            <label for="featured">
@@ -1207,7 +1207,7 @@
        $containerId = sprintf('%s-%s-selector', $name, $config['subtype']);
        $input = sprintf(
            '<div class="row btw">
            '<div class="row x-btw">
    <input type="hidden" name="%s" value="%s">
    <label for="%s-autocomplete">%s<span>%s</span></label>',
            esc_attr($name),
@@ -1224,7 +1224,7 @@
        }
        $plural = static::getPlural($config);
        $input .= sprintf(
            '<div class="selected-items row start" role="region" aria-label="Selected %s"></div>',
            '<div class="selected-items row left" role="region" aria-label="Selected %s"></div>',
            $plural[1]??''
        );
@@ -1498,7 +1498,7 @@
        $config['data']['tag-format'] = esc_attr($tagFormat);
        $input = sprintf(
            '<h3>%s</h3><div class="row start wrap">',
            '<h3>%s</h3><div class="row left wrap">',
            esc_html($config['label']??'')
        );
@@ -1657,7 +1657,7 @@
                            %s
                        </button>
                        <details%s>
                            <summary class="row btw repeater-row-header">
                            <summary class="row x-btw repeater-row-header">
                                <span class="drag-handle">%s</span>
                                <span class="row-number">#%s</span>
                                <span class="row-title">%s</span>
@@ -1729,8 +1729,8 @@
                    <div class="items-wrap">
                        <!-- Common/Favorite terms section -->
                        <details class="favourite-terms" hidden>
                            <summary class="title row btw">Your Go Tos:</summary>
                            <ul class="favourite-list row btw"></ul>
                            <summary class="title row x-btw">Your Go Tos:</summary>
                            <ul class="favourite-list row x-btw"></ul>
                        </details>
                        <!-- Pagination info -->
@@ -1748,7 +1748,7 @@
                            { <span>loading items</span> }
                        </p>
                        <!-- Terms list -->
                        <ul class="items-container col start" role="listbox" aria-label="Available terms">
                        <ul class="items-container col top" role="listbox" aria-label="Available terms">
                            <!-- Terms will be populated here -->
                        </ul>
@@ -1767,7 +1767,7 @@
                    <!-- Create new term section -->
                    <details class="create-term" hidden>
                        <summary class="row btw">Add New Term</summary>
                        <summary class="row x-btw">Add New Term</summary>
                        <div class="create-new-term-section">
                            <form class="create-term" data-nocache data-form-id="create-term" data-save="terms">
                                <div class="form-group">
@@ -1826,20 +1826,33 @@
        );
    }
    public static function search(string $placeholder = 'Search...', string $id = 'search'):string
    public static function search(string $placeholder = 'Search...', string $id = 'search', string $label = '', string $buttonText = '',bool $buttonInside = false,  bool $hideSearch = false):string
    {
        $id = sanitize_title($id);
        $label = empty($label) ? '' : sprintf(
            '<h3>%s</h3>',
            $label
        );
        $buttonText = empty($buttonText) ? '' : sprintf(
            '<span>%s</span>',
            $buttonText
        );
        $hideSearch = $hideSearch ? ' hidden' : '';
        return sprintf(
            '<div class="search-container row start nowrap">
                <input type="search" id="%s" placeholder="%s">
            '%s<div class="search-container row left nowrap%s">
                <input type="search" id="%s" placeholder="%s"%s>
                <button title="Clear Search" type="button" class="clear-search" aria-label="Clear search"
                    onclick="this.previousElementSibling.value = \'\'; this.previousElementSibling.focus();">%s</button>
                <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s</button>
                <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s%s</button>
                </div>',
            $label,
            $buttonInside ? ' insideButton' : '',
            $id,
            $placeholder,
            $hideSearch,
            jvbIcon('x', ['title' => 'Clear Search']),
            jvbIcon('magnifying-glass')
            jvbIcon('magnifying-glass'),
            $buttonText
        );
    }
}
inc/templates.php
@@ -13,7 +13,7 @@
{
    return '<template class="response">
    <details class="response" open>
            <summary class="row btw">
            <summary class="row x-btw">
                <div class="header">
                </div>
@@ -65,7 +65,7 @@
{
    return '<template class="responses">
        <details class="responses">
            <summary class="row btw">
            <summary class="row x-btw">
                Comments
            </summary>
        </details>
@@ -112,7 +112,7 @@
                        <h4>'.__('From:', 'jvb').'</h4>
                        <div class="source-info col">
                            <div class="source-day"></div>
                            <div class="source-hours row start"></div>
                            <div class="source-hours row left"></div>
                        </div>
                    </div>
inc/ui/CRUDSkeleton.php
@@ -578,7 +578,7 @@
    protected function renderUploader(): void {
        ?>
        <details open class="uploader">
            <summary class="row btw"><?= esc_html($this->uploaderConfig['label'] ?? 'Upload Files') ?></summary>
            <summary class="row x-btw"><?= esc_html($this->uploaderConfig['label'] ?? 'Upload Files') ?></summary>
            <form id="uploader" data-form-id="upload_new_<?=$this->dataType ?>">
                <?php
                echo jvbFormRestore();
@@ -624,7 +624,7 @@
            return;
        }
        ?>
        <details class="all-filters col start" data-ignore>
        <details class="all-filters col top" data-ignore>
            <summary>Filters</summary>
            <?php
@@ -649,7 +649,7 @@
            return;
        }
        ?>
        <div class="search row start nowrap">
        <div class="search row left nowrap">
            <span class="label">Search:</span>
            <?= jvbSearch() ?>
        </div>
@@ -725,7 +725,7 @@
    protected function renderOrderControls():void
    {
        ?>
        <div class="radio-options order row btw w-full">
        <div class="radio-options order row x-btw w-full">
            <?php
            $order = [
                'orderby' => [
@@ -740,7 +740,7 @@
            foreach ($order as $o => $option) {
                ?>
                <div class="row start">
                <div class="row left">
                    <span class="label"><?= ucfirst($o)?>:</span>
                    <?php
                    $i = 0;
@@ -770,7 +770,7 @@
            return;
        }
        ?>
        <div class="filters row start">
        <div class="filters row left">
            <span class="label">Filters:</span>
            <?php
            foreach ($this->filters as $key => $config) {
@@ -928,7 +928,7 @@
        ob_start();
        ?>
        <details class="multi-select" title="Select columns" hidden>
            <summary class="row start nowrap">
            <summary class="row left nowrap">
                <?= jvbDashIcon('columns') ?>
                <span class="labels">Toggle Columns</span>
            </summary>
@@ -961,7 +961,7 @@
            return;
        }
        ?>
        <div class="bulk-controls row nowrap btw">
        <div class="bulk-controls row nowrap x-btw">
            <div class="bulk-select">
                <input type="checkbox" id="select-all" class="select-all">
                <label for="select-all" class="row"><span>Select All</span><span class="selected-count" hidden></span></label>
@@ -1132,7 +1132,7 @@
        }
        ob_start();
        ?>
        <div class="item-actions row btw abs">
        <div class="item-actions row x-btw abs">
            <?php
            foreach ($this->itemActions as $action) {
                $config = $this->defaultItemActions[$action];
@@ -1173,7 +1173,7 @@
            <div class="item <?=esc_attr($this->dataType)?> row nowrap">
                <?= $this->renderItemSelect()?>
                <?=$this->renderImage() ?>
                <div class="col start w-full">
                <div class="col top w-full">
                    <h3 data-field="post_title"></h3>
                    <p data-attr="date"></p>
                    <p data-field="price"></p>
@@ -1260,7 +1260,7 @@
                    <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                        <?php
                        if (in_array('edit', $this->caps)) {
                            echo $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '';
                            echo $makeThisDetailed ? '<details><summary class="row x-btw">See Value</summary>' : '';
                            if (in_array($config['type'], ['selector', 'taxonomy', 'post'])) {
                                $config['autocomplete'] = true;
                            }
@@ -1342,7 +1342,7 @@
                    $makeThisDetailed = (in_array($config['type'], $makeDetails));
                    ?>
                    <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                        <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
                        <?= $makeThisDetailed ? '<details><summary class="row x-btw">See Value</summary>' : '' ?>
                        <?php
                        if (in_array($config['type'], ['selector', 'taxonomy', 'post'])) {
                            $config['autocomplete'] = true;
@@ -1375,7 +1375,7 @@
                    $makeThisDetailed = (in_array($config['type'], $makeDetails));
                    ?>
                    <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                        <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
                        <?= $makeThisDetailed ? '<details><summary class="row x-btw">See Value</summary>' : '' ?>
                        <?= Form::render($name, '', $config); ?>
                        <?= $makeThisDetailed ? '</details>' : '' ?>
                    </td>
@@ -1458,7 +1458,7 @@
    protected function renderTableActions(): string {
        ob_start();
        ?>
        <div class="table-actions row btw nowrap">
        <div class="table-actions row x-btw nowrap">
            <?php if (count(array_intersect(['create', 'edit'], $this->caps)) > 0) { ?>
                <?= jvbRenderToggleTextField(
                    'vertical',
inc/ui/Checkout.php
@@ -74,7 +74,7 @@
        $form .= jvbRenderTabs($tabs, true);
        $form .= '<div class="cart-total row end">
        $form .= '<div class="cart-total row right">
                <p class="tax">Tax: <span></span></p>
                <p class="total">GRAND TOTAL: <span></span></p>
            </div>
@@ -239,7 +239,7 @@
                <h3>Looks like we left things hanging</h3>
                <p>We\'ve restored your cart from your last session below.</p>
                <p>If you\'d rather start over, click the button below.</p>
                <div class="row btw">
                <div class="row x-btw">
                    <button type="button" data-clear-cart>' . jvbIcon('trash') . 'Clear Cart</button>
                    <button type="button" data-dismiss>' . jvbIcon('x') . 'Dismiss</button>
                </div>
inc/ui/Tabs.php
@@ -48,7 +48,7 @@
            return '';
        }
        $header = '<nav class="tabs row start" role="tablist">';
        $header = '<nav class="tabs row left" role="tablist">';
        $content = '';
        $i = 0;
inc/users/UserSettings.php
@@ -171,7 +171,7 @@
        <div class="notification-preferences">
            <?php foreach ($favourites as $type => $items) : ?>
                <details class="notification-group">
                    <summary class="notification-group-header row btw">
                    <summary class="notification-group-header row x-btw">
                        <span class="type-label"><?=jvbIcon('heart', ['style' => 'fill'])?> <?= $notify[$type] ?></span>
                        <span class="type-count">( <?= count($items); ?> )</span>
                    </summary>
inc/utility/Image.php
@@ -153,4 +153,10 @@
            }
        );
    }
    public static function getData(int $imgID):array
    {
        return (new Image)->getImageData($imgID);
    }
}
jvb.php
@@ -141,6 +141,13 @@
    return IconsManager::for($source)->get($name, $options);
}
function jvbFullIcon(string $name, array $options = []):string
{
    $source = $options['source'] ?? 'icons';
    unset($options['source']);
    return IconsManager::for($source)->getRawSvg($name, $options['style']??null);
}
/**
 * Get a CSS data URI for an icon
 *
src/drawer-menu/block.json
New file
@@ -0,0 +1,27 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/drawer-menu",
    "title": "Drawer Menu",
    "category": "jvb",
    "icon": "menu",
    "version": "1.0.0",
    "textdomain": "jvb",
    "supports": {
        "html": false
    },
    "attributes": {
        "menuId": {
            "type": "string",
            "default": ""
        },
        "collapsed": {
            "type": "boolean",
            "default": true
        }
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "render": "file:./render.php"
}
src/drawer-menu/edit.js
New file
@@ -0,0 +1,33 @@
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, TextControl } from '@wordpress/components';
export default function Edit({ attributes, setAttributes }) {
    const { menuId, collapsed } = attributes;
    const blockProps = useBlockProps();
    return (
        <>
            <InspectorControls>
                <PanelBody title="Drawer Settings">
                    <TextControl
                        label="Menu ID"
                        value={menuId}
                        onChange={(value) => setAttributes({ menuId: value })}
                        help="PHP-generated menu identifier"
                    />
                    <ToggleControl
                        label="Start Collapsed"
                        checked={collapsed}
                        onChange={(value) => setAttributes({ collapsed: value })}
                    />
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                <div className="drawer-menu-preview">
                    <p>Drawer Menu: {menuId || 'Not configured'}</p>
                    <p>State: {collapsed ? 'Collapsed' : 'Expanded'}</p>
                </div>
            </div>
        </>
    );
}
src/drawer-menu/editor.scss
src/drawer-menu/index.js
New file
@@ -0,0 +1,10 @@
import { registerBlockType } from '@wordpress/blocks';
import edit from './edit';
import save from './save';
import './style.scss';
import './editor.scss';
registerBlockType('jvb/drawer-menu', {
    edit,
    save,
});
src/drawer-menu/index.php
src/drawer-menu/render.php
New file
@@ -0,0 +1,38 @@
<?php
use JVBase\managers\Cache;
use JVBase\ui\Navigation;
$menu_id = $attributes['menuId'] ?? '';
$collapsed = $attributes['collapsed'] ?? true;
// You'd populate this from options, a filter, or however you store menu data
$menu_items = apply_filters('jvbDrawerItems', [], $menu_id);
if (empty($menu_items) || empty($menu_id)) {
    return '<p>Please configure the drawer menu in block settings.</p>';
}
$cache = Cache::for('drawer');
if (!is_front_page()) {
    $menu_items[] = [
        'text'  => 'Home',
        'url'   => home_url(),
        'icon'  => 'house-simple',
    ];
}
$items = array_map(function($item) { return $item['text'];}, $menu_items);
$key = $cache->generateKey($items);
$menu =  $cache->remember($key,
function () use ($menu_items, $menu_id, $collapsed) {
    $menu = new Navigation($menu_id);
    $menu->asDrawer($collapsed)->populateFromArray($menu_items);
    return $menu->render();
});
global $wp;
$current = home_url($wp->request.'/');
echo str_replace($current.'"', $current.'" class="current" aria-current="page"', $menu);
src/drawer-menu/save.js
New file
@@ -0,0 +1,3 @@
export default function save() {
    return null; // Server-side rendered
}
src/drawer-menu/style.scss
New file
@@ -0,0 +1,88 @@
nav.drawer {
    position: fixed;
    right: 0;
    bottom: 0;
    max-height: 100vh;
    overflow: hidden auto;
    width: var(--btn);
    z-index: var(--z-5);
    transition: var(--trans-size);
    --dir: column-reverse;
    background-color: var(--base);
    border-left: 1px solid var(--base-200);
    box-shadow:rgba(var(--base-rgb), var(--op-4)) var(--shdw-left);
    height: auto;
    --w: var(--chip_);
    .title,
    .section-title {
        display: none;
    }
    ul .icon {
        min-width: var(--chip_);
    }
    a,
    .a {
        height: var(--chipchip);
        padding: 0 .5rem;
        width: 100%;
        gap: .5rem;
        justify-content: center;
    }
    .toggle {
        width: 100%;
        .icon {
            transform: rotate(0);
            transition: var(--trans-transform);
        }
    }
    &.open {
        width: 240px;
        .title,
        .section-title {
            display: block;
        }
        .toggle .icon {
            transform: rotate(180deg);
        }
        a,
        .a {
            justify-content: flex-start;
        }
    }
    ul {
        --dir: column;
        --gap: 0;
        --height: auto;
        padding: 0;
        margin: 0;
        height: auto;
        width:100%;
    }
    li {
        height: auto;
        width: 100%;
    }
    .row {
        width: 100%;
    }
    .menu-section {
        border-bottom: 1px solid var(--contrast-200);
    }
    .section-title {
        padding: 0.5rem var(--px);
        font-size: var(--small);
        text-transform: uppercase;
        opacity: 0.6;
        font-weight: bold;
    }
}
src/drawer-menu/view.js
New file
@@ -0,0 +1 @@
src/faq/block.json
New file
@@ -0,0 +1,34 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/faq",
    "title": "FAQ Block",
    "category": "jvb",
    "icon": "info",
    "description": "Display FAQs organized by sections with customizable ordering",
    "keywords": ["faq", "questions", "help"],
    "version": "1.0.0",
    "textdomain": "jvb",
    "attributes": {
        "sectionOrder": {
            "type": "array",
            "default": []
        },
        "showSectionTitles": {
            "type": "boolean",
            "default": true
        },
        "collapseByDefault": {
            "type": "boolean",
            "default": false
        }
    },
    "supports": {
        "align": ["wide", "full"],
        "html": false
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/faq/edit.js
New file
@@ -0,0 +1,145 @@
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, Notice, Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import './editor.scss';
export default function Edit({ attributes, setAttributes }) {
    const { sectionOrder, showSectionTitles, collapseByDefault } = attributes;
    const [sections, setSections] = useState([]);
    // Get sections from localized script
    const allSections = window.jvbFaq?.sections || [];
    // Initialize sections with proper ordering
    useEffect(() => {
        if (!allSections.length) return;
        if (sectionOrder.length === 0) {
            // First time - use default order
            const orderedSections = allSections.map(section => ({
                id: section.id,
                name: section.name,
            }));
            setSections(orderedSections);
            setAttributes({ sectionOrder: orderedSections.map(s => s.id) });
        } else {
            // Use saved order, add any new sections at the end
            const orderedSections = [];
            const existingIds = new Set(sectionOrder);
            // Add sections in saved order
            sectionOrder.forEach(id => {
                const section = allSections.find(s => s.id === id);
                if (section) {
                    orderedSections.push({ id: section.id, name: section.name });
                }
            });
            // Add any new sections that weren't in the saved order
            allSections.forEach(section => {
                if (!existingIds.has(section.id)) {
                    orderedSections.push({ id: section.id, name: section.name });
                }
            });
            setSections(orderedSections);
        }
    }, [allSections, sectionOrder]);
    const moveSection = (index, direction) => {
        const newSections = [...sections];
        const newIndex = direction === 'up' ? index - 1 : index + 1;
        if (newIndex < 0 || newIndex >= newSections.length) return;
        // Swap sections
        [newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
        setSections(newSections);
        setAttributes({ sectionOrder: newSections.map(s => s.id) });
    };
    const blockProps = useBlockProps({
        className: 'faq-block-editor',
    });
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('FAQ Settings', 'jvb')} initialOpen={true}>
                    <ToggleControl
                        label={__('Show Section Titles', 'jvb')}
                        checked={showSectionTitles}
                        onChange={(value) => setAttributes({ showSectionTitles: value })}
                        help={__('Display section names as headings', 'jvb')}
                    />
                    <ToggleControl
                        label={__('Collapse by Default', 'jvb')}
                        checked={collapseByDefault}
                        onChange={(value) => setAttributes({ collapseByDefault: value })}
                        help={__('Questions start collapsed and expand on click', 'jvb')}
                    />
                </PanelBody>
                <PanelBody title={__('Section Order', 'jvb')} initialOpen={false}>
                    <p className="components-base-control__help">
                        {__('Use the arrow buttons to reorder sections', 'jvb')}
                    </p>
                    {sections.length > 0 ? (
                        <div className="faq-section-list">
                            {sections.map((section, index) => (
                                <div key={section.id} className="faq-section-item">
                                    <div className="faq-section-controls">
                                        <Button
                                            icon="arrow-up-alt2"
                                            label={__('Move up', 'jvb')}
                                            disabled={index === 0}
                                            onClick={() => moveSection(index, 'up')}
                                            className="faq-section-button"
                                        />
                                        <Button
                                            icon="arrow-down-alt2"
                                            label={__('Move down', 'jvb')}
                                            disabled={index === sections.length - 1}
                                            onClick={() => moveSection(index, 'down')}
                                            className="faq-section-button"
                                        />
                                    </div>
                                    <span className="faq-section-name">{section.name}</span>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <Notice status="info" isDismissible={false}>
                            {__('No sections found. Create sections in the FAQ taxonomy.', 'jvb')}
                        </Notice>
                    )}
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                <div className="faq-block-preview">
                    <h3>{__('FAQ Block', 'jvb')}</h3>
                    <p>
                        {__('This block will display FAQs organized by sections.', 'jvb')}
                    </p>
                    {sections.length > 0 ? (
                        <div className="faq-sections-preview">
                            <strong>{__('Section Order:', 'jvb')}</strong>
                            <ol>
                                {sections.map((section) => (
                                    <li key={section.id}>{section.name}</li>
                                ))}
                            </ol>
                        </div>
                    ) : (
                        <Notice status="warning" isDismissible={false}>
                            {__('No sections available. Create sections in the FAQ taxonomy.', 'jvb')}
                        </Notice>
                    )}
                </div>
            </div>
        </>
    );
}
src/faq/editor.scss
New file
@@ -0,0 +1,99 @@
/**
 * FAQ Block - Editor Styles
 */
.faq-block-editor {
    padding: 2rem;
    border: 2px dashed #ccc;
    border-radius: 8px;
    background: #f9f9f9;
    .faq-block-preview {
        text-align: center;
        h3 {
            margin: 0 0 0.5rem;
            font-size: 1.25rem;
            font-weight: 600;
        }
        > p {
            margin: 0 0 1.5rem;
            color: #666;
        }
        .faq-sections-preview {
            margin-top: 1.5rem;
            text-align: left;
            background: white;
            padding: 1rem;
            border-radius: 4px;
            strong {
                display: block;
                margin-bottom: 0.5rem;
            }
            ol {
                margin: 0;
                padding-left: 1.5rem;
                li {
                    margin: 0.25rem 0;
                    padding: 0.25rem 0;
                }
            }
        }
    }
}
// Inspector Controls
.faq-section-list {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-top: 0.5rem;
}
.faq-section-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    background: white;
    border: 1px solid #ddd;
    border-radius: 4px;
    transition: background 150ms ease;
    &:hover {
        background: #f9f9f9;
    }
}
.faq-section-controls {
    display: flex;
    gap: 0.25rem;
    flex-shrink: 0;
}
.faq-section-button {
    min-width: 30px !important;
    padding: 4px !important;
    height: 30px !important;
    &:disabled {
        opacity: 0.3;
        cursor: not-allowed;
    }
}
.faq-section-name {
    flex: 1;
    font-weight: 500;
    padding-left: 0.5rem;
}
// Notice adjustments
.components-panel__body .components-notice {
    margin: 1rem 0;
}
src/faq/index.js
New file
@@ -0,0 +1,11 @@
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import './style.scss';
import './editor.scss';
import metadata from './block.json';
registerBlockType(metadata.name, {
    edit: Edit,
    // No save function - dynamic block rendered on server
    save: () => null,
});
src/faq/index.php
src/faq/render.php
src/faq/style.scss
New file
@@ -0,0 +1,72 @@
nav#faq {
    height: max-content;
    display: block;
    background-color: var(--base-100);
    border-radius: var(--radius-outer);
    padding: 1.5rem;
    touch-action: auto;
    ol {
        list-style: decimal-leading-zero;
        height: fit-content;
        display: block;
        counter-reset: faq;
        li {
            counter-increment: faq;
            width: max-content;
            &::before {
                content: counter(faq);
                display: block;
                font-family: var(--heading);
                font-weight: var(--fw-h-bold);
            }
        }
    }
    h2 {
        left: 0;
        font-size: var(--txt-large);
        margin: .5rem 0 .5rem;
    }
    a {
        padding: .5rem;
    }
}
.faq-block {
    padding-bottom: 3rem;
    max-width: none;
    width: 100%;
    > * {
        max-width: var(--wide);
        margin: 1rem auto;
    }
    h2 {
        margin: 5rem 0 1.5rem;
    }
    h3 {
        margin: 0;
        text-transform: none;
    }
    :target {
        background-color: var(--base);
        outline: none;
        h2 {
            background-color: var(--base);
            padding: 1rem 1.5rem;
            border-radius: var(--radius-outer);
        }
    }
    details {
        max-width: var(--content);
        margin: 1rem auto;
        padding: .75rem;
    }
    details + details {
        margin-top: 3rem;
    }
    details .button {
        height: fit-content;
        display: flex;
        margin-left: auto;
    }
}
src/faq/view.js
New file
@@ -0,0 +1,84 @@
/**
 * FAQ Block - Frontend Interactions
 * Handles accordion functionality for FAQ items
 */
document.addEventListener('DOMContentLoaded', () => {
    const faqBlocks = document.querySelectorAll('.faq-block');
    faqBlocks.forEach((block) => {
        const faqItems = block.querySelectorAll('.faq-item');
        faqItems.forEach((item) => {
            const button = item.querySelector('.faq-item__question');
            const answer = item.querySelector('.faq-item__answer');
            if (!button || !answer) return;
            button.addEventListener('click', () => {
                const isExpanded = button.getAttribute('aria-expanded') === 'true';
                // Toggle this item
                button.setAttribute('aria-expanded', !isExpanded);
                if (isExpanded) {
                    // Collapse
                    answer.style.height = answer.scrollHeight + 'px';
                    // Force reflow
                    answer.offsetHeight;
                    answer.style.height = '0';
                    setTimeout(() => {
                        answer.style.display = 'none';
                        answer.style.height = '';
                    }, 300);
                    item.classList.remove('faq-item--expanded');
                } else {
                    // Expand
                    answer.style.display = 'block';
                    answer.style.height = '0';
                    // Force reflow
                    answer.offsetHeight;
                    answer.style.height = answer.scrollHeight + 'px';
                    setTimeout(() => {
                        answer.style.height = 'auto';
                    }, 300);
                    item.classList.add('faq-item--expanded');
                }
            });
            // Handle keyboard navigation
            button.addEventListener('keydown', (e) => {
                // Space or Enter triggers the button
                if (e.key === ' ' || e.key === 'Enter') {
                    e.preventDefault();
                    button.click();
                }
            });
        });
    });
    // Optional: Add URL hash navigation
    // If URL has #faq-123, open that specific FAQ
    if (window.location.hash) {
        const hash = window.location.hash.substring(1);
        const targetItem = document.querySelector(`[data-faq-id="${hash}"]`);
        if (targetItem) {
            const button = targetItem.querySelector('.faq-item__question');
            const isExpanded = button.getAttribute('aria-expanded') === 'true';
            if (!isExpanded) {
                button.click();
            }
            // Scroll to item after a short delay
            setTimeout(() => {
                targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }, 350);
        }
    }
});
src/feed/block.json
New file
@@ -0,0 +1,57 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/feed",
    "title": "Feed",
    "category": "jvb",
    "icon": "grid-view",
    "description": "Displays a filterable feed of registered content types",
    "keywords": [ "feed", "grid" ],
    "version": "0.9.0",
    "textdomain": "jvb",
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "attributes": {
        "title": {
            "type": "string",
            "default": "Your Scene"
        },
        "inheritQuery": {
            "type": "boolean",
            "default": false
        },
        "contentTypes": {
            "type": "array",
            "default": ["tattoo", "artwork", "artist"],
            "items": {
                "type": "string"
            }
        },
        "itemsPerPage": {
            "type": "number",
            "default": 36
        },
        "defaultOrder": {
            "type": "string",
            "default": "date_desc"
        }
    },
    "selectors": {
        "root": ".feed-block"
    },
    "styles": [
        { "name": "default", "label": "Default", "isDefault": true },
        { "name": "other", "label": "Other" }
    ],
    "example": {
        "attributes": {
            "message": "This is a notice!"
        }
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/feed/edit.js
New file
@@ -0,0 +1,256 @@
/**
 * Feed Block - Edit Component
 * Fetches available feed types from /jvb/v1/feed/types
 * Allows configuration of content types and inherit query setting
 */
import { useEffect, useState } from '@wordpress/element';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
    PanelBody,
    CheckboxControl,
    ToggleControl,
    Spinner,
    Notice
} from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
export default function Edit({ attributes, setAttributes }) {
    const [feedTypes, setFeedTypes] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    const blockProps = useBlockProps({
        className: 'feed-block-editor'
    });
    /**
     * Fetch available feed types on component mount
     */
    useEffect(() => {
        apiFetch({
            path: '/jvb/v1/feed/types',
            headers: {
                'If-Modified-Since': localStorage.getItem('feed_types_modified'),
            }
        })
            .then(types => {
                setFeedTypes(types);
                setLoading(false);
                // Store Last-Modified for future requests
                // (apiFetch doesn't expose response headers easily,
                // but the server will handle 304s)
                // Initialize contentTypes if not set and not inheriting
                if (!attributes.contentTypes && !attributes.inheritQuery) {
                    const firstType = Object.keys(types)[0];
                    if (firstType) {
                        setAttributes({ contentTypes: [firstType] });
                    }
                }
            })
            .catch(err => {
                console.error('Error loading feed types:', err);
                setError(err.message);
                setLoading(false);
            });
    }, [attributes.inheritQuery]);
    /**
     * Toggle a content type in the selection
     */
    const toggleContentType = (slug, checked) => {
        const currentTypes = attributes.contentTypes || [];
        const newTypes = checked
            ? [...currentTypes, slug]
            : currentTypes.filter(t => t !== slug);
        setAttributes({ contentTypes: newTypes });
    };
    /**
     * Get friendly label for content type
     */
    const getTypeLabel = (slug, config) => {
        return `${config.plural} (${config.type})`;
    };
    /**
     * Group types by category for better UX
     */
    const groupedTypes = feedTypes ? {
        content: Object.entries(feedTypes)
            .filter(([_, config]) => config.type === 'content'),
        taxonomy: Object.entries(feedTypes)
            .filter(([_, config]) => config.type === 'taxonomy')
    } : { content: [], taxonomy: [] };
    return (
        <div {...blockProps}>
            <InspectorControls>
                <PanelBody
                    title={__('Feed Settings', 'jvb')}
                    initialOpen={true}
                >
                    <ToggleControl
                        label={__('Inherit from Page Context', 'jvb')}
                        help={
                            attributes.inheritQuery
                                ? __('Feed will adapt to the current page (profile, taxonomy, etc.)', 'jvb')
                                : __('Manually select content types to display', 'jvb')
                        }
                        checked={attributes.inheritQuery}
                        onChange={(value) => setAttributes({ inheritQuery: value })}
                    />
                    {!attributes.inheritQuery && (
                        <>
                            {loading && (
                                <div style={{ textAlign: 'center', padding: '20px' }}>
                                    <Spinner />
                                    <p>{__('Loading feed types...', 'jvb')}</p>
                                </div>
                            )}
                            {error && (
                                <Notice status="error" isDismissible={false}>
                                    {__('Error loading feed types: ', 'jvb')} {error}
                                </Notice>
                            )}
                            {!loading && !error && feedTypes && (
                                <>
                                    {groupedTypes.content.length > 0 && (
                                        <>
                                            <h4>{__('Content Types', 'jvb')}</h4>
                                            {groupedTypes.content.map(([slug, config]) => (
                                                <CheckboxControl
                                                    key={slug}
                                                    label={getTypeLabel(slug, config)}
                                                    checked={
                                                        attributes.contentTypes?.includes(slug) || false
                                                    }
                                                    onChange={(checked) =>
                                                        toggleContentType(slug, checked)
                                                    }
                                                    help={
                                                        config.taxonomies?.length > 0
                                                            ? `Filters: ${config.taxonomies.join(', ')}`
                                                            : null
                                                    }
                                                />
                                            ))}
                                        </>
                                    )}
                                    {groupedTypes.taxonomy.length > 0 && (
                                        <>
                                            <h4 style={{ marginTop: '20px' }}>
                                                {__('Content Taxonomies', 'jvb')}
                                            </h4>
                                            <p style={{ fontSize: '12px', color: '#757575' }}>
                                                {__('These are collections that group other content', 'jvb')}
                                            </p>
                                            {groupedTypes.taxonomy.map(([slug, config]) => (
                                                <CheckboxControl
                                                    key={slug}
                                                    label={getTypeLabel(slug, config)}
                                                    checked={
                                                        attributes.contentTypes?.includes(slug) || false
                                                    }
                                                    onChange={(checked) =>
                                                        toggleContentType(slug, checked)
                                                    }
                                                    help={
                                                        config.for_content?.length > 0
                                                            ? `Contains: ${config.for_content.join(', ')}`
                                                            : null
                                                    }
                                                />
                                            ))}
                                        </>
                                    )}
                                    {!attributes.contentTypes?.length && (
                                        <Notice status="warning" isDismissible={false}>
                                            {__('Please select at least one content type', 'jvb')}
                                        </Notice>
                                    )}
                                </>
                            )}
                        </>
                    )}
                </PanelBody>
                <PanelBody
                    title={__('Display Settings', 'jvb')}
                    initialOpen={false}
                >
                    <ToggleControl
                        label={__('Show Gallery View', 'jvb')}
                        help={__('Enable lightbox for images', 'jvb')}
                        checked={attributes.enableGallery || false}
                        onChange={(value) =>
                            setAttributes({ enableGallery: value })
                        }
                    />
                </PanelBody>
            </InspectorControls>
            <div className="feed-block-placeholder">
                <div className="feed-block-icon">
                    <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
                        <rect x="3" y="3" width="7" height="7" fill="currentColor" opacity="0.3" />
                        <rect x="13" y="3" width="7" height="7" fill="currentColor" opacity="0.3" />
                        <rect x="3" y="13" width="7" height="7" fill="currentColor" opacity="0.3" />
                        <rect x="13" y="13" width="7" height="7" fill="currentColor" opacity="0.3" />
                    </svg>
                </div>
                <h3>{__('Feed Block', 'jvb')}</h3>
                {attributes.inheritQuery ? (
                    <p className="feed-block-description">
                        {__('📍 Inheriting from page context', 'jvb')}
                    </p>
                ) : (
                    <div className="feed-block-description">
                        {attributes.contentTypes?.length > 0 ? (
                            <>
                                <p><strong>{__('Showing:', 'jvb')}</strong></p>
                                <ul style={{
                                    listStyle: 'none',
                                    padding: '0',
                                    margin: '8px 0'
                                }}>
                                    {attributes.contentTypes.map(type => {
                                        const config = feedTypes?.[type];
                                        return (
                                            <li key={type} style={{
                                                padding: '4px 0',
                                                color: '#2271b1'
                                            }}>
                                                ✓ {config?.plural || type}
                                            </li>
                                        );
                                    })}
                                </ul>
                            </>
                        ) : (
                            <p style={{ color: '#d63638' }}>
                                {__('⚠️  No content types selected', 'jvb')}
                            </p>
                        )}
                    </div>
                )}
                <p className="feed-block-note">
                    {__('Feed will be displayed on the frontend', 'jvb')}
                </p>
            </div>
        </div>
    );
}
src/feed/editor.scss
New file
@@ -0,0 +1,128 @@
.feed-content-types {
    margin-bottom: 16px;
    .components-base-control__label {
        margin-bottom: 8px;
        font-weight: 500;
    }
    .components-checkbox-control {
        margin-bottom: 8px;
        &:last-child {
            margin-bottom: 0;
        }
        .components-checkbox-control__input-container {
            margin-right: 8px;
        }
    }
}
.feed-block {
    border: 1px solid #ddd;
    padding: 20px;
    background: white;
    .feed-block-preview {
        .filter-preview {
            display: flex;
            gap: 8px;
            margin: 16px 0;
            flex-wrap: wrap;
            .content-type-badge {
                background: #f0f0f0;
                padding: 4px 8px;
                border-radius: 4px;
                font-size: 12px;
            }
        }
        .feed-grid-placeholder {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            gap: 16px;
            margin-top: 20px;
            .grid-item-placeholder {
                background: #f0f0f0;
                aspect-ratio: 1;
                border-radius: 4px;
            }
        }
    }
}
.feed-content-types {
    margin-bottom: 16px;
    .components-base-control__label {
        margin-bottom: 8px;
        font-weight: 500;
    }
    .checkbox-list {
        border: 1px solid #ddd;
        border-radius: 4px;
        max-height: 200px;
        overflow-y: auto;
        padding: 8px;
        background: white;
        .components-checkbox-control {
            margin: 4px 0;
            &:first-child {
                margin-top: 0;
            }
            &:last-child {
                margin-bottom: 0;
            }
        }
    }
    .select-all-wrapper {
        margin-top: 8px;
        padding-top: 8px;
        border-top: 1px solid #ddd;
    }
    .components-checkbox-control__input-container {
        margin-right: 8px;
    }
}
.feed-block {
    border: 1px solid #ddd;
    padding: 20px;
    background: white;
    .feed-block-preview {
        .filter-preview {
            display: flex;
            gap: 8px;
            margin: 16px 0;
            flex-wrap: wrap;
            .content-type-badge {
                background: #f0f0f0;
                padding: 4px 8px;
                border-radius: 4px;
                font-size: 12px;
            }
        }
        .feed-grid-placeholder {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            gap: 16px;
            margin-top: 20px;
            .grid-item-placeholder {
                background: #f0f0f0;
                aspect-ratio: 1;
                border-radius: 4px;
            }
        }
    }
}
src/feed/index.js
New file
@@ -0,0 +1,39 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import save from './save';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
    /**
     * @see ./save.js
     */
    save,
} );
src/feed/index.php
New file
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
src/feed/render.php
New file
@@ -0,0 +1,4 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
src/feed/save.js
New file
@@ -0,0 +1,3 @@
export default function save() {
    return null; // Dynamic block rendered by PHP
}
src/feed/style.scss
New file
@@ -0,0 +1,956 @@
//.feed-block {
//  max-width: var(--full);
//  margin: 0 auto;
//
//  &:target {
//      scroll-snap-margin-top: 5rem;
//      scroll-margin-top: 5rem;
//      outline: 0;
//      border-radius: 0;
//      padding: 0;
//
//      .feed-item {
//          outline: double var(--pink-0);
//      }
//  }
//}
//
//.loading .feed-block {
//  opacity: .7;
//}
//
//.label {
//  display: flex;
//  align-items: center;
//  gap: .25rem;
//  font-size: .9rem;
//  a:hover {
//      color: var(--pink-0);
//  }
//}
//
///** Filters Form **/
//.feed-filters {
//  margin: 2rem 0;
//  position: sticky;
//  top: 3rem;
//  z-index: 15;
//  background: rgba(var(--base-rgb),var(--op-6));
//  padding: .25rem 3rem;
//  details[open] summary {
//      background-color: var(--overlay);
//  }
//  summary {
//      justify-content: flex-start;
//
//      > * {
//          order: 3;
//      }
//      .label {
//          order: 1;
//      }
//      .filter-label {
//          order: 2;
//      }
//      &::after {
//          order: 4;
//      }
//      #favourites + label {
//          margin-left: auto;
//      }
//      #favourites + label:hover,
//      #favourites:checked + label {
//          border-color: var(--pink-0);
//          background-color: var(--pink-0);
//          color: var(--white);
//
//      }
//      #favourites:checked + label {
//          animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
//      }
//  }
//
//  details[open],
//  summary:hover {
//      background-color: rgba(var(--base-rgb),var(--op-6));
//  }
//
//  &:has(#favourites) {
//      summary::after {
//          margin-left: 1rem;
//      }
//  }
//}
//summary > * {
//  order: 3;
//}
//summary .label {
//  order: 1;
//}
//.filter-label {
//  display: inline-block;
//  vertical-align: middle;
//  height: 1.3em;
//  order: 2;
//  margin: 0;
//  padding: 0;
//
//  li {
//      list-style: none;
//      height: 0;
//      overflow: hidden;
//      &.active {
//          height: 100%;
//      }
//  }
//}
//.filter-group {
//  display: flex;
//  align-items: center;
//  gap: .5rem;
//  flex-wrap: wrap;
//  label {
//      font-weight: 100;
//  }
//  &:has(.order-by) {
//      justify-content: space-between;
//  }
//  .order-by,
//  .order-direction {
//      display: flex;
//      flex-wrap: wrap;
//      gap: .5rem;
//      flex: 1;
//      justify-content: flex-start;
//      .label {
//          width: 100%;
//      }
//
//      label:has(.label) {
//          padding: 0 .35rem;
//          gap: .5rem;
//          .label {
//              font-size: 1rem;
//          }
//      }
//  }
//}
//.filter-group >.label {
//  width: 100%;
//}
//
///**
//Feed Grid
// */
//.feed-grid {
//  display: grid;
//  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
//  gap: .5rem;
//  margin-bottom: 2rem;
//  padding: 0 4rem;
//  --delay: 0s;
//  --increase: .1s;
//}
//.feed-empty-state {
//  grid-column: 1/-1;
//  text-align: center;
//  padding: 2rem;
//  background: var(--base-100);
//  border-radius: var(--radius);
//  margin: 0 auto;
//  max-width: 600px;
//}
///**
//Placeholders
// */
//.placeholder {
//  aspect-ratio: 1;
//  background: var(--base);
//  border: 1rem solid var(--base-50);
//  border-radius: 1rem;
//  display: flex;
//  justify-content: center;
//  align-items: center;
//
//  .icon {
//      --w: 50%;
//      color: var(--base-200);
//      animation: dance 2.5s ease-in-out infinite;
//
//  }
//}
//
///**
//Feed Items
// */
//.feed-item {
//  position: relative;
//  border-radius: 0.5rem;
//  overflow: hidden;
//  background: var(--base-50);
//  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
//  opacity: 0;
//  transition: opacity var(--trans-base) var(--delay);
//  height: fit-content;
//  padding: 0;
//
//  img {
//      opacity: .7;
//      filter: grayscale(.5) sepia(.3) blur(7px);
//  }
//  &[data-loaded=true] {
//      img {
//          opacity: 1;
//          filter: none;
//      }
//  }
//
//  a {
//      &::before,
//      &::after {
//          display: none;
//      }
//  }
//  details a {
//      font-size: clamp(1rem, 0.9306rem + 0.2222vw, 1.125rem);
//  }
//
//  &[data-loaded] {
//      opacity: 1;
//      + [data-loaded] {
//          --delay: var(--delay) + var(--increase);
//      }
//  }
//
//  &.highlighted {
//      box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1);
//      animation: highlight-puls 2s ease-in-out;
//  }
//  &[open],
//  &:hover {
//      .handle {
//          background-color: var(--overlay-pink-medium);
//          backdrop-filter: blur(5px);
//      }
//  }
//  summary {
//      width: calc(100% - 1rem);
//      height: 100%;
//      aspect-ratio: 1;
//      .handle {
//          position: absolute;
//          bottom: 0;
//          left: 0;
//          right: 0;
//          background-color: rgba(var(--base-rgb),var(--op-3));
//          backdrop-filter: blur(5px);
//          border-radius: var(--radius);
//          z-index: 1;
//          padding: .25rem .25rem .25rem 1.1rem;
//      }
//      &::after {
//          z-index: 11;
//          position: absolute;
//          bottom: .35rem;
//          right: .7rem;
//          width: 1.5rem;
//          height: 1.5rem;
//          cursor: pointer;
//      }
//  }
//
//  label {
//      font-weight: normal;
//      text-transform: none;
//      .icon {
//          --w: 1.5em;
//      }
//  }
//}
///**
//Load More Button
// */
//.load-more {
//  opacity: 1;
//  margin: 1rem auto;
//  width: 66.666%;
//  display: flex;
//  align-items: center;
//  gap: .5rem;
//  padding: .75rem 1.5rem;
//  background: var(--base);
//  color: var(--contrast);
//  border-radius: 4px;
//  font-size: var(--txt-medium);
//  transition: all var(--trans-base);
//  border: 2px solid transparent;
//  &[hidden] {
//      opacity: 0;
//      transition: all var(--trans-base);
//  }
//  &:hover {
//      background: var(--pink-50);
//      border-color: var(--contrast);
//      color: var(--white);
//  }
//}
///**
//favourite button
// */
//button.favourite {
//  position: absolute;
//  top: .5rem;
//  right: .5rem;
//  z-index: 10;
//  background: rgba(var(--base-rgb),var(--op-4));
//  border-radius: 50%;
//  box-shadow: rgba(var(--base-rgb),var(--op-45)) var(--shdw-subtle);
//  border: none;
//  width: 2rem;
//  height: 2rem;
//  display: flex;
//  justify-content: center;
//  align-items: center;
//  backdrop-filter: blur(5px);
//  transition: all var(--trans-base);
//
//  &:hover {
//      transform: scale(1.1);
//      color: var(--pink-0);
//      background: var(--base);
//      box-shadow: 0 4px 8px rgba(0,0,0,0.15);
//  }
//
//  &.favourited {
//      animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
//  }
//}
//
//
///** Images **/
//.feed-image {
//  display: block;
//  aspect-ratio: 1;
//  overflow: hidden;
//  width: 100%;
//  height: 100%;
//}
//.feed-images {
//  width: 100%;
//  height: 100%;
//  &.multi {
//      display :grid;
//      grid-template-columns: repeat(3, 1fr);
//      grid-auto-rows: 1fr;
//      gap: 4px;
//
//      > a {
//          width: 100%;
//          height: 100%;
//          aspect-ratio: 1;
//      }
//      .feed-image {
//          grid-row: span 2;
//          grid-column: span 2;
//      }
//  }
//  img {
//      width: 100%;
//      height: 100%;
//      object-fit: cover;
//      transition: transform var(--trans-t) var(--trans-fn);
//  }
//  a:hover img {
//      transform: scale(1.05);
//  }
//}
//
//.feed-item {
//  &:nth-of-type(4n+2) .multi .feed-image {
//      grid-column: 2 / span 2;
//      grid-row: 1 / span 2;
//  }
//  &:nth-of-type(4n+3) .multi .feed-image {
//      grid-row: 2 / span 2;
//      grid-column: 1 / span 2;
//  }
//  &:nth-of-type(4n+4) .multi .feed-image {
//      grid-column: 2 / span 2;
//      grid-row: 2 / span 2;
//  }
//}
//
///** Item Information **/
//.item-info {
//  padding: .25rem;
//  border-left: 1px solid var(--base-200);
//  >div + div {
//      margin-top: .5em;
//      position: relative;
//
//      &::before {
//          content: '';
//          display: block;
//          position: absolute;
//          top: -.3em;
//          left: -.25rem;
//          width: 66.6%;
//          border-bottom: 1px solid var(--base-200);
//      }
//  }
//  h3 {
//      margin: 0 0 .5em 0!important;
//      font-size: 1.1rem;
//      font-family: var(--body);
//      font-weight: var(--fw-b);
//  }
//  span {
//      text-transform: uppercase;
//      display: flex;
//      align-items: center;
//  }
//  .icon {
//      --w: 1.1em;
//      margin-right: .5em;
//      display: inline-block;
//      vertical-align: middle;
//  }
//}
//.item-list {
//  ul {
//      margin: 0;
//      padding: .5em 0;
//      display: flex;
//      flex-wrap: wrap;
//      gap: .5rem;
//      li {
//          list-style: none;
//      }
//  }
//  a {
//      background-color: var(--pink-0);
//      border: 1px solid transparent;
//      border-radius: 4px;
//      color: var(--light-0);
//      padding: .25em;
//      line-height: .8;
//      &:visited {
//          background-color: var(--pink-100);
//          color: var(--white);
//      }
//      &:visited:hover,
//      &:visited:focus,
//      &:hover,
//      &:focus {
//          background-color: transparent;
//          border-color: var(--contrast);
//          color: var(--contrast);
//      }
//  }
//}
///**
//Loading
// */
//.loading {
//  opacity: .7;
//}
//
//.loading-overlay {
//  position: fixed;
//  top: 0;
//  left: 0;
//  right: 0;
//  bottom: 0;
//  background-color: rgba(var(--base-rgb),var(--op-4));
//  display: flex;
//  align-items: center;
//  justify-content: center;
//  transition: opacity 0.3s ease, visibility 0.3s ease;
//  z-index: 9999;
//  opacity: 0;
//  visibility: hidden;
//}
//.loading .loading-overlay {
//  opacity: 1;
//  visibility: visible;
//
//  &::after {
//      content: '';
//      position: absolute;
//      z-index: -1;
//      inset: 0;
//      background: linear-gradient(
//              90deg,
//              var(--shimmer)
//      );
//      animation: shimmer 3s ease-in-out infinite;
//  }
//
//  .wrapper {
//      background-color: rgba(var(--base-rgb),var(--op-6));
//      padding: 2rem;
//      border-radius: var(--radius);
//      text-align: center;
//      max-width: 90%;
//      width: 400px;
//      height: 300px;
//      z-index: 5;
//      display: flex;
//      justify-content: center;
//      align-items: center;
//      position: relative;
//
//      .spinner {
//          --h: 150px;
//          --w: calc(var(--h) * 2);
//          border-top: 5px solid var(--pink-0);
//          border-radius: 50%;
//          position: absolute;
//          width: var(--w);
//          height: var(--w);
//          top: calc(50% - var(--h));
//          left: calc(50% - var(--h));
//          opacity: .5;
//          z-index: 0;
//          animation: spin 1s var(--trans-t) infinite;
//      }
//      div.icon {
//          height: 50px;
//          width: 50px;
//
//          .icon {
//              --w: 100%;
//              svg {
//                  animation: dance 2s ease-in-out infinite;
//                  transition: color 0.3s ease;
//              }
//          }
//      }
//      .status {
//          height: 200px;
//          width: 100%;
//          z-index: 5;
//          display: flex;
//          flex-direction: column;
//          align-items: center;
//
//          h3 {
//              margin: 1.5rem 0 .25rem!important;
//              color: var(--contrast-200);
//          }
//          .message {
//              margin: 0;
//              max-width: 275px;
//              color: var(--contrast-100);
//              font-size: var(--txt-x-small);
//              animation: flicker 2s infinite;
//          }
//      }
//  }
//}
//
//
///* Animations */
//@keyframes highlight {
//  0%, 100% {
//      box-shadow: none;
//  }
//  50% {
//      box-shadow: 0 0 0 4px var(--pink-0);
//  }
//}
//
//@keyframes pop {
//  0% { transform: scale(1); }
//  50% { transform: scale(1.3); }
//  75% { transform: scale(0.9); }
//  100% { transform: scale(1); }
//}
//
//@keyframes bubble {
//  50%  { box-shadow: 19px 0 0 3px, 38px 0 0 7px, 57px 0 0 3px }
//  100% { box-shadow: 19px 0 0 0, 38px 0 0 3px, 57px 0 0 7px }
//}
//@keyframes highlight-pulse {
//  0%, 100% { box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1); }
//  50% { box-shadow: 0 0 0 8px #FF0080, 0 12px 24px rgba(0, 0, 0, 0.15); }
//}
//
//@keyframes spin {
//  to { transform: rotate(360deg); }
//}
//
//@keyframes shimmer {
//  0% { transform: translateX(-100%); }
//  50%, 100% { transform: translateX(100%); }
//}
//@keyframes dance {
//  0%, 100% { transform: rotate(-5deg) scale(1);}
//  50% { transform: rotate(5deg) scale(1.1); }
//}
//@keyframes flicker {
//  0% { opacity: 0.6; }
//  50% { opacity: 1; }
//  100% { opacity: 0.6; }
//}
//
//
///**
//Accessibility
// */
//
///* Keyboard navigable feed items */
//.feed-item[tabindex="0"] {
//  position: relative;
//}
//
//.feed-item[tabindex="0"]::after {
//  content: '';
//  position: absolute;
//  top: 0;
//  left: 0;
//  right: 0;
//  bottom: 0;
//  pointer-events: none;
//  border: 2px solid transparent;
//  transition: border-color 0.2s ease;
//}
//
//.feed-item[tabindex="0"]:focus::after {
//  border-color: #FF0080;
//}
//
//
//.feed-block .item {
//  summary {
//      a {
//          background-color: var(--action-0);
//          display: flex;
//          gap: .25rem;
//          flex-wrap: nowrap;
//          aspect-ratio: 1;
//          position: relative;
//      }
//      img {
//          width: 50%;
//          height: 100%;
//          object-fit: cover;
//      }
//
//  }
//}
.feed-block {
    grid-column: full;
    .filters {
        padding: 1rem 0;
        max-width:var(--wide);
        margin: 0 auto;
        .remove-term.remove-term {
            width: max-content;
            height: max-content
        }
    }
    .filter-group {
        position: relative;
        padding: 2rem 0;
        .label {
            position: absolute;
            left: 0;
        }
        > .label {
            top: 0;
        }
        [type=radio] {
            position:absolute;
            left: var(--offScreen);
        }
        button, label {
            position: relative;
            padding: .5rem;
            height: max-content;
            &:hover {
                color: var(--action-contrast);
            }
        }
        button:hover .label,
        :checked + label .label,
        label:hover .label {
            opacity: 1;
            visibility: visible;
        }
        &:has(label:hover) :checked + label .label,
        button .label,
        label .label {
            --height: max-content;
            opacity: 0;
            visibility: hidden;
            bottom: -2rem;
            width: max-content;
            white-space: nowrap;
            font-weight: var(--fw-b);
        }
    }
    h3 {
        margin: 0 0 .25rem;
        font-size: var(--medium);
    }
}
/** PLACEHOLDERS **/
.placeholder {
    aspect-ratio: 1;
    background: var(--base);
    border: 1rem solid var(--base-50);
    border-radius: 1rem;
    display: flex;
    justify-content: center;
    align-items: center;
    i.icon {
        --w: 50%;
        color: var(--base-200);
        animation: dance 2.5s ease-in-out infinite;
    }
}
.item-grid {
    padding: 0 var(--chip);
    max-width: 100%;
}
/** FEED ITEM **/
.feed.item {
    position: relative;
    border-radius: 0.5rem;
    overflow: hidden;
    background: var(--base-50);
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    height: fit-content;
    padding: 0;
    details {
        z-index: var(--z-2);
        width: 100%;
        position: relative;
        padding: 0;
        summary {
            position:absolute;
            top: -3rem;
            left:0;
            width: 100%;
            background-color: rgba(var(--base-rgb),var(--op-2));
            backdrop-filter: blur(5px);
            &:hover {
                background-color: rgba(var(--action-rgb),var(--op-45));
            }
        }
        &[open] {
            padding: .25rem .5rem;
            summary .icon {
                opacity: 0;
            }
        }
    }
    img {
        object-fit: cover;
        width: 100%;
        height: 100%;
        //opacity: .7;
        //filter: grayscale(.5) sepia(.3);
        &:hover {
            opacity: .8;
        }
    }
    &[data-timeline] {
        .images {
            aspect-ratio: 3/2;
            padding: 0 0 1rem;
            span {
                width: 50%;
                position: absolute;
                background-color: var(--action-0);
                color: var(--action-contrast);
                padding: .25rem .5rem;
                &:first-of-type {
                    bottom: 0;
                    right: 50%;
                    text-align: right;
                }
                &:last-of-type {
                    top: 0;
                    left: 50%;
                }
            }
            > a {
                position: relative;
                display: flex;
                flex-wrap: nowrap;
                width: 100%;
                height: 100%;
            }
        }
        img {
            width: 50%;
            object-fit: cover;
            height: 100%;
            &:first-of-type {
                border-right: 1px solid var(--action-0);
            }
        }
    }
    a {
        &::before,
        &::after {
            display: none;
        }
    }
    label {
        font-weight: normal;
        text-transform: none;
        .icon {
            --w: 1.5em;
        }
    }
}
.item-grid:has([data-timeline]) {
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.items-wrap [type=radio],
.items-wrap [type=checkbox] {
    position: absolute;
    opacity: 0;
    left: -200vw;
}
.items-wrap [type=radio] + label,
.items-wrap [type=checkbox] + label {
    position: relative;
    cursor: pointer;
}
.items-wrap [type=radio] + label:hover,
.items-wrap [type=checkbox] + label:hover {
    color: var(--action-0);
}
.items-wrap [type=radio] + label::before,
.items-wrap [type=checkbox] + label::before,
.items-wrap [type=radio] + label::after,
.items-wrap [type=checkbox] + label::after {
    content: '';
    position: absolute;
    top: 50%;
}
.items-wrap [type=radio] + label::after,
.items-wrap [type=checkbox] + label::after {
    left: 5px;
    transform: translateY(-70%) rotate(45deg);
    width: 5px;
    height: 10px;
    border: solid var(--light-0);
    border-width: 0 2px 2px 0;
    display: none;
}
.items-wrap [type=radio] + label::before,
.items-wrap [type=checkbox] + label::before {
    left: 0;
    transform: translateY(-50%);
    width: 1rem;
    height: 1rem;
    border: 2px solid var(--contrast-200);
    background-color: var(--base);
    border-radius: var(--radius);
}
.items-wrap [type=radio]:hover + label::before,
.items-wrap [type=checkbox]:hover + label::before {
    border-color: var(--action-200);
}
.items-wrap [type=radio]:checked + label::before,
.items-wrap [type=checkbox]:checked + label::before{
    background-color: var(--action-0);
    border-color: var(--action-100);
}
.items-wrap [type=radio]:checked + label::before {
    border-radius: 50%;
}
.items-wrap [type=checkbox]:checked + label::after {
    display: block;
    left: 5px;
    top: 50%;
    transform: translateY(-70%) rotate(45deg);
    width: .35rem;
    height: .66rem;
    border: solid var(--light-0);
    border-width: 0 2px 2px 0;
}
.items-wrap :disabled + label {
    cursor: not-allowed;
    background-color: var(--base-50);
    color: var(--base-200);
    border-color: var(--base-200);
}
.items-wrap :disabled + label:hover {
    background-color: var(--base-50);
    color: var(--base-200);
    border-color: var(--base-200);
}
.items-wrap :disabled + label::before {
    border-color: var(--base-200);
}
#jvb-selector .items-wrap [type=radio] + label,
#jvb-selector .items-wrap [type=checkbox] + label{
    flex: 1;
    padding-left: 2rem!important;
    transform-origin: top center;
    will-change: transform;
}
.feed-block + footer {
    grid-column: full;
    padding: 0!important;
    margin: 0;
    background-color: var(--base-50);
    z-index: 0;
    display: flex;
    justify-content: flex-end;
    button {
        width: max-content;
        margin-left: auto;
        padding: .35rem .25rem;
        --w: 1.3em!important;
        flex-wrap: nowrap;
        justify-content:flex-start;
        transition: var(--trans-size);
        font-size: var(--txt-x-small);
        min-height: 0;
        span {
            display: none;
            white-space: nowrap;
        }
        &:focus span,
        &:hover span {
            display: block;
        }
    }
}
src/feed/view.js
New file
@@ -0,0 +1,747 @@
class FeedBlock {
    constructor() {
        this.container = document.querySelector('section.feed-block');
        if(!this.container) return;
        this.a11y = window.jvbA11y;
        this.error = window.jvbError;
        this.cache = new window.jvbCache('feed');
        this.templates = window.jvbTemplates;
        this.config = {
            source: '',
            context: '',
            highlight: null,
            gallery: false,
            view: this.cache.get('feedView') || 'grid',
            ... this.container.dataset
        };
        this.init();
    }
    init() {
        this.initElements();
        this.defineTemplates();
        this.initListeners();
        this.initFilters();
        if ('requestIdleCallback' in window) {
            requestIdleCallback(() => {
                this.initStore();
                this.initTaxonomies();
                this.processCachedFilters();
                this.processURLFilters();
                this.updateFilterUI();
                this.initGallery();
            }, { timeout: 2000 });
        } else {
            setTimeout(() => {
                this.initStore();
                this.initTaxonomies();
                this.processCachedFilters();
                this.processURLFilters();
                this.updateFilterUI();
                this.initGallery();
            }, 100);
        }
    }
    initElements() {
        this.selectors = {
            filterTrigger: '[data-filter]',
            filters: {
                actions:    '.filter-actions .toggle-text',
                container:  '.filters',
                content:    '[data-filter="content"]',
                orderby:    '[data-filter="orderby"]',
                order:      '[data-filter="order"]',
                match:      '[data-filter="match"]',
                favourites: '[data-filter="favourites"]',
                taxonomy:   '[data-filter^="taxonomy"]',
            },
            grid:       '.item-grid',
            selected:   '.selected-items',
            buttons: {
                loadMore:       'button.load-more',
                remove:         '.remove-term',
                clearFilters:   'button.clear-filters',
                refresh:        'button[data-action="refresh"]'
            }
        };
        this.ui = window.uiFromSelectors(this.selectors, this.container);
        this.ui.buttons.refresh = document.querySelector(this.selectors.buttons.refresh);
        //Add content and taxonomies
        this.ui.content = this.ui.filters.container.querySelectorAll('[name="content"]');
        if (this.ui.content.length === 0) this.ui.content = false;
        this.ui.taxonomies = this.ui.filters.container.querySelectorAll('[data-taxonomy]');
        if (this.ui.taxonomies.length === 0) this.ui.taxonomies = false;
        this.ui.orderbyWrap = this.ui.filters.container.querySelector('[data-for-order]');
        if (this.ui.orderbyWrap.length === 0) this.ui.orderbyWrap = false;
        this.ui.order = this.ui.filters.container.querySelectorAll('[data-filter="order"]');
        if (this.ui.order.length === 0) this.ui.order = false;
        this.ui.orderby = this.ui.filters.container.querySelectorAll('[data-filter="orderby"]');
        if (this.ui.orderby.length === 0) this.ui.orderby = false;
        this.orderbyFilters = (this.ui.orderby)
            ? Array.from(this.ui.orderby).map(o => o.value)
            : [];
        this.contentTypes = (this.ui.content)
            ? Array.from(this.ui.content).map(c => c.value)
            : [this.container.dataset.content];
        this.taxonomies = (this.ui.taxonomies?.length > 0)
            ? Array.from(this.ui.taxonomies).map(t => t.dataset.taxonomy)
            : [];
    }
    initListeners() {
        this.popStateHandler    = this.handlePopState.bind(this);
        this.clickHandler       = this.handleClick.bind(this);
        this.changeHandler      = this.handleChange.bind(this);
        window.addEventListener('popstate', this.popStateHandler);
        document.addEventListener('click', this.clickHandler);
        document.addEventListener('change', this.changeHandler);
    }
    initFilters() {
        this.allowedFilters = ['content', 'order', 'orderby', 'favourites', 'match'];
        let defaults = {
            content:    this.contentTypes[0],
            orderby:    'date',
            order:      'desc',
            page:       1,
        };
        if (this.config.context) defaults.context = this.config.context;
        if (this.config.source) defaults.source = this.config.source;
        this.filters = defaults;
        this.defaults = {...defaults};
    }
    updateFilterUI() {
        if (this.ui.filters.container) {
            //Get cached inputs
            let groups = [
                this.ui.content,
                this.ui.orderby,
                this.ui.order
            ];
            groups.forEach(group => {
                if(group) {
                    for (let input of group) {
                        let [filter, value] = [input.dataset.filter, input.value];
                        if (!Object.hasOwn(this.store.filters, filter)) break;
                        let doit = this.store.filters[filter] === value;
                        if (doit) {
                            input.checked = doit;
                            break;
                        }
                    }
                }
            });
            if (Object.hasOwn(this.store.filters, 'taxonomy')) {
                for (let [taxonomy, terms] of Object.entries(this.store.filters.taxonomy)) {
                    terms.forEach(termId => {
                        termId = parseInt(termId);
                        const term = this.selector.store.get(termId);
                        if (term) {
                            this.createTermElement(termId);
                        }
                    });
                }
            }
        }
    }
    handlePopState(e) {
        if (e.state?.filters) {
            if (this.processURLFilters()) {
                this.store.setFilters(this.filters);
                this.a11y.announce('Feed filters updated from browser history');
            }
        }
    }
    handleClick(e) {
        if (window.targetCheck(e, this.selectors.buttons.loadMore)) {
            this.nextPage();
        } else if (window.targetCheck(e, this.selectors.buttons.clearFilters)) {
            this.clearFilters();
        }
        let remove = window.targetCheck(e, this.selectors.buttons.remove);
        if (remove) {
            this.removeSelectedTerm(remove);
        }
        let refresh = window.targetCheck(e, this.selectors.buttons.refresh);
        if (refresh) {
            this.store.clearCache();
            this.store.fetch();
        }
        let orderbyButton = window.targetCheck(e, '[data-filter="orderby"]');
        if (orderbyButton && orderbyButton.value === 'random' && orderbyButton.checked) {
            // Already selected random, just re-render to trigger new shuffle
            this.renderItems();
        }
    }
    nextPage() {
        const nextPage = (this.store.filters.page || 1) + 1;
        const maxPage = this.store.lastResponse?.pages || nextPage;
        this.store.setFilters({ page: Math.min(nextPage, maxPage) });
    }
    handleChange(e) {
        const target = e.target;
        if (Object.hasOwn(target.dataset, 'filter')) {
            if (this.allowedFilters.includes(target.dataset.filter)) {
                let filters = {};
                filters[target.dataset.filter] = target.value;
                this.resetFilters(filters);
            }
            switch (target.dataset.filter) {
                case 'content':
                    this.updateContentFor(target.value);
                    break;
                case 'orderby':
                    this.updateOrderOptions(target.value);
                    break;
            }
        }
    }
    clearFilters() {
        this.taxFilters = {};
        window.removeChildren(this.ui.selected);
        this.taxonomies.forEach(tax => {
            let fieldId = this.getFieldId(tax);
            this.selector.selectedTerms.get(fieldId)?.clear();
        });
        this.store.setFilters({
            ...this.defaults,
            taxonomy: null
        });
        this.updateURL();
        this.saveToCacheFilters();
    }
    resetFilters(filters) {
        filters = {
            ...this.store.filters,
            page: 1,
            ... filters
        }
        this.store.setFilters(filters);
        this.updateURL();
        this.saveToCacheFilters();
    }
    getFieldId(taxonomy) {
        return this.selector.getFieldId(Array.from(this.ui.taxonomies).filter(tax => tax.dataset.taxonomy === taxonomy)[0]??null);
    }
    removeSelectedTerm(button) {
        const termId = parseInt(button.dataset.id);
        const taxonomy = button.dataset.taxonomy;
        if (Object.hasOwn(this.taxFilters, taxonomy)){
            this.taxFilters[taxonomy] = this.taxFilters[taxonomy]
                .filter(id => id !== termId);
            if (this.taxFilters[taxonomy].length === 0) {
                delete this.taxFilters[taxonomy];
            }
        }
        button.remove();
        // Find the fieldId for this taxonomy
        const field = this.getFieldId(taxonomy);
        if (field) {
            this.selector.activeField = field;
            // Notify selector to remove from its selectedTerms
            this.selector.removeSelected(termId, field);
        }
        this.resetFilters({
            taxonomy: Object.keys(this.taxFilters).length > 0
                ? this.taxFilters
                : null
        });
    }
    updateContentFor(content) {
        let checkIt = [
            this.ui.taxonomies,
            this.ui.orderby
        ];
        checkIt.forEach(check => {
            if (!check) return;
            check.forEach(button => {
                const forTypes = button.dataset.for?.split(',')??[];
                button.hidden = forTypes.length > 0 && !forTypes.includes(content);
                if (button.hidden && button.checked) {
                    button.checked = false;
                }
            });
        });
    }
    updateOrderOptions(order) {
        if (this.ui.orderbyWrap) {
            let options = this.ui.orderbyWrap.dataset.forOrder.split(',')??[];
            this.ui.orderbyWrap.hidden = !options.includes(order);
        }
    }
    updateFilterControls() {
        const isHidden = Object.keys(this.taxFilters).length === 0;
        if (this.ui.buttons.clearFilters) {
            this.ui.buttons.clearFilters.hidden = isHidden;
        }
        if (this.ui.filters.actions) {
            this.ui.filters.actions.hidden = isHidden;
        }
    }
    async initTaxonomies() {
        this.taxFilters = {};
        this.selector = window.jvbSelector;
        // this.selector.scanExistingFields(this.ui.filters.container);
        // this.taxonomies.map(tax => this.selector.batchFetch.add(tax));
        // this.selector.batchFetchTaxonomies();
        this.selector.subscribe((event, data) => {
            switch (event) {
                case 'selected-terms':
                    this.handleTaxonomyChange(data);
                    break;
            }
        });
    }
    handleTaxonomyChange(data) {
        const {terms, taxonomy } = data;
        if (terms.size === 0) return;
        this.taxFilters[taxonomy] = Array.from(terms);
        this.resetFilters({ taxonomy: this.taxFilters });
        terms.forEach(t => {
            this.createTermElement(t);
        });
        this.updateFilterControls();
    }
    getTaxonomyIcon(taxonomy) {
        let iconButton = Array.from(this.ui.taxonomies)
            .find(t => t.dataset.taxonomy === taxonomy);
        return iconButton?.dataset.icon.trim() || 'tag';
    }
    createTermElement(termId) {
        const term = this.selector.store.get(termId);
        if (!term) return;
        if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
        term.icon = this.getTaxonomyIcon(term.taxonomy);
        this.ui.selected.append(this.templates.create('feedTerm', term));
    }
    processCachedFilters() {
        Object.keys(this.filters).forEach(filter => {
            let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`);
            if (cached && cached !== this.filters[filter]) {
                this.filters[filter] = cached;
            }
        });
    }
    processURLFilters() {
        if (!this.isFirstPage()) return false;
        const params = new URLSearchParams(window.location.search);
        if (!params.toString()) return false;
        let shouldUpdate = false;
        this.allowedFilters.forEach(filter => {
            let value = params.get(`f_${filter}`);
            if (value) {
                shouldUpdate = true;
                this.filters[filter] = value;
            }
        });
        let hasTax = false;
        params.forEach((value, key) => {
            if (key.startsWith('f_tax_')) {
                hasTax = true;
                shouldUpdate = true;
                const taxonomy = key.replace('f_tax_','');
                this.taxFilters[taxonomy] = value.split(',').map(Number);
            }
        });
        if (shouldUpdate) {
            if (hasTax) {
                this.filters.taxonomy = this.taxFilters;
            }
            this.resetFilters(this.filters);
        }
        return true;
    }
    updateURL() {
        const params = new URLSearchParams();
        this.allowedFilters.forEach(key => {
            if (Object.hasOwn(this.store.filters, key) && this.store.filters[key] !== this.defaults[key]) {
                params.set(`f_${key}`, this.store.filters[key]);
            }
        });
        for (let [tax, terms] of Object.entries(this.taxFilters)) {
            if (terms.length > 0) {
                params.set(`f_tax_${tax}`, terms.join(','));
            }
        }
        const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
        const currentURL = window.location.pathname + window.location.search;  // Change this line
        if (newURL !== currentURL) {
            window.history.pushState({filters:this.store.filters}, '', newURL);
        }
    }
    saveToCacheFilters() {
        Object.keys(this.store.filters).forEach(filter => {
            const cacheKey = `${this.config.source}_${this.config.context}_${filter}`;
            if (this.store.filters[filter] !== this.defaults[filter]) {
                this.cache.set(cacheKey, this.store.filters[filter]);
            } else {
                this.cache.remove(cacheKey);
            }
        });
        const taxCacheKey = `${this.config.source}_${this.config.context}_taxonomy`;
        if (Object.keys(this.taxFilters).length > 0) {
            this.cache.set(taxCacheKey, this.taxFilters);
        } else {
            this.cache.remove(taxCacheKey);
        }
    }
    initGallery() {
        this.gallery = (this.config.gallery) ? window.jvbGallery : false;
        if (this.gallery) {
            this.gallery.subscribe((event, data) => {
                if (event === 'load-more' && this.store.lastResponse?.has_more) {
                    this.nextPage();
                }
            });
        }
    }
    initStore() {
        let extraOrderby = this.orderbyFilters.filter(v => !['date','modified','title','random'].includes(v));
        let extraIndexes = [];
        extraOrderby.forEach(orderby =>{
            extraIndexes.push({name:orderby, keyPath: orderby});
        });
        const store = window.jvbStore.register(
            'feed',
            {
                storeName: 'feed',
                endpoint: 'feed',
                keyPath: 'id',
                indexes: [
                    { name: 'content', keyPath: 'content'},
                    { name: 'taxonomy', keyPath: 'taxonomy'},
                    { name: 'user', keyPath: 'user'},
                    { name: 'date', keyPath: 'date'},
                    { name: 'modified', keyPath: 'modified'},
                    { name: 'title', keyPath: 'title'},
                    ... extraIndexes
                ],
                filters: this.filters,
                TTL: 6 * 60 * 60 * 1000, //6 hours
                showLoading: true,
                required: 'content',
            }
        );
        this.store = store.feed;
        this.store.subscribe((event, data) => {
            switch (event) {
                case 'data-loaded':
                    this.renderItems(data.items);
                    this.ui.buttons.loadMore.hidden = true;
                    if (this.store.lastResponse && this.store.lastResponse?.has_more) {
                        this.ui.buttons.loadMore.hidden = !this.store.lastResponse?.has_more??true;
                    }
                    break;
            }
        });
    }
    isFirstPage() {
        return this.store.filters.page === 1;
    }
    renderItems(items = null) {
        items = items??this.store.getFiltered();
        if (this.isFirstPage()) {
            window.removeChildren(this.ui.grid);
        }
        if (items.length === 0) {
            this.showEmptyState();
            this.a11y.announceItems(0, this.isFirstPage());
        } else {
            window.chunkIt(
                items,
                (item) => this.createItemElement(item),
                (fragment) => {
                    this.removePlaceholders();
                    this.ui.grid.append(fragment);
                    if (this.config.gallery) this.gallery.buildGalleryItems('.item img');
                    this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
                    this.a11y.announceItems(items.length, !this.isFirstPage(), this.store.lastResponse?.has_more??false);
                },
                5
            ).then(()=>{});
        }
        this.updateFilterControls();
    }
    showEmptyState() {
        window.removeChildren(this.ui.grid);
        this.ui.grid.append(this.templates.create('emptyState'));
    }
    createItemElement(item) {
        if (typeof item !== 'object') {
            item = this.store.get(item);
            if (!item) return;
        }
        return this.templates.create(`feedItem${window.uppercaseFirst(item.content)}`, item);
    }
    splitIDs(value) {
        return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value);
    }
    isImageField(item, value) {
        if (!Object.hasOwn(item, 'images') || Object.keys(item.images).length === 0) {
            return false;
        }
        let values = this.splitIDs(value);
        return values.some(v =>
            Object.keys(item.images).map(k => parseInt(k)).includes(parseInt(v))
        );
    }
    formatImageFields(element, value, item) {
        let values = this.splitIDs(value); // Convert string to array first
        if (values.length === 0) return;
        if (values.length > 1) {
            let image = element.querySelector('img');
            if (!image) return;
            values.forEach(imgID => {
                let img = image.cloneNode(true);
                this.formatImageField(img, imgID, item);
                element.append(img);
            });
            image.remove();
        } else {
            if (element.tagName !== 'IMG') {
                element = element.querySelector('img');
                if (!element) return;
            }
            this.formatImageField(element, values[0], item);
        }
    }
    formatImageField(element, value, item) {
        let imgData = item.images[value]??false;
        if (!imgData) return;
        [
            element.src,
            element.srcset,
            element.alt
        ] = [
            imgData.tiny,
            `${imgData.tiny} 50w, ${imgData.small} 300w, ${imgData.medium} 1024w`,
            imgData['image-alt-text']
        ]
    }
    isTaxonomyField(item, field) {
        if (!Object.hasOwn(item, 'taxonomies') || Object.keys(item.taxonomies).length === 0) {
            return false;
        }
        return Object.keys(item.taxonomies).includes(field);
    }
    formatTaxonomyField(element, item, field, value) {
        if (element.tagName !== 'UL' || !element.querySelector('li')) return;
        let values = this.splitIDs(value);
        if (values.length === 0) {
            element.remove();
        }
        let listItem = element.querySelector('li');
        for (let termID of values) {
            let term = item.taxonomies[field][termID]??false;
            if (!term) continue;
            let termItem = listItem.cloneNode(true);
            let link = termItem.querySelector('a');
            if (!link) continue;
            let title = window.decodeHTMLEntities(term.title);
            [
                link.href,
                link.title,
                link.textContent
            ] = [
                term.url,
                `See more ${title}`,
                title
            ];
            element.append(termItem);
        }
        listItem.remove();
    }
    isTimeField(el) {
        return el.tagName === 'TIME' || el.querySelector('time') !== null;
    }
    formatTimeField(element, value) {
        if (element.tagName !== 'TIME') {
            element = element.querySelector('time');
            if (!element) return;
        }
        element.setAttribute('datetime', value);
        element.textContent = window.formatTimeAgo(value, 'F Y');
    }
    formatField(element, value) {
        element.textContent = window.decodeHTMLEntities(value);
    }
    addTimelineElements(item, template) {
        let [
            afterEl,
            number,
            started,
            last
        ] = [
            template.querySelector('span.after-text'),
            template.querySelector('[data-field="number"] b'),
            template.querySelector('[data-field="started"] time'),
            template.querySelector('[data-field="updated"] time')
        ];
        if (afterEl) {
            afterEl.textContent = `After ${item.number - 1} Tx`;
        }
        if (number) {
            number.textContent = item.number - 1;
        }
        if (started) {
            this.formatTimeField(started, item.fields.timeline[0]['post_date']);
        }
        if (last) {
            this.formatTimeField(last, item.fields.timeline[item.fields.timeline.length - 1]['post_date']);
        }
    }
    removePlaceholders() {
        const placeholders = this.ui.grid.querySelectorAll('.placeholder');
        if (placeholders.length > 0) {
            placeholders.forEach(p => p.remove());
        }
    }
    defineTemplates() {
        const T = this.templates;
        const f = this;
        T.define('feedTerm', {
            refs: {
                icon: '.icon',
                span: 'span'
            },
            setup({el, refs, manyRefs, data}) {
                el.dataset.id = data.id;
                el.dataset.taxonomy = data.taxonomy;
                if (refs.icon) refs.icon.className = `icon icon=${data.icon}`;
                if (refs.span) refs.span.textContent = window.decodeHTMLEntities(data.name);
            }
        });
        T.define('emptyState');
        this.contentTypes.forEach(content => {
            T.define(`feedItem${window.uppercaseFirst(content)}`, {
                refs: {
                    link: 'a',
                },
                manyRefs: {
                    fields: '[data-field]',
                },
                setup({el, refs, manyRefs, data}) {
                    const isTimeline = Object.hasOwn(el.dataset, 'timeline');
                    if (manyRefs.fields) {
                        for (let field of manyRefs.fields) {
                            if (isTimeline && ['timeline','number'].includes(field.dataset.field)) continue;
                            const value = Object.hasOwn(data.fields, field.dataset.field)? data.fields[field.dataset.field] : false;
                            if (!value) {
                                field.remove();
                                continue;
                            }
                            if (f.isImageField(data, value)) {
                                f.formatImageField(field, value, data);
                            } else if (f.isTaxonomyField(data, field.dataset.field)) {
                                f.formatTaxonomyField(field, data, field.dataset.field, value);
                            } else if (f.isTimeField(field)) {
                                f.formatTimeField(field, value);
                            } else {
                                f.formatField(field, value);
                            }
                        }
                        if (refs.link && data.url !== '') {
                            refs.link.href = data.url;
                            refs.link.title = `View ${data.fields['post_title']??'Item'}`;
                        }
                        if (isTimeline ) f.addTimelineElements(data, el);
                    }
                }
            })
        });
    }
    // addPlaceholders() {
    //  let total = this.contentTypes.length;
    //  const fragment = document.createDocumentFragment();
    //  for (let i = 0; i < 12; i++) {
    //      let template = window.getTemplate('placeholderTemplate');
    //
    //      let rand = Math.floor(Math.random() * total);
    //      let icon;
    //      if (this.ui.content && this.ui.content.length > 0) {
    //          icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true);
    //      } else {
    //          icon = window.getIcon(this.container.dataset.icon);
    //      }
    //      template.append(icon);
    //      fragment.append(template);
    //  }
    //  this.ui.grid.append(fragment);
    // }
}
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
            window.feedBlock = new FeedBlock();
        }
    });
});
src/feed/viewOld.js
New file
@@ -0,0 +1,770 @@
class FeedBlockOld {
    constructor() {
        this.container = document.querySelector('section.feed-block');
        if (!this.container) {
            return;
        }
        this.a11y = window.jvbA11y;
        this.cache = new window.jvbCache('feed');
        this.error = window.jvbError;
        this.config = {
            source: '',
            context: '',
            highlight: null,
            gallery: false,
            view: this.cache.get('feedView') || 'grid',
            ... this.container.dataset
        };
        this.initElements();
        this.initFilters();
        this.loadWhenAble();
    }
    loadWhenAble() {
        if ('requestIdleCallback' in window) {
            requestIdleCallback(() => {
                this.initTaxonomies();
                this.initStore();
                this.initListeners();
                this.initGallery();
            }, { timeout: 2000 });
        } else {
            setTimeout(() => {
                this.initTaxonomies();
                this.initStore();
                this.initListeners();
                this.initGallery();
            }, 100);
        }
    }
    initElements() {
        this.currentTaxonomies = new Set();         // Allowed Taxonomies, grabbed from active buttons
        this.taxonomyFilters = {};
        this.elements = {
            filterTrigger: '[data-filter]',
            filters: {
                content: '[data-filter="content"]',
                orderby: '[data-filter="orderby"]',
                order: '[data-filter="order"]',
                match: '[data-filter="match"]',
                favourites: '[data-filter="favourites"]',
                taxonomy: '[data-filter^="taxonomy"]'
            },
            selectedTax: '.selected-items',
            clearFilter: 'button.clear-filters',
            loadMore: 'button.load-more',
            filterContainer: '.filters',
            grid: '.item-grid',
        };
        this.ui = window.uiFromSelectors(this.elements);
        this.ui.content = this.ui.filterContainer.querySelectorAll('[name="content"]')??false;
        this.ui.taxonomies = this.ui.filterContainer.querySelectorAll('[data-taxonomy]');
        if (this.ui.content && this.ui.content.length > 0) {
            this.contentTypes = Array.from(
                this.ui.content
            ).map(content => content.value);
        } else {
            this.contentTypes = [this.container.dataset['content']];
        }
        if (this.ui.taxonomies.length>0) {
            this.taxonomies = Array.from(
                this.ui.taxonomies,
            ).map(content => content.dataset.taxonomy);
        } else {
            this.taxonomies = [];
        }
    }
    async initTaxonomies() {
        this.selector = window.jvbSelector;
        const buttons = document.querySelectorAll('[data-filter="taxonomy"]');
        this.selector.isInitializing = true;
        buttons.forEach((button) => {
            const taxonomy = button.dataset.taxonomy;
            this.currentTaxonomies.add(taxonomy);
            this.selector.registerFilterButton(button, {
                button: button,
                buttonSelector: '[data-filter="taxonomy"]',
                selected: this.ui.selectedTax
            });
            // Add preload listeners
            this.addTaxonomyPreloadListeners(button, taxonomy);
        });
        this.selector.isInitializing = false;
        this.selector.subscribe((event, data) => {
            if (event === 'selected-terms') this.handleTaxonomyChange(data);
        });
    }
    addTaxonomyPreloadListeners(button, taxonomy) {
        const preload = () => {
            this.selector.preloadTaxonomy(taxonomy);
        };
        // Desktop hover
        button.addEventListener('mouseenter', preload, { once: true });
        // Touch/keyboard (fires before click)
        button.addEventListener('pointerdown', preload, { once: true });
        // Keyboard focus
        button.addEventListener('focus', preload, { once: true });
    }
    handleTaxonomyChange(data) {
        const { terms, taxonomy } = data;
        // Update only the current taxonomy's terms
        if (terms.size > 0) {
            this.taxonomyFilters[taxonomy] = Array.from(terms.keys());
        } else {
            // Remove taxonomy if no terms selected
            delete this.taxonomyFilters[taxonomy];
        }
        // Build filters object with all taxonomies
        let filters = {
            page: 1
        };
        // Add taxonomy filters if any exist
        if (Object.keys(this.taxonomyFilters).length > 0) {
            filters.taxonomy = this.taxonomyFilters;
        }
        this.updateFilter(filters);
    }
    clearAllTaxonomies() {
        this.taxonomyFilters = {};
        window.removeChildren(this.ui.selectedTax);
        this.updateFilter({
            taxonomy: null,
            page: 1
        });
    }
    initFilters() {
        //defaults
        this.filters = {
            content:    this.contentTypes[0],
            orderby:    'date',
            order:      'desc',
            page:       1
        };
        if (this.config.context) this.filters.context = this.config.context;
        if (this.config.source) this.filters.source = this.config.source;
        //check the cache
        this.processCachedFilters();
        //check url
        this.processURLFilters();
        // Set initial UI state
        this.syncUIToFilters();
    }
    syncUIToFilters() {
        if (this.ui.filterContainer) {
            // Check radio buttons
            Object.entries(this.filters).forEach(([key, value]) => {
                const input = this.ui.filterContainer.querySelector(`[data-filter="${key}"][value="${value}"]`);
                if (input) {
                    input.checked = true;
                }
            });
        }
        // Update content-specific visibility
        this.updateContentFor(this.filters.content);
    }
    nextPage() {
        this.store.setFilter('page', this.store.filters.page++);
    }
    initStore() {
        const store = window.jvbStore.register(
            'feed',
            {
                storeName: 'feed',
                endpoint: 'feed',
                keyPath: 'id',
                indexes: [
                    { name: 'content', keyPath: 'content'},
                    { name: 'taxonomy', keyPath: 'taxonomy'},
                    { name: 'user', keyPath: 'user'},
                    { name: 'date', keyPath: 'modified'},
                    { name: 'title', keyPath: 'title'}
                ],
                filters: this.filters,
                TTL: 6 * 60 * 60 * 1000,
                showLoading: true,
                required: 'content',
                delayFetch: true
            }
        );
        this.store = store.feed;
        this.store.subscribe((event, data) => {
            switch (event) {
                case 'data-loaded':
                    this.renderItems();
                    this.ui.loadMore.hidden = true;
                    if (this.store.lastResponse && this.store.lastResponse['has_more']) {
                        this.ui.loadMore.hidden = !this.store.lastResponse['has_more'];
                    }
                    break;
            }
        });
    }
    initGallery() {
        this.gallery = (this.config.gallery) ? window.jvbGallery : false;
        if (this.gallery) {
            this.gallery.subscribe((event, data) => {
                if (event === 'load-more' && this.store.lastResponse) {
                    if (this.store.lastResponse['has_more']) {
                        this.nextPage();
                    }
                }
            });
        }
    }
    processCachedFilters() {
        Object.keys(this.filters).forEach(filter => {
            let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`);
            if (cached && cached !== this.filters[filter]){
                this.filters[filter] = cached;
            }
        });
    }
    processURLFilters() {
        if (this.filters.page > 1) {
            return false;
        }
        const params = new URLSearchParams(window.location.search);
        if (!params.toString()) {
            return false;
        }
        let filters = ['content', 'order', 'orderby', 'favourites', 'match'];
        filters.forEach(filter => {
            let value = params.get(`f_${filter}`);
            if (value) {
                this.filters[filter] = value;
                let input = this.ui.filters[filter];
                if (input) {
                    input.checked = true;
                }
            }
        });
        let hasTaxonomy = false;
        // Load taxonomy filters from URL
        params.forEach((value, key) => {
            if (key.startsWith('f_tax_')) {
                hasTaxonomy = true;
                const taxonomy = key.replace('f_tax_', '');
                if (!this.taxonomyFilters[taxonomy]) {
                    this.taxonomyFilters[taxonomy] = [];
                }
                this.taxonomyFilters[taxonomy] = value.split(',').map(Number);
            }
        });
        if (this.ui.filterContainer && hasTaxonomy) {
            for (let [tax, ids] in Object.entries(this.taxonomyFilters)) {
                let button = this.ui.filterContainer.querySelector(`[data-taxonomy="${tax}"]`);
                if (button) {
                    if (button.dataset.fieldId) {
                        let field = this.selector.get(button.dataset.fieldId);
                        field.selectedTerms = new Set(ids);
                        this.selector.initFieldDisplay(button.dataset.fieldId);
                    } else {
                        this.selector.registerField(button, {
                            button: button,
                            buttonSelector: '[data-filter="taxonomy"]',
                            selected: this.ui.selectedTax,
                            selectedItems: ids
                        });
                    }
                }
            }
        }
        return true;
    }
    /**
     * Update URL with current filters (for sharing/bookmarking)
     */
    updateURL() {
        const params = new URLSearchParams();
        // Add simple filters
        ['content', 'order', 'orderby', 'match'].forEach(key => {
            if (this.filters[key]) {
                params.set(`f_${key}`, this.filters[key]);
            }
        });
        // Add taxonomy filters
        Object.entries(this.taxonomyFilters).forEach(([taxonomy, terms]) => {
            if (terms.length > 0) {
                params.set(`f_tax_${taxonomy}`, terms.join(','));
            }
        });
        // Update URL without reload
        const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
        window.history.pushState({ filters: this.filters }, '', newURL);
    }
    renderItems() {
        let items = this.store.getFiltered();
        if (this.store.filters['page'] === 1) {
            window.removeChildren(this.ui.grid);
        }
        if (items.length === 0) {
            this.a11y.announceItems(0, this.store.filters['page'] > 0);
            return;
        }
        const fragment = document.createDocumentFragment();
        const batchSize = 10;
        const processBatch = (startIndex) => {
            const endIndex = Math.min(startIndex + batchSize, items.length);
            for (let i = startIndex; i < endIndex; i++) {
                const item = items[i];
                const element = this.createItemElement(item);
                fragment.appendChild(element);
            }
            if (endIndex < items.length) {
                requestAnimationFrame(() => processBatch(endIndex));
            } else {
                this.removePlaceholders();
                this.ui.grid.append(fragment);
                if (this.config.gallery) {
                    this.gallery.updateGalleryItems(this.gallery.getGalleryItems());
                }
                this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
                this.a11y.announceItems(items.length, this.store.filters['page'] > 1, this.store.hasMore);
            }
        };
        if (items.length > 0) {
            processBatch(0);
        } else {
            this.a11y.announceItems(0, this.store.filters['page'] >1, false);
        }
        if (this.ui.filters.match) {
            this.ui.filters.match.hidden = Object.keys(this.taxonomyFilters).length === 0;
        }
        if (this.ui.clearFilter) {
            this.ui.clearFilter.hidden = Object.keys(this.taxonomyFilters).length === 0;
        }
    }
    /**
     *
     * @param {object} item
     */
    createItemElement(item) {
        let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`);
        const isTimeline = Object.hasOwn(template.dataset, 'timeline');
        // Format fields using helpers
        for (let [fieldName, value] of Object.entries(item.fields)) {
            if (isTimeline && ['timeline', 'number'].includes(fieldName)) continue;
            let el = template.querySelector(`[data-field="${fieldName}"]`);
            if (!el) continue;
            if (value === '') {
                el.remove();
                continue;
            }
            if (this.isImageField(item, value)) {
                this.formatImageFields(el, value, item);
            } else if (this.isTaxonomyField(item, fieldName)) {
                this.formatTaxonomyField(el, item, fieldName, value);
            } else if (this.isTimeField(el)) {
                this.formatTimeField(el, value);
            } else {
                this.formatField(el, value);
            }
        }
        // Handle link
        let link = template.querySelector('a');
        if (link && item.url !== '') {
            [
                link.href,
                link.title
            ] = [
                item.url,
                `View ${item.fields['post_title']??'Item'}`
            ];
        }
        if (isTimeline) {
            this.addTimelineElements(item, template);
        }
        return template;
    }
    splitIDs(value) {
        return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value);
    }
    isImageField(item, value) {
        if (!Object.hasOwn(item, 'images') || Object.keys(item.images).length === 0) {
            return false;
        }
        let values = this.splitIDs(value);
        return values.some(v =>
            Object.keys(item.images).map(k => parseInt(k)).includes(parseInt(v))
        );
    }
    formatImageFields(element, value, item) {
        let values = this.splitIDs(value); // Convert string to array first
        if (values.length === 0) return;
        if (values.length > 1) {
            let image = element.querySelector('img');
            if (!image) return;
            values.forEach(imgID => {
                let img = image.cloneNode(true);
                this.formatImageField(img, imgID, item);
                element.append(img);
            });
            image.remove();
        } else {
            if (element.tagName !== 'IMG') {
                element = element.querySelector('img');
                if (!element) return;
            }
            this.formatImageField(element, values[0], item);
        }
    }
    formatImageField(element, value, item) {
        let imgData = item.images[value]??false;
        if (!imgData) return;
        [
            element.src,
            element.srcset,
            element.alt
        ] = [
            imgData.tiny,
            `${imgData.tiny} 50w, ${imgData.small} 300w, ${imgData.medium} 1024w`,
            imgData['image-alt-text']
        ]
    }
    isTaxonomyField(item, field) {
        if (!Object.hasOwn(item, 'taxonomies') || Object.keys(item.taxonomies).length === 0) {
            return false;
        }
        return Object.keys(item.taxonomies).includes(field);
    }
    formatTaxonomyField(element, item, field, value) {
        if (element.tagName !== 'UL' || !element.querySelector('li')) return;
        let values = this.splitIDs(value);
        if (values.length === 0) {
            element.remove();
        }
        let listItem = element.querySelector('li');
        for (let termID of values) {
            let term = item.taxonomies[field][termID]??false;
            if (!term) continue;
            let termItem = listItem.cloneNode(true);
            let link = termItem.querySelector('a');
            if (!link) continue;
            [
                link.href,
                link.title,
                link.textContent
            ] = [
                term.url,
                `See more ${term.title}`,
                term.title
            ];
            element.append(termItem);
        }
        listItem.remove();
    }
    isTimeField(el) {
        return el.tagName === 'TIME' || el.querySelector('time') !== null;
    }
    formatTimeField(element, value) {
        if (element.tagName !== 'TIME') {
            element = element.querySelector('time');
            if (!element) return;
        }
        element.setAttribute('datetime', value);
        element.textContent = window.formatTimeAgo(value, 'F Y');
    }
    formatField(element, value) {
        element.textContent = value;
    }
    addTimelineElements(item, template) {
        let [
            afterEl,
            number,
            started,
            last
        ] = [
            template.querySelector('span.after-text'),
            template.querySelector('[data-field="number"] b'),
            template.querySelector('[data-field="started"] time'),
            template.querySelector('[data-field="updated"] time')
        ];
        if (afterEl) {
            afterEl.textContent = `After ${item.fields.number} Tx`;
        }
        if (number) {
            number.textContent = item.fields.number;
        }
        if (started) {
            this.formatTimeField(started, item.fields.timeline[0]['post_date']);
        }
        if (last) {
            this.formatTimeField(last, item.fields.timeline[item.fields.timeline.length - 1]['post_date']);
        }
    }
    removePlaceholders() {
        const placeholders = this.ui.grid.querySelectorAll('.placeholder');
        if (placeholders.length > 0) {
            placeholders.forEach(p => p.remove());
        }
    }
    addPlaceholders() {
        let total = this.contentTypes.length;
        const fragment = document.createDocumentFragment();
        for (let i = 0; i < 12; i++) {
            let template = window.getTemplate('placeholderTemplate');
            let rand = Math.floor(Math.random() * total);
            let icon;
            if (this.ui.content && this.ui.content.length > 0) {
                icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true);
            } else {
                icon = window.getIcon(this.container.dataset.icon);
            }
            template.append(icon);
            fragment.append(template);
        }
        this.ui.grid.append(fragment);
    }
    /**
     *
     * @param {object} filters {name: value}
     */
    updateFilter(filters) {
        //double check filters are what we're expecting
        let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)];
        filters = Object.keys(filters)
            .filter(key => allowed.includes(key))
            .reduce((obj, key) => {
                obj[key] = filters[key];
                return obj;
            }, {});
        if (window.getDifferences.map(this.filters, filters)) {
            this.filters = { ...this.filters, ...filters };  // Merge instead of replace
            this.updateURL();
            this.store.setFilters(filters);
        }
    }
    /**
     * Update visible filters based on selected content type
     */
    updateContentFor(contentType) {
        // Update taxonomy filter visibility
        const taxonomyButtons = this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]');
        taxonomyButtons.forEach(button => {
            const forTypes = button.dataset.for?.split(',') || [];
            button.hidden = forTypes.length > 0 && !forTypes.includes(contentType);
        });
        // Update ordering options
        const orderButtons = this.ui.filterContainer.querySelectorAll('[data-for]');
        orderButtons.forEach(button => {
            const forTypes = button.dataset.for?.split(',') || [];
            if (forTypes.length > 0) {
                button.hidden = !forTypes.includes(contentType);
                // Uncheck if hiding
                if (button.hidden && button.checked) {
                    button.checked = false;
                }
            }
        });
        // Update order direction visibility based on selected orderby
        const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked');
        this.updateOrderDirectionVisibility(orderBy?.value);
    }
    /**
     * Show/hide order direction based on orderby selection
     */
    updateOrderDirectionVisibility(orderBy) {
        const orderDirection = this.ui.filterContainer.querySelector('.order-direction');
        if (orderDirection) {
            const forOrders = orderDirection.dataset.forOrder?.split(',') || [];
            orderDirection.hidden = forOrders.length > 0 && !forOrders.includes(orderBy);
        }
    }
    /*********************************************************************
    LISTENERS
     *********************************************************************/
    initListeners() {
        this.popStateHandler    = this.handlePopState.bind(this);
        this.clickHandler       = this.handleClick.bind(this);
        this.changeHandler      = this.handleChange.bind(this);
        this.imageObserver = null;
        this.resizeObserver = null;
        if ('IntersectionObserver' in window) {
            this.imageObserver = new IntersectionObserver(entries => {
                entries.forEach(entry => {
                    this.loadImage(entry.target);
                    this.imageObserver.unobserve(entry.target);
                });
            }, {
                rootMargin: '100px',
                threshold: .1
            });
        }
        if ('ResizeObserver' in window) {
            this.resizeObserver = new ResizeObserver(() => {
                window.debouncer.schedule(
                    'feed-update-images',
                    () => this.updateImageSizes(),
                    250
                );
            });
        } else {
            window.addEventListener('resize', () => {
                window.debouncer.schedule(
                    'feed-update-images',
                    () => this.updateImageSizes(),
                    250
                );
            });
        }
        window.addEventListener('popstate', this.popStateHandler);
        document.addEventListener('click', this.clickHandler);
        document.addEventListener('change', this.changeHandler);
    }
    handlePopState(e) {
        if (e.state?.filters) {
            if (this.processURLFilters()) {
                this.store.setFilters(this.filters);
                this.a11y.announce('Feed filters updated from browser history');
            }
        }
    }
    handleClick(e) {
        if (window.targetCheck(e, this.elements.loadMore)) {
            this.nextPage();
        } else if (window.targetCheck(e, this.elements.clearFilter)) {
            this.clearAllTaxonomies();
        } else if (window.targetCheck(e, '.remove-item')) {
            this.handleRemoveSelectedTerm(e);
        }
    }
    handleRemoveSelectedTerm(e) {
        const selectedItem = e.target.closest('.selected-item');
        if (!selectedItem) return;
        const termId = parseInt(selectedItem.dataset.id);
        const taxonomy = selectedItem.dataset.taxonomy;
        // Remove from filters
        if (this.taxonomyFilters[taxonomy]) {
            this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy]
                .filter(id => id !== termId);
            if (this.taxonomyFilters[taxonomy].length === 0) {
                delete this.taxonomyFilters[taxonomy];
            }
        }
        // Remove from UI
        selectedItem.remove();
        // Update filters
        this.updateFilter({
            taxonomy: Object.keys(this.taxonomyFilters).length > 0
                ? this.taxonomyFilters
                : null,
            page: 1
        });
    }
    handleChange(e) {
        let target = e.target;
        if (Object.hasOwn(target.dataset, 'filter')) {
            if (target.dataset.filter === 'content') {
                this.updateContentFor(target.value);
                this.updateFilter({ content: target.value, page: 1 });
            } else if (target.dataset.filter === 'orderby') {
                this.updateOrderDirectionVisibility(target.value);
                this.updateFilter({ orderby: target.value, page: 1 });
            } else if (target.dataset.filter === 'order') {
                this.updateFilter({ order: target.value, page: 1 });
            } else if (target.dataset.filter === 'match') {
                this.updateFilter({ match: target.checked ? 'all' : 'any', page: 1 });
            } else if (target.dataset.filter === 'favourites') {
                this.updateFilter({ favourites: target.checked, page: 1 });
            }
        }
    }
}
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
            window.feedBlock = new FeedBlock();
        }
    });
});
src/fields/block.json
New file
@@ -0,0 +1,25 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/fields",
    "title": "JakeVan Fields",
    "category": "jvb",
    "icon": "ellipses",
    "description": "Access data from your custom fields",
    "keywords": [ "field", "custom", "jake" ],
    "version": "0.9.0",
    "textdomain": "jvb",
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "selectors": {
        "root": ".jvb-f"
    },
    "styles": [],
    "render": "file:./render.php",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/fields/edit.js
New file
@@ -0,0 +1,29 @@
/**
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { SelectControl, ToggleControl, PanelBody } from '@wordpress/components';
/**
 * Styles
 */
import './editor.scss';
/**
 * Edit function for Summary Block
 */
export default function Edit({ attributes, setAttributes }) {
    const blockProps = useBlockProps();
    return (
        <div {...blockProps}>
            <div className="jvb-summary-preview">
                <h3>{__('Summary', 'jvb')}</h3>
                <p className="jvb-list-preview-note">
                    {__('This will inherit the current query to build the information from our custom meta on the front end.', 'jvb')}
                </p>
            </div>
        </div>
    );
}
src/fields/editor.scss
New file
@@ -0,0 +1,20 @@
/**
 * Directory List Block Editor Styles
 */
.jvb-summary-preview {
    padding: 20px;
    background-color: #f8f9fa;
    border: 1px solid #e2e4e7;
    border-radius: 4px;
    h3 {
        margin-top: 0;
        padding-bottom: 10px;
        border-bottom: 1px solid #ff0080;
    }
    &-note {
        font-style: italic;
        color: #555d66;
        margin-bottom: 0;
    }
}
src/fields/index.js
New file
@@ -0,0 +1,39 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import save from './save';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
    /**
     * @see ./save.js
     */
    save,
} );
src/fields/index.php
src/fields/render.php
New file
@@ -0,0 +1,320 @@
<?php
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\meta\Render;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Summary Block Render
 *
 * @package Edmonton_Ink
 */
function jvbRenderSummaryBlock(array $attributes):string
{
    // Buffer output
    if (is_tax()) {
        switch (get_queried_object()->taxonomy) {
            case BASE.'shop':
                return jvbRenderShopSummary();
            default:
                return jvbRenderTermSummary();
        }
    } elseif (is_singular()) {
        return jvbRenderArtistSummary();
    }
    return '';
}
function jvbRenderArtistSummary():string
{
    $current = get_queried_object();
    $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
    $key = $current->ID;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $meta = Meta::forPost($current->ID);
    $artist = jvbContentFromUser((int)$current->post_author);
    $registrar = Registrar::getInstance($current->post_type);
    $sections = [];
    if ($registrar) {
        $sections = $registrar->getSections();
    }
//    $handler = JVB()->getContent(str_replace(BASE,'', $current->post_type));
    ?>
    <nav id="artist" class="on-this-page index">
        <label>Jump to:
            <button type="button" aria-label="Show Index" title="Show Index" class="toggle" aria-expanded="false">
                <?= jvbIcon('plus-square')?>
            </button>
        </label>
        <ul>
            <li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li>
            <li><a href="#about">About</a></li>
            <li><a href="#styles">Styles</a></li>
            <li><a href="#contact">Contact</a></li>
            <li><a href="#work">Work</a></li>
        </ul>
    </nav>
    <header id="top">
        <h1><small><?=(!empty($artist['city'])) ? $artist['city']['name'] :'Edmonton'?>'s Best <?= (!empty($artist['type'])) ?
                    $artist['type']['name']:'Tattoo Artists'?>:
            </small><?=$artist['display_name']?></h1>
        <div>
            <?php if (!empty($artist['shop'])) : ?>
            <ul class="term-list shop">
                <li>
                    <a href="<?=$artist['shop']['url']?>" title="Learn more about <?=$artist['shop']['name']?>">
                        <?= strtolower($artist['shop']['name'])?>
                    </a>
                </li>
            </ul>
            <?php endif; ?>
            <?php if (!empty($artist['city'])): ?>
            <ul class="term-list city">
                <li>
                    <a href="<?=$artist['city']['url']?>" title="See who else is rocking out of <?=$artist['city']['name']?>">
                        <?= strtolower($artist['city']['name'])?>
                    </a>
                </li>
            </ul>
            <?php endif; ?>
            <?php $styles = $meta->get('top_styles');
            if (!empty($styles)) {
                ?>
                <ul class="term-list style">
                    <?php
                    foreach ($styles as $style) {
                        $term = get_term((int)$style, BASE.'style');
                        if ($term && !is_wp_error($term)) {
                            $link = get_term_link((int)$style, BASE.'style');
                            ?>
                            <li>
                                <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
                                    <?=strtolower(html_entity_decode($term->name))?>
                                </a>
                            </li>
                            <?php
                        }
                    }
                    ?>
                </ul>
                <?php
            }
            ?>
        </div>
    </header>
    <section>
        <details class="bio-info">
            <summary class="row x-btw">
                <h2>About <?= ($artist['name'] !== '') ? $artist['name'] : strtok($artist['display_name'], ' ')?></h2>
            </summary>
            <div class="columns stack-small">
                <div class="column">
                    <?= Render::renderFrom($meta, 'image_portrait'); ?>
                </div>
                <div class="column">
                    <?= Render::renderFrom($meta, 'short_bio'); ?>
                </div>
            </div>
            <div id="styles">
                <h3>Works In</h3>
                <?= jvbGetTheTerms('style', $current->ID) ?>
            </div>
            <div class="contact">
                <h3>Contact:</h3>
                <?php
                echo jvbRenderContactInfo($current->ID, $meta);
                echo jvbRenderLinks($current->ID, $meta);
                ?>
            </div>
            <div id="about">
                <?= Render::renderFrom($meta, 'bio')?>
            </div>
        </details>
    </section>
    <section id="contact" class="">
        <h2>Contact <?=$artist['name']?></h2>
        <?php
        echo jvbRenderContactInfo($current->ID, 'post');
        echo jvbRenderLinks($current->ID, 'post');
        ?>
    </section>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
function jvbRenderShopSummary()
{
    $current = get_queried_object();
    $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
    $key = $current->term_id;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $meta = Meta::forTerm($current->term_id);
    $fields = $meta->getAll(['average_rating', 'established', 'bio','location','hours','specialties','awards','reviews']);
    ?>
    <nav id="shop" class="on-this-page index">
        <label>Jump to:
            <button type="button" aria-label="Show Index" title="Show Index" class="toggle" aria-expanded="false">
                <?= jvbIcon('plus-square')?>
            </button>
        </label>
        <ul>
            <li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li> <?php
            if ($fields['rating'] !== 'none') {
                ?>
                <li><a href="#rating">Rating</a></li>
                <?php
            } elseif ($fields['opened'] !== '') {
                ?>
                <li><a href="#opened">Opened</a></li>
                <?php
            } elseif ($fields['location'] !== '') {
                ?>
                <li><a href="#location">Location</a></li>
                <?php
            } elseif ($fields['about'] !== '') {
                ?>
                <li><a href="#about">About</a></li>
                <?php
            } elseif ($fields['hours'] !== '') {
                ?>
                <li><a href="#hours">Hours</a></li>
                <?php
            } elseif ($fields['specialties'] !== '') {
                ?>
                <li><a href="#specialties">Specialties</a></li>
                <?php
            } elseif ($fields['awards'] !== '') {
                ?>
                <li><a href="#awards">Awards</a></li>
                <?php
            } elseif ($fields['reviews'] !== '') {
                ?>
                <li><a href="#reviews">Reviews</a></li>
                <?php
            }
            ?>
            <li><a href="#contact">Contact</a></li>
            <li><a href="#artists">Artists</a></li>
        </ul>
    </nav>
    <header id="top">
        <div class="columns stack-small">
            <div class="column">
                <?=jvbFormatImage($meta->get('image'))?>
            </div>
            <div class="column">
                <h1>
                    <small><?= (get_term((int)$meta->get('city'), BASE.'city')) ?
                            get_term((int)$meta->get('city'), BASE.'city')->name :
                            'Edmonton'?>'s Best Tattoo Shops</small>
                    <?=$current->name?>
                </h1>
                <?= jvbFormatRating($current->term_id, 'term') ?>
                <?= Render::renderFrom($meta,   'slogan'); ?>
            </div>
        </div>
    </header>
    <section>
        <details class="bio-info">
            <summary class="row x-btw">
                <h2>Learn More About <?=$current->name?></h2>
            </summary>
            <div class="map">
                <?= Render::renderFrom($meta, 'location'); ?>
            </div>
            <div class="short-bio">
                <?= Render::renderFrom($meta, 'short_bio'); ?>
            </div>
            <div class="contact">
                <h3>Contact:</h3>
                <?php
                echo jvbRenderContactInfo($current->term_id, 'term');
                echo jvbRenderLinks($current->term_id, 'term');
                ?>
            </div>
            <div id="about">
                <?= Render::renderFrom($meta, 'bio')?>
            </div>
        </details>
    </section>
    <section id="contact" class="">
        <h2>Contact </h2>
        <?php
        echo jvbRenderContactInfo($current->term_id, 'term');
        echo jvbRenderLinks($current->term_id, 'term');
        ?>
    </section>
    <?= jvbRenderHours($current->term_id, 'term')?>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
function jvbRenderTermSummary()
{
    $current = get_queried_object();
    $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
    $key = $current->ID;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $tax = jvbNoBase($current->taxonomy);
    switch ($tax) {
        case 'style':
            $title = 'Tattoo Artists';
            break;
        case 'theme':
            $title = 'Tattoos';
            break;
        default:
            $title = '';
    }
    $meta = Meta::forTerm($current->ID);
    $fields = $meta->getAll();
    ?>
    <header id="top">
        <h1><?= get_the_archive_title() ?></h1>
    </header>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
src/fields/save.js
New file
@@ -0,0 +1,3 @@
export default function save() {
    return null; // Dynamic block rendered by PHP
}
src/fields/style.scss
New file
@@ -0,0 +1,20 @@
details > div {
    margin: 1rem 0;
}
main > header:not(:has(img)) {
    margin-top: 3rem!important;
}
header a::before {
    display: none!important;
}
header + details {
    margin: 1.5rem auto 3rem!important;
    max-width: var(--wide);
}
main {
    padding-top: 0!important;
}
src/fields/view.js
New file
@@ -0,0 +1 @@
src/forms/block.json
New file
@@ -0,0 +1,47 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/forms",
    "title": "Contact Forms",
    "category": "jvb",
    "icon": "align-center",
    "description": "Our custom contact forms",
    "keywords": [ "form", "forms", "contact" ],
    "version": "1.0.0",
    "textdomain": "jvb",
    "attributes": {
        "formType": {
            "type": "string",
            "default": ""
        },
        "showLabels": {
            "type": "boolean",
            "default": true
        },
        "customEmailTo": {
            "type": "string",
            "default": ""
        }
    },
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "selectors": {
        "root": ".jvb-form-block"
    },
    "styles": [
        { "name": "default", "label": "Default", "isDefault": true }
    ],
    "example": {
        "attributes": {
            "formType": "contact",
            "showLabels": true
        }
    },
    "render": "file:./render.php",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./editor.scss",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/forms/edit.js
New file
@@ -0,0 +1,319 @@
/**
 * edit.js
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
    PanelBody,
    SelectControl,
    ToggleControl,
    TextControl,
    Notice
} from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
/**
 * Styles
 */
import './editor.scss';
/**
 * Edit function for Form Block
 */
export default function Edit({ attributes, setAttributes }) {
    const {
        formType,
        showLabels,
        customEmailTo,
        turnstileEnabled
    } = attributes;
    const [isPreviewVisible, setIsPreviewVisible] = useState(true);
    const blockProps = useBlockProps({
        className: `jvb-form-block ${formType ? `jvb-form-block-${formType}` : ''}`
    });
    // Get form types from localized data, with fallback
    const getFormTypes = () => {
        if (typeof window !== 'undefined' && window.jvbFormsData && window.jvbFormsData.formTypes) {
            return window.jvbFormsData.formTypes;
        }
        // Fallback if data isn't available
        return [
            { label: __('Select a form type', 'jvb'), value: '' },
            { label: __('No forms available', 'jvb'), value: '', disabled: true }
        ];
    };
    // Get available forms configuration
    const getAvailableForms = () => {
        console.log(window.jvbFormsData);
        if (typeof window !== 'undefined' && window.jvbFormsData && window.jvbFormsData.availableForms) {
            return window.jvbFormsData.availableForms;
        }
        return {};
    };
    const formTypes = getFormTypes();
    const availableForms = getAvailableForms();
    // Get form configuration based on selected type
    const getCurrentFormConfig = () => {
        if (!formType || !availableForms[formType]) {
            return null;
        }
        return availableForms[formType];
    };
    // Form labels based on the selected form type
    const getFormLabels = () => {
        const formConfig = getCurrentFormConfig();
        if (formConfig) {
            return {
                title: formConfig.title || __('Form', 'jvb'),
                description: Array.isArray(formConfig.description)
                    ? formConfig.description.join(' ')
                    : (formConfig.description || ''),
                button: formConfig.submit || __('Submit', 'jvb')
            };
        }
        return {
            title: __('Form', 'jvb'),
            description: formType ? __('Loading form configuration...', 'jvb') : __('Please select a form type in the sidebar', 'jvb'),
            button: __('Submit', 'jvb')
        };
    };
    const formLabels = getFormLabels();
    // Render a preview of the form in the editor
    const renderFormPreview = () => {
        if (!formType) {
            return (
                <Notice status="warning" isDismissible={false}>
                    {__('Please select a form type in the block settings.', 'jvb')}
                </Notice>
            );
        }
        let formFields = [];
        switch (formType) {
            case 'contact':
                formFields = [
                    { id: 'name', label: __('Name', 'jvb'), type: 'text', required: true },
                    { id: 'email', label: __('Email', 'jvb'), type: 'email', required: true },
                    { id: 'phone', label: __('Phone', 'jvb'), type: 'tel', required: true },
                    { id: 'instagram', label: __('Instagram URL', 'jvb'), type: 'url' },
                    { id: 'contact_methods', label: __('Preferred Contact', 'jvb'), type: 'checkboxes',
                        options: [
                            { value: 'text', label: __('Text', 'jvb') },
                            { value: 'call', label: __('Call', 'jvb') },
                            { value: 'email', label: __('Email', 'jvb') },
                            { value: 'instagram', label: __('Instagram', 'jvb') }
                        ]
                    },
                    { id: 'message', label: __('Your Message', 'jvb'), type: 'textarea', required: true }
                ];
                break;
            case 'feature_request':
                formFields = [
                    { id: 'name', label: __('Name', 'jvb'), type: 'text', help: __('Required if you want us to follow up.', 'jvb') },
                    { id: 'email', label: __('Email', 'jvb'), type: 'email', help: __('Required if you want us to follow up.', 'jvb') },
                    { id: 'follow_up', label: __('Would you like me to follow up with you?', 'jvb'), type: 'checkbox' },
                    { id: 'target_audience', label: __('This Feature is For', 'jvb'), type: 'checkboxes',
                        options: [
                            { value: 'artists', label: __('Artists', 'jvb') },
                            { value: 'visitors', label: __('Site Visitors', 'jvb') },
                            { value: 'partners', label: __('Partners', 'jvb') },
                            { value: 'other', label: __('Other', 'jvb') }
                        ]
                    },
                    { id: 'feature_name', label: __('Name your Feature', 'jvb'), type: 'text', required: true },
                    { id: 'message', label: __('Describe Your Feature', 'jvb'), type: 'textarea', required: true }
                ];
                break;
            case 'technical_issue':
                formFields = [
                    { id: 'name', label: __('Name', 'jvb'), type: 'text' },
                    { id: 'email', label: __('Email', 'jvb'), type: 'email' },
                    { id: 'follow_up', label: __('Would you like me to follow up with you?', 'jvb'), type: 'checkbox' },
                    { id: 'issue_type', label: __('Type of Issue', 'jvb'), type: 'checkboxes',
                        options: [
                            { value: 'visual', label: __('Visual', 'jvb') },
                            { value: 'error', label: __('Error Page', 'jvb') },
                            { value: 'other', label: __('Other', 'jvb') }
                        ]
                    },
                    { id: 'message', label: __('Please describe the issue.', 'jvb'), type: 'textarea', required: true }
                ];
                break;
        }
        return (
            <>
                <h3 className="jvb-form-title">{formLabels.title}</h3>
                <p className="jvb-form-description">{formLabels.description}</p>
                <div className="jvb-form-preview">
                    {formFields.map((field) => (
                        <div key={field.id} className="jvb-form-field">
                            {showLabels && (
                                <label htmlFor={`jvb-${field.id}`} className={field.required ? 'required' : ''}>
                                    {field.label}
                                </label>
                            )}
                            {field.type === 'text' && (
                                <input
                                    type="text"
                                    id={`jvb-${field.id}`}
                                    placeholder={showLabels ? '' : field.label}
                                    disabled
                                />
                            )}
                            {field.type === 'email' && (
                                <input
                                    type="email"
                                    id={`jvb-${field.id}`}
                                    placeholder={showLabels ? '' : field.label}
                                    disabled
                                />
                            )}
                            {field.type === 'tel' && (
                                <input
                                    type="tel"
                                    id={`jvb-${field.id}`}
                                    placeholder={showLabels ? '' : field.label}
                                    disabled
                                />
                            )}
                            {field.type === 'url' && (
                                <input
                                    type="url"
                                    id={`jvb-${field.id}`}
                                    placeholder={showLabels ? '' : field.label}
                                    disabled
                                />
                            )}
                            {field.type === 'textarea' && (
                                <textarea
                                    id={`jvb-${field.id}`}
                                    placeholder={showLabels ? '' : field.label}
                                    disabled
                                    rows="4"
                                ></textarea>
                            )}
                            {field.type === 'checkbox' && (
                                <div className="jvb-form-checkbox">
                                    <input
                                        type="checkbox"
                                        id={`jvb-${field.id}`}
                                        disabled
                                    />
                                    <label htmlFor={`jvb-${field.id}`}>{field.label}</label>
                                </div>
                            )}
                            {field.type === 'checkboxes' && field.options && (
                                <div className="jvb-form-checkboxes">
                                    {field.options.map((option) => (
                                        <div key={option.value} className="jvb-form-checkbox">
                                            <input
                                                type="checkbox"
                                                id={`jvb-${field.id}-${option.value}`}
                                                disabled
                                            />
                                            <label htmlFor={`jvb-${field.id}-${option.value}`}>{option.label}</label>
                                        </div>
                                    ))}
                                </div>
                            )}
                            {field.help && (
                                <p className="jvb-form-help">{field.help}</p>
                            )}
                        </div>
                    ))}
                    {turnstileEnabled && (
                        <div className="jvb-form-turnstile">
                            <div className="jvb-turnstile-placeholder">
                                <span>{__('Cloudflare Turnstile will appear here', 'jvb')}</span>
                            </div>
                        </div>
                    )}
                    <div className="jvb-form-submit">
                        <button type="button" className="jvb-form-button">{formLabels.button}</button>
                    </div>
                </div>
            </>
        );
    };
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Form Settings', 'jvb')}>
                    <SelectControl
                        label={__('Form Type', 'jvb')}
                        value={formType}
                        options={formTypes}
                        onChange={(value) => setAttributes({ formType: value })}
                    />
                    <ToggleControl
                        label={__('Show Field Labels', 'jvb')}
                        checked={showLabels}
                        onChange={(value) => setAttributes({ showLabels: value })}
                        help={__('Toggle to show or hide field labels.', 'jvb')}
                    />
                    <TextControl
                        label={__('Custom Recipient Email', 'jvb')}
                        value={customEmailTo || ''}
                        onChange={(value) => setAttributes({ customEmailTo: value })}
                        help={__('Leave empty to use the default email.', 'jvb')}
                        type="email"
                    />
                </PanelBody>
                <PanelBody title={__('Form Preview', 'jvb')} initialOpen={false}>
                    <ToggleControl
                        label={__('Show Preview', 'jvb')}
                        checked={isPreviewVisible}
                        onChange={(value) => setIsPreviewVisible(value)}
                    />
                    <p className="components-base-control__help">
                        {__('This is just a preview. The actual form will be rendered on the frontend.', 'jvb')}
                    </p>
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                {isPreviewVisible ? (
                    renderFormPreview()
                ) : (
                    <div className="jvb-form-placeholder">
                        <h3>{formLabels.title}</h3>
                        <p>{__('Form preview is hidden. Edit settings in the sidebar.', 'jvb')}</p>
                    </div>
                )}
            </div>
        </>
    );
}
src/forms/editor.scss
src/forms/index.js
New file
@@ -0,0 +1,40 @@
//index.js
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import save from './save';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
    /**
     * @see ./save.js
     */
    save,
} );
src/forms/index.php
src/forms/render.php
New file
@@ -0,0 +1,55 @@
<?php
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Form Block Render
 *
 * @package Edmonton_Ink
 */
/**
 * Renders the form block on the frontend
 *
 * @param array    $attributes The block attributes.
 * @param string   $content    The block content.
 * @param WP_Block $block      The block instance.
 * @return string The rendered output.
 */
function jvbRenderFormBlock(array $attributes, string $content, WP_Block $block):string
{
    // Get form type from attributes
    $form_type = isset($attributes['formType']) ? $attributes['formType'] : '';
    // If no form type selected, return a message
    if (empty($form_type)) {
        return '<div class="jvb-form-error">No form type selected. Please edit this block and select a form type.</div>';
    }
    // Get other attributes
    $show_labels = isset($attributes['showLabels']) ? $attributes['showLabels'] : true;
    $custom_email_to = isset($attributes['customEmailTo']) ? $attributes['customEmailTo'] : '';
    // Set custom options for the form
    $form_options = array();
    // Set custom email recipient if provided
    if (!empty($custom_email_to)) {
        $form_options['email_to'] = $custom_email_to;
    }
    // Render the form with the specified options
    $form_output = JVB()->forms()->renderForm($form_type);
    // Get block classes
    $wrapper_attributes = get_block_wrapper_attributes([
        'class' => 'jvb-forms'
    ]);
    // Return the wrapped form
    return sprintf(
        '<div %1$s>%2$s</div>',
        $wrapper_attributes,
        $form_output
    );
}
src/forms/save.js
New file
@@ -0,0 +1,23 @@
//save.js
/**
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * The save function defines the way in which the different attributes should
 * be combined into the final markup, which is then serialized by the block
 * editor into `post_content`.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#save
 *
 * @return {WPElement} Element to render.
 */
export default function save() {
    // This is a dynamic block that is rendered on the server side
    // Return null to let WordPress handle the saving and rendering
    return null;
}
src/forms/style.scss
New file
Diff too large
src/forms/view.js
New file
@@ -0,0 +1,112 @@
/**
 * view.js
 * Frontend JavaScript for the Form Block
 * Handles form validation and submission
 */
/**
 * view.js
 * Frontend JavaScript for the Form Block
 */
class FormBlock {
    constructor() {
        this.controller = window.jvbForm;
        document.querySelectorAll('.jvb-form-block form').forEach(form => {
            this.controller.registerForm(form, {
                cache: true,
                autoUpload: false,
                imageMeta: false,
            });
        });
        this.controller.subscribe((event, data) => {
            if (event === 'form-submit') {
                this.handleFormSubmission(data).then(()=>{});
            }
        });
    }
    async handleFormSubmission(eventData) {
        const { config, data } = eventData;
        const form = config.element;
        const submitData = new FormData();
        // Add regular form fields
        for (const [key, value] of Object.entries(data)) {
            if (Array.isArray(value)) {
                value.forEach(v => submitData.append(`${key}[]`, v));
            } else if (typeof value === 'object' && value !== null) {
                submitData.append(key, JSON.stringify(value));
            } else {
                submitData.append(key, value);
            }
        }
        config.element.querySelectorAll('[name="form_id"],[name="form_type"],[name="timestamp"],[name="cf-turnstile-response"]').forEach(input => {
            submitData.append(input.name, input.value);
        });
        // Add uploaded files
        if (window.jvbUploads) {
            try {
                const files = await window.jvbUploads.getFilesForForm(form);
                files.forEach(({ file, fieldName }) => {
                    submitData.append(`${fieldName}[]`, file);
                });
            } catch (error) {
                console.error('Error getting files:', error);
            }
        }
        this.controller.showFormStatus(config.id, 'uploading');
        try {
            const response = await fetch(`${jvbSettings.api}forms`, {
                method: 'POST',
                credentials: 'same-origin',
                body: submitData
            });
            const result = await response.json();
            if (!response.ok) {
                this.controller.showFormStatus(config.id, 'error');
                this.controller.handleFormError(form, result);
                return;
            }
            this.controller.showFormStatus(config.id, 'submitted');
            // this.controller.handleFormSuccess(form, result);
            this.controller.showSummary({ changes: data, config: config });
            window.jvbA11y.announce('Form successfully submitted!');
            // Clean up uploaded files
            if (window.jvbUploads) {
                const uploadFields = form.querySelectorAll('[data-upload-field]');
                for (const field of uploadFields) {
                    const fieldId = window.jvbUploads.determineFieldId(field);
                    await window.jvbUploads.clearFieldFromStores(fieldId);
                }
            }
        } catch (error) {
            console.error('Form submission error:', error);
            this.controller.showFormStatus(config.id, 'error');
            this.controller.handleFormError(form, {
                message: 'Network error. Please check your connection and try again.',
                code: 'network_error'
            });
        } finally {
            await this.controller.store.delete(config.id);
        }
    }
}
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
            new FormBlock();
        }
    });
});
src/glossary/block.json
New file
@@ -0,0 +1,24 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/glossary",
    "version": "0.1.0",
    "title": "Glossary of Terms",
    "category": "jvb",
    "icon": "excerpt-view",
    "description": "Outputs the terms",
    "example": {},
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "textdomain": "jvb",
    "selectors": {
        "root": ".glossary"
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "render": "file:./render.php",
    "viewScript": "file:./view.js"
}
src/glossary/edit.js
New file
@@ -0,0 +1,38 @@
/**
 * Retrieves the translation of text.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
 */
import { __ } from '@wordpress/i18n';
/**
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * Those files can contain any CSS code that gets applied to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './editor.scss';
/**
 * The edit function describes the structure of your block in the context of the
 * editor. This represents what the editor will render when the block is used.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
 *
 * @return {Element} Element to render.
 */
export default function Edit() {
    return (
        <p { ...useBlockProps() }>
            { __( 'Will output the glossary', 'jvb' ) }
        </p>
    );
}
src/glossary/editor.scss
src/glossary/index.js
New file
@@ -0,0 +1,33 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
} );
src/glossary/index.php
src/glossary/render.php
New file
@@ -0,0 +1,8 @@
<?php
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
    <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
</p>
src/glossary/style.scss
New file
@@ -0,0 +1,109 @@
:root {
    --navWidth: 40vw;
    @media (min-width: 768px) {
        --navWidth: 22vw;
    }
}
nav.glossary-index {
    position: fixed;
    top: 50%;
    transform: translateY(-50%);
    width: var(--navWidth);
    right: -8px;
    height: 60vh;
    z-index: var(--z-3);
    > ul {
        --dir: column;
        --align: flex-start;
        --justify: flex-start;
        --gap: 1px;
        touch-action: pan-y;
        max-height: 100%;
        height: 100%;
        width: 100%;
        overflow: hidden auto;
        scroll-behavior: smooth;
    }
    li, a {
        flex: 1;
        width: 100%;
        height: max-content;
        min-height: max(var(--chipchip), max-content);
    }
    a {
        --justify: center;
        padding: .25rem .5rem;
        hyphens: auto;
        background-color: rgba(var(--base-rgb),var(--op-45));
        word-wrap: anywhere;
        white-space: wrap;
    }
    a:hover,
    a:focus,
    a.active {
        background-color: rgba(var(--action-rgb), var(--op-6));
        color: var(--action-contrast);
    }
}
.glossary dd {
    margin-left: .5rem;
    width: calc(100% + .75rem);
}
.glossary dd,
.glossary dt {
    position: relative;
    left: 0;
    transition: margin var(--trans-base),
    left var(--trans-base),
    width var(--trans-base);
}
.glossary dt:target,
.glossary dt.active {
    outline: none;
    left: -1.5rem;
    padding: 0;
    color: var(--action-0);
}
    .glossary dt:target + dd,
    .glossary dt.active + dd {
        left: -1.5rem;
    }
main header,
dl.glossary {
    grid-column: full;
    padding: 0 var(--navWidth) 0 2rem;
    @media (min-width:768px) {
        margin-left: auto;
        max-width: var(--content);
        margin-right: var(--navWidth);
        padding-right: var(--btn);
    }
}
@media (max-width: 768px) {
    .glossary {
        h2 {
            font-size: var(--txt-medium);
        }
        p {
            font-size: var(--txt-x-small);
        }
    }
    .glossary-index {
        li,a {
            height: fit-content;
        }
        a {
            font-size: var(--txt-x-small);
            padding: .25rem;
            min-height: 2em;
        }
    }
    body:has(.glossary) h1 {
        font-size: var(--txt-xx-large);
    }
}
src/glossary/view.js
New file
@@ -0,0 +1,184 @@
/**
 * Glossary Navigation Active State Manager
 * Handles highlighting active terms as they scroll into view
 * and syncing navigation with scroll position
 */
class GlossaryNavigator {
    constructor(glossarySelector = 'dl.glossary', navSelector = 'nav.glossary-index') {
        this.glossary = document.querySelector(glossarySelector);
        this.nav = document.querySelector(navSelector);
        if (!this.glossary || !this.nav) return;
        this.terms = this.glossary.querySelectorAll('dt[id]');
        this.navList = this.nav.querySelector('ul');
        this.activeClass = 'active';
        this.currentActive = null;
        this.init();
        this.setupResizeHandler();
    }
    init() {
        // Set up Intersection Observer with screen-size appropriate margins
        const observerOptions = {
            root: null, // viewport
            rootMargin: this.getRootMargin(),
            threshold: 0
        };
        this.observer = new IntersectionObserver(
            (entries) => this.handleIntersection(entries),
            observerOptions
        );
        // Observe all terms
        this.terms.forEach(term => this.observer.observe(term));
        // Also handle manual scroll for edge cases
        this.handleScroll = this.debounce(() => this.checkActiveTerm(), 100);
        window.addEventListener('scroll', this.handleScroll, { passive: true });
    }
    getRootMargin() {
        // On larger screens: centered (50% from top and bottom)
        return '-50% 0px -50% 0px';
    }
    setupResizeHandler() {
        let resizeTimer;
        window.addEventListener('resize', () => {
            clearTimeout(resizeTimer);
            resizeTimer = setTimeout(() => {
                // Reinitialize observer with new margins on resize
                this.reinitialize();
            }, 250);
        });
    }
    reinitialize() {
        // Disconnect old observer
        if (this.observer) {
            this.observer.disconnect();
        }
        // Create new observer with updated margins
        this.init();
    }
    handleIntersection(entries) {
        // Find the entry that's intersecting
        const intersecting = entries.find(entry => entry.isIntersecting);
        if (intersecting) {
            this.setActive(intersecting.target);
        }
    }
    checkActiveTerm() {
        // Fallback method to find which term is in the trigger zone
        const remInPixels = parseFloat(getComputedStyle(document.documentElement).fontSize);
        const margin = remInPixels * 4;
        let closestTerm = null;
        let closestDistance = Infinity;
        this.terms.forEach(term => {
            const rect = term.getBoundingClientRect();
            const isInZone = rect.top + rect.height / 2 >= 0 && rect.top + rect.height / 2 <= window.innerHeight;
            if (isInZone) {
                // Find closest to the trigger point
                const triggerPoint = window.innerHeight / 2;
                const distance = Math.abs(rect.top - triggerPoint);
                if (distance < closestDistance) {
                    closestDistance = distance;
                    closestTerm = term;
                }
            }
        });
        if (closestTerm) {
            this.setActive(closestTerm);
        }
    }
    setActive(term) {
        if (this.currentActive === term) return;
        // Remove active class from previous term
        if (this.currentActive) {
            this.currentActive.classList.remove(this.activeClass);
        }
        // Add active class to current term
        term.classList.add(this.activeClass);
        this.currentActive = term;
        // Update navigation
        this.updateNavigation(term.id);
    }
    updateNavigation(termId) {
        // Remove active from all nav links
        const navLinks = this.nav.querySelectorAll('a');
        navLinks.forEach(link => link.classList.remove(this.activeClass));
        // Find and activate corresponding nav link
        const activeLink = this.nav.querySelector(`a[href="#${termId}"]`);
        if (activeLink) {
            activeLink.classList.add(this.activeClass);
            // Scroll the nav list to center the active link
            this.centerNavItem(activeLink);
        }
    }
    centerNavItem(link) {
        const listRect = this.navList.getBoundingClientRect();
        const linkRect = link.getBoundingClientRect();
        // Calculate position to center the link in the nav container
        const scrollTop = this.navList.scrollTop;
        const linkOffset = linkRect.top - listRect.top;
        const centerOffset = (listRect.height / 2) - (linkRect.height / 2);
        this.navList.scrollTo({
            top: scrollTop + linkOffset - centerOffset,
            behavior: 'smooth'
        });
    }
    debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }
    destroy() {
        if (this.observer) {
            this.observer.disconnect();
        }
        window.removeEventListener('scroll', this.handleScroll);
    }
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
        new GlossaryNavigator();
    });
} else {
    new GlossaryNavigator();
}
src/gmbreviews/block.json
New file
@@ -0,0 +1,68 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/gmbreviews",
    "title": "GMB Reviews",
    "category": "jvb",
    "description": "Display top-rated Google My Business reviews with statistics and action buttons",
    "keywords": ["reviews", "google", "testimonials", "gmb", "ratings"],
    "textdomain": "jvb",
    "attributes": {
        "inheritUser": {
            "type": "boolean",
            "default": false
        },
        "count": {
            "type": "number",
            "default": 5
        },
        "showRating": {
            "type": "boolean",
            "default": true
        },
        "showDate": {
            "type": "boolean",
            "default": true
        },
        "showReviewLink": {
            "type": "boolean",
            "default": true
        },
        "showViewAllLink": {
            "type": "boolean",
            "default": true
        },
        "showStats": {
            "type": "boolean",
            "default": true
        },
        "minStars": {
            "type": "number",
            "default": 4,
            "minimum": 1,
            "maximum": 5
        }
    },
    "supports": {
        "html": false,
        "align": true,
        "color": {
            "text": true,
            "background": true,
            "link": true
        },
        "spacing": {
            "margin": true,
            "padding": true
        },
        "typography": {
            "fontSize": true,
            "lineHeight": true
        }
    },
    "render": "file:./render.php",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/gmbreviews/edit.js
New file
@@ -0,0 +1,69 @@
// src/gmbreviews/edit.js
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl, ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import ServerSideRender from '@wordpress/server-side-render';
export default function Edit({ attributes, setAttributes }) {
    const blockProps = useBlockProps();
    const { count, inheritUser, showStats, minStars, showViewAllLink, showRating, showDate, showReviewLink } = attributes;
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Review Settings', 'jvb')}>
                    <ToggleControl
                        label={__('Inherit User', 'jvb')}
                        checked={inheritUser}
                        onChange={(value) => setAttributes({ inheritUser: value })}
                    />
                    <RangeControl
                        label={__('Number of Reviews', 'jvb')}
                        value={count}
                        onChange={(value) => setAttributes({ count: value })}
                        min={1}
                        max={20}
                    />
                    <ToggleControl
                        label={__('Show Rating', 'jvb')}
                        checked={showRating}
                        onChange={(value) => setAttributes({ showRating: value })}
                    />
                    <ToggleControl
                        label={__('Show Date', 'jvb')}
                        checked={showDate}
                        onChange={(value) => setAttributes({ showDate: value })}
                    />
                    <ToggleControl
                        label={__('Show Review Link', 'jvb')}
                        checked={showReviewLink}
                        onChange={(value) => setAttributes({ showReviewLink: value })}
                    />
                    <ToggleControl
                        label={__('Show Stats', 'jvb')}
                        checked={showStats}
                        onChange={(value) => setAttributes({ showStats: value })}
                    />
                    <ToggleControl
                        label={__('Show All Reviews Link', 'jvb')}
                        checked={showViewAllLink}
                        onChange={(value) => setAttributes({ showViewAllLink: value })}
                    />
                    <RangeControl
                        label={__('Minimum Rating', 'jvb')}
                        value={minStars}
                        onChange={(value) => setAttributes({ minStars: value })}
                        min={1}
                        max={5}
                    />
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                <ServerSideRender
                    block="jvb/gmbreviews"
                    attributes={attributes}
                />
            </div>
        </>
    );
}
src/gmbreviews/editor.scss
src/gmbreviews/index.js
New file
@@ -0,0 +1,11 @@
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import './style.scss';
import './editor.scss';
import metadata from './block.json';
registerBlockType(metadata.name, {
    edit: Edit,
    // No save function - dynamic block rendered on server
    save: () => null,
});
src/gmbreviews/index.php
src/gmbreviews/render.php
New file
@@ -0,0 +1,207 @@
<?php
/**
 * GMB Reviews Block - Render Template
 *
 * Displays recent Google My Business reviews with a link to leave a review
 */
function jvbRenderGMBReviewsBlock(array $attributes): string
{
    $count = $attributes['count'] ?? 5;
    $showRating = $attributes['showRating'] ?? true;
    $showDate = $attributes['showDate'] ?? true;
    $showReviewLink = $attributes['showReviewLink'] ?? true;
    $showViewAllLink = $attributes['showViewAllLink'] ?? true;
    $showStats = $attributes['showStats'] ?? true;
    $minStars = $attributes['minStars'] ?? 4; // Only show 4+ star reviews
    $inheritUser = $attributes['inheritUser']??null;
    if ($inheritUser) {
        global $post;
        $inheritUser = $post->post_author;
    }else {
        $inheritUser = null;
    }
    try {
        $gmb = JVB()->connect('gmb', $inheritUser);
        if (!$gmb->isSetUp()) {
            error_log('GMB Not set up for: '.(int)$inheritUser);
            return '';
        }
        $gotReviews = $gmb->getReviews();
        // Get all data
        $allReviews = $gotReviews['reviews']??[];
        $reviewUrl = $gmb->getReviewUrl();
        $viewAllUrl = $gmb->getReviewsViewUrl();
        $average = $gotReviews['averageRating']??null;
        $total = $gotReviews['totalReviewCount']??null;
        // Filter reviews by minimum stars
        $reviews = [];
        if (!empty($allReviews)) {
            foreach ($allReviews as $review) {
                $rating = $review['starRating'] ?? 0;
                if ($rating >= $minStars) {
                    $reviews[] = $review;
                    if (count($reviews) >= $count) {
                        break; // Got enough reviews
                    }
                }
            }
        }
        if (empty($reviews) && empty($reviewUrl) && empty($stats)) {
            error_log('No reviews to display...');
            return '';
        }
        ob_start();
        ?>
        <div class="gmb-reviews">
            <div class="row center">
            <?php
            if ($showStats && !empty($average) && !empty($total)) {
                ?>
                <p>
                    <span class="stars" title="<?= $average ?> out of 5 stars">
                        <?php
                        $fullStars = floor($average);
                        $hasHalfStar = ($average - $fullStars) >= 0.5;
                        for ($i = 1; $i <= 5; $i++) {
                            if ($i <= $fullStars) {
                                echo jvbIcon('star', ['style' => 'fill']);
                            } elseif ($i == $fullStars + 1 && $hasHalfStar) {
                                echo jvbIcon('star-half', ['style'=> 'fill']);
                            } else {
                                echo  jvbIcon('star', ['style' => 'light']);
                            }
                        }
                        ?>
                    </span>
                    <i>Average</i>
                </p>
                <?php
                if ($total > 0) {
                    ?>
                    <p><i>{ <?= number_format($total ) . ' ' . _n('Review', 'Reviews', $total, 'jvb')?> Total }</i></p>
                    <?php
                }
            ?>
            <?php
            }
            ?>
        </div>
            <?php
            if ($showReviewLink && !empty($reviewUrl)) {
                ?>
                <a href="<?=esc_url($reviewUrl)?>"
                   class="button"
                   target="_blank"
                   rel="noopener noreferrer">
                    <?= jvbIcon('star', ['style' => 'fill']) ?>
                    Leave Your Review
                </a>
                <?php
            }
            ?>
        <ul>
            <?php
            foreach ($reviews as $review) {
                $reviewer = $review['reviewer']['displayName'] ?? 'Anonymous';
                $reviewer = strtok($reviewer, ' ');
                $profilePhoto = $review['reviewer']['profilePhotoUrl'] ?? '';
                $rating = $review['starRating'] ?? 0;
                $rating = match($rating) {
                    'FIVE'  => 5,
                    'FOUR'  => 4,
                    'THREE' => 3,
                    'TWO'   => 2,
                    'ONE'   => 1,
                    default =>  $rating
                };
                $comment = $review['comment'] ?? '';
                $date = $review['updateTime'] ?? '';
                ?>
                <li>
                    <blockquote class="review">
                        <?php
                        // Review text
                        if (!empty($comment)) { ?>
                            <div class="content review">
                                <?= apply_filters('wpautop', $comment) ?>
                            </div>
                        <?php } ?>
                        <cite class="row left nowrap">
                            <?php if (!empty($profilePhoto)) { ?>
                                <img src="<?=esc_url($profilePhoto)?>"
                                     alt="<?=esc_attr($reviewer)?>"
                                'loading="lazy">
                            <?php } else { ?>
                                <div class="avatar">
                                    <?= jvbIcon('user-circle')?>
                                </div>
                            <?php } ?>
                            <div class="row left wrap">
                                <?php if ($showRating && $rating > 0) { ?>
                                    <div class="stars" title="<?= $rating ?> out of 5 stars">
                                        <?php
                                        for ($i = 1; $i <= 5; $i++) {
                                            echo ($i <= $rating) ? jvbIcon('star', ['style' => 'fill']) : jvbIcon('star', ['style' => 'light']);
                                        } ?>
                                    </div>
                                <?php } ?>
                                <p><?= esc_html($reviewer)?></p>
                                <?php
                                // Date
                                if ($showDate && !empty($date)) {
                                    $formatted_date = human_time_diff(strtotime($date), current_time('timestamp')) . ' ago';
                                    ?>
                                    <time datetime="<?=esc_attr($date)?>">
                                        <?= esc_html($formatted_date) ?>
                                    </time>
                                <?php } ?>
                            </div>
                        </cite>
                    </blockquote>
                </li>
            <?php
            }
            ?>
        </ul>
        <?php
        // Footer with "See All Reviews" button
        if ($showViewAllLink && !empty($viewAllUrl)) {
            ?>
            <div class="footer">
                <a href=" <?= esc_url($viewAllUrl) ?>"
                    class="button"
                    target="_blank"
                    rel="noopener noreferrer">
                    <?php
                    if ($showStats ) {
                        echo 'See All ' . number_format($total) . ' Reviews';
                    } else {
                        echo ' See All Reviews';
                    }
                    ?>
                    <?= jvbIcon('arrow-square-out') ?>
                </a>
            </div>
        <?php
        }
        ?>
        </div>
        <?php
        return ob_get_clean();
    } catch (\Exception $e) {
        error_log('[GMB Reviews Block] Error: ' . $e->getMessage());
        return '';
    }
}
src/gmbreviews/style.scss
New file
@@ -0,0 +1,122 @@
.gmb-reviews {
    max-width: none;
    > .row.center {
        max-width:var(--content);
        margin: 0 auto;
        --gap: .5rem 6rem;
        p {
            width: fit-content;
        }
    }
    .button {
        width: 66.6%;
        margin: 0 auto 2rem;
        display: flex;
        height: max-content;
    }
    .stars {
        display: inline-flex;
        align-items: center;
        justify-content: flex-start;
        flex-wrap: nowrap;
    }
    ul {
        list-style: none;
        margin: 0;
        padding: 0;
        max-width: var(--full);
        li {
            width: 100%;
            max-width: none;
            padding: 4rem 1rem;
            &:nth-of-type(odd) {
                background-color: var(--base-50);
                blockquote {
                    --background: var(--base-50);
                }
            }
            &:nth-of-type(even) {
                background-color: var(--base-100);
                blockquote {
                    --background: var(--base-100);
                }
            }
        }
    }
    blockquote {
        margin:0 auto;
        padding: 0;
        max-width: var(--content);
        .content {
            border-width: 4px 1px;
            &::after {
                border-width: 4px 1px;
            }
            &::before {
                border-width: 8px;
                bottom: -4px;
            }
        }
        cite {
            position: relative;
            img {
                width: 4.5rem;
                position: absolute;
                left: -8rem;
                top: 0;
            }
            p {
                margin: 0;
            }
            .wrap {
                --wrap: wrap;
                p, time {
                    max-width: 49%;
                }
                .stars {
                    width: 100%;
                }
            }
        }
        time {
            white-space: nowrap;
        }
    }
    .stars .icon {
        background-color: var(--action-0);
    }
    article {
        padding: 1rem;
        border-radius: var(--radius-outer);
        background-color: var(--base);
        header {
            --align: center;
            >img {
                position: relative;
                left: 0;
            }
        }
        time {
            font-style: italic;
        }
        .review {
            padding: 1.5rem;
        }
        h4 {
            width: max-content;
        }
        .icon {
            color: var(--action-0);
        }
    }
    .footer .button {
        width: 100%;
    }
}
src/gmbreviews/view.js
src/index.php
New file
@@ -0,0 +1,3 @@
<?php
//Nothing to see here
src/menu/block.json
New file
@@ -0,0 +1,24 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/menu",
    "version": "0.1.0",
    "title": "Our Menu",
    "category": "jvb",
    "icon": "food",
    "description": "Outputs our menu, organized by categories",
    "example": {},
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "textdomain": "jvb",
    "selectors": {
        "root": ".menu-block"
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "render": "file:./render.php",
    "viewScript": "file:./view.js"
}
src/menu/edit.js
New file
@@ -0,0 +1,38 @@
/**
 * Retrieves the translation of text.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
 */
import { __ } from '@wordpress/i18n';
/**
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * Those files can contain any CSS code that gets applied to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './editor.scss';
/**
 * The edit function describes the structure of your block in the context of the
 * editor. This represents what the editor will render when the block is used.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
 *
 * @return {Element} Element to render.
 */
export default function Edit() {
    return (
        <p { ...useBlockProps() }>
            { __( 'Will output the menu', 'jvb' ) }
        </p>
    );
}
src/menu/editor.scss
src/menu/index.js
New file
@@ -0,0 +1,33 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
} );
src/menu/index.php
src/menu/render.php
New file
@@ -0,0 +1,8 @@
<?php
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
    <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
</p>
src/menu/style.scss
src/menu/view.js
New file
@@ -0,0 +1,43 @@
window.details = document.querySelectorAll('details');
window.toggles = document.querySelectorAll('.toggle-details');
document.addEventListener('click', (e) => {
    if (e.target.classList.contains('toggle-details')) {
        e.target.classList.toggle('open');
        let on = e.target.classList.contains('open');
        let section = e.target.dataset.toggle;
        if (section === 'all') {
            toggleToggles(on);
        }
        let span = e.target.querySelector('span');
        span.textContent = (on) ? 'Close': 'Open';
        toggleDetails(section, on);
    }
});
console.log(window.details);
function toggleDetails(name, toggle) {
    if (name === 'all') {
        console.log('Toggling all!');
        window.details.forEach(detail => {
            console.log(detail);
            detail.open = toggle;
        });
    } else {
        for (let detail of window.details) {
            if (detail.dataset.section === name) {
                detail.open = toggle;
            }
        }
    }
}
function toggleToggles(on) {
    window.toggles.forEach(toggle => {
        if (toggle.dataset.toggle !== 'all') {
            toggle.querySelector('span').textContent = (on) ? 'Close' : 'Open';
        }
    });
}
src/summary/block.json
New file
@@ -0,0 +1,32 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/summary",
    "title": "Archive Summary",
    "category": "jvb",
    "icon": "align-center",
    "description": "Outputs the information for the given archive page, or the bio for a profile. Pairs well with the feed block.",
    "keywords": [ "summary", "bio", "style", "term" ],
    "version": "0.9.0",
    "textdomain": "jvb",
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "selectors": {
        "root": ".summary-block"
    },
    "styles": [
        { "name": "default", "label": "Default", "isDefault": true }
    ],
    "example": {
        "attributes": {
            "listType": "tattoo"
        }
    },
    "render": "file:./render.php",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/summary/edit.js
New file
@@ -0,0 +1,29 @@
/**
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { SelectControl, ToggleControl, PanelBody } from '@wordpress/components';
/**
 * Styles
 */
import './editor.scss';
/**
 * Edit function for Summary Block
 */
export default function Edit({ attributes, setAttributes }) {
    const blockProps = useBlockProps();
    return (
        <div {...blockProps}>
            <div className="jvb-summary-preview">
                <h3>{__('Summary', 'jvb')}</h3>
                <p className="jvb-list-preview-note">
                    {__('This will inherit the current query to build the information from our custom meta on the front end.', 'jvb')}
                </p>
            </div>
        </div>
    );
}
src/summary/editor.scss
New file
@@ -0,0 +1,20 @@
/**
 * Directory List Block Editor Styles
 */
.jvb-summary-preview {
    padding: 20px;
    background-color: #f8f9fa;
    border: 1px solid #e2e4e7;
    border-radius: 4px;
    h3 {
        margin-top: 0;
        padding-bottom: 10px;
        border-bottom: 1px solid #ff0080;
    }
    &-note {
        font-style: italic;
        color: #555d66;
        margin-bottom: 0;
    }
}
src/summary/index.js
New file
@@ -0,0 +1,39 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import save from './save';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
    /**
     * @see ./save.js
     */
    save,
} );
src/summary/index.php
src/summary/render.php
New file
@@ -0,0 +1,320 @@
<?php
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\meta\Render;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Summary Block Render
 *
 * @package Edmonton_Ink
 */
function jvbRenderSummaryBlock(array $attributes):string
{
    // Buffer output
    if (is_tax()) {
        switch (get_queried_object()->taxonomy) {
            case BASE.'shop':
                return jvbRenderShopSummary();
            default:
                return jvbRenderTermSummary();
        }
    } elseif (is_singular()) {
        return jvbRenderArtistSummary();
    }
    return '';
}
function jvbRenderArtistSummary():string
{
    $current = get_queried_object();
    $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
    $key = $current->ID;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $meta = Meta::forPost($current->ID);
    $artist = jvbContentFromUser((int)$current->post_author);
    $registrar = Registrar::getInstance($current->post_type));
    $sections = [];
    if ($registrar) {
        $sections = $registrar->getSections();
    }
//    $handler = JVB()->getContent(str_replace(BASE,'', $current->post_type));
    ?>
    <nav id="artist" class="on-this-page index">
        <label>Jump to:
            <button type="button" aria-label="Show Index" title="Show Index" class="toggle" aria-expanded="false">
                <?= jvbIcon('plus-square')?>
            </button>
        </label>
        <ul>
            <li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li>
            <li><a href="#about">About</a></li>
            <li><a href="#styles">Styles</a></li>
            <li><a href="#contact">Contact</a></li>
            <li><a href="#work">Work</a></li>
        </ul>
    </nav>
    <header id="top">
        <h1><small><?=(!empty($artist['city'])) ? $artist['city']['name'] :'Edmonton'?>'s Best <?= (!empty($artist['type'])) ?
                    $artist['type']['name']:'Tattoo Artists'?>:
            </small><?=$artist['display_name']?></h1>
        <div>
            <?php if (!empty($artist['shop'])) : ?>
            <ul class="term-list shop">
                <li>
                    <a href="<?=$artist['shop']['url']?>" title="Learn more about <?=$artist['shop']['name']?>">
                        <?= strtolower($artist['shop']['name'])?>
                    </a>
                </li>
            </ul>
            <?php endif; ?>
            <?php if (!empty($artist['city'])): ?>
            <ul class="term-list city">
                <li>
                    <a href="<?=$artist['city']['url']?>" title="See who else is rocking out of <?=$artist['city']['name']?>">
                        <?= strtolower($artist['city']['name'])?>
                    </a>
                </li>
            </ul>
            <?php endif; ?>
            <?php $styles = $meta->get('top_styles');
            if (!empty($styles)) {
                ?>
                <ul class="term-list style">
                    <?php
                    foreach ($styles as $style) {
                        $term = get_term((int)$style, BASE.'style');
                        if ($term && !is_wp_error($term)) {
                            $link = get_term_link((int)$style, BASE.'style');
                            ?>
                            <li>
                                <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
                                    <?=strtolower(html_entity_decode($term->name))?>
                                </a>
                            </li>
                            <?php
                        }
                    }
                    ?>
                </ul>
                <?php
            }
            ?>
        </div>
    </header>
    <section>
        <details class="bio-info">
            <summary class="row x-btw">
                <h2>About <?= ($artist['name'] !== '') ? $artist['name'] : strtok($artist['display_name'], ' ')?></h2>
            </summary>
            <div class="columns stack-small">
                <div class="column">
                    <?= Render::renderFrom($meta, 'image_portrait'); ?>
                </div>
                <div class="column">
                    <?= Render::renderFrom($meta, 'short_bio'); ?>
                </div>
            </div>
            <div id="styles">
                <h3>Works In</h3>
                <?= jvbGetTheTerms('style', $current->ID) ?>
            </div>
            <div class="contact">
                <h3>Contact:</h3>
                <?php
                echo jvbRenderContactInfo($current->ID, $meta);
                echo jvbRenderLinks($current->ID, $meta);
                ?>
            </div>
            <div id="about">
                <?= Render::renderFrom($meta, 'bio')?>
            </div>
        </details>
    </section>
    <section id="contact" class="">
        <h2>Contact <?=$artist['name']?></h2>
        <?php
        echo jvbRenderContactInfo($current->ID, 'post');
        echo jvbRenderLinks($current->ID, 'post');
        ?>
    </section>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
function jvbRenderShopSummary()
{
    $current = get_queried_object();
    $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
    $key = $current->term_id;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $meta = Meta::forTerm($current->term_id);
    $fields = $meta->getAll(['average_rating', 'established', 'bio','location','hours','specialties','awards','reviews']);
    ?>
    <nav id="shop" class="on-this-page index">
        <label>Jump to:
            <button type="button" aria-label="Show Index" title="Show Index" class="toggle" aria-expanded="false">
                <?= jvbIcon('plus-square')?>
            </button>
        </label>
        <ul>
            <li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li> <?php
            if ($fields['rating'] !== 'none') {
                ?>
                <li><a href="#rating">Rating</a></li>
                <?php
            } elseif ($fields['opened'] !== '') {
                ?>
                <li><a href="#opened">Opened</a></li>
                <?php
            } elseif ($fields['location'] !== '') {
                ?>
                <li><a href="#location">Location</a></li>
                <?php
            } elseif ($fields['about'] !== '') {
                ?>
                <li><a href="#about">About</a></li>
                <?php
            } elseif ($fields['hours'] !== '') {
                ?>
                <li><a href="#hours">Hours</a></li>
                <?php
            } elseif ($fields['specialties'] !== '') {
                ?>
                <li><a href="#specialties">Specialties</a></li>
                <?php
            } elseif ($fields['awards'] !== '') {
                ?>
                <li><a href="#awards">Awards</a></li>
                <?php
            } elseif ($fields['reviews'] !== '') {
                ?>
                <li><a href="#reviews">Reviews</a></li>
                <?php
            }
            ?>
            <li><a href="#contact">Contact</a></li>
            <li><a href="#artists">Artists</a></li>
        </ul>
    </nav>
    <header id="top">
        <div class="columns stack-small">
            <div class="column">
                <?=jvbFormatImage($meta->get('image'))?>
            </div>
            <div class="column">
                <h1>
                    <small><?= (get_term((int)$meta->get('city'), BASE.'city')) ?
                            get_term((int)$meta->get('city'), BASE.'city')->name :
                            'Edmonton'?>'s Best Tattoo Shops</small>
                    <?=$current->name?>
                </h1>
                <?= jvbFormatRating($current->term_id, 'term') ?>
                <?= Render::renderFrom($meta,   'slogan'); ?>
            </div>
        </div>
    </header>
    <section>
        <details class="bio-info">
            <summary class="row x-btw">
                <h2>Learn More About <?=$current->name?></h2>
            </summary>
            <div class="map">
                <?= Render::renderFrom($meta, 'location'); ?>
            </div>
            <div class="short-bio">
                <?= Render::renderFrom($meta, 'short_bio'); ?>
            </div>
            <div class="contact">
                <h3>Contact:</h3>
                <?php
                echo jvbRenderContactInfo($current->term_id, 'term');
                echo jvbRenderLinks($current->term_id, 'term');
                ?>
            </div>
            <div id="about">
                <?= Render::renderFrom($meta, 'bio')?>
            </div>
        </details>
    </section>
    <section id="contact" class="">
        <h2>Contact </h2>
        <?php
        echo jvbRenderContactInfo($current->term_id, 'term');
        echo jvbRenderLinks($current->term_id, 'term');
        ?>
    </section>
    <?= jvbRenderHours($current->term_id, 'term')?>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
function jvbRenderTermSummary()
{
    $current = get_queried_object();
    $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
    $key = $current->ID;
    $cached = $cache->get($key);
    if ($cached !== false) {
        return $cached;
    }
    ob_start();
    $tax = jvbNoBase($current->taxonomy);
    switch ($tax) {
        case 'style':
            $title = 'Tattoo Artists';
            break;
        case 'theme':
            $title = 'Tattoos';
            break;
        default:
            $title = '';
    }
    $meta = Meta::forTerm($current->ID);
    $fields = $meta->getAll();
    ?>
    <header id="top">
        <h1><?= get_the_archive_title() ?></h1>
    </header>
    <?php
    $finished = ob_get_clean();
    $cache->set($key, $finished);
    return $finished;
}
src/summary/save.js
New file
@@ -0,0 +1,3 @@
export default function save() {
    return null; // Dynamic block rendered by PHP
}
src/summary/style.scss
New file
@@ -0,0 +1,20 @@
details > div {
    margin: 1rem 0;
}
main > header:not(:has(img)) {
    margin-top: 3rem!important;
}
header a::before {
    display: none!important;
}
header + details {
    margin: 1.5rem auto 3rem!important;
    max-width: var(--wide);
}
main {
    padding-top: 0!important;
}
src/summary/view.js
New file
@@ -0,0 +1 @@
src/timeline/block.json
New file
@@ -0,0 +1,23 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/timeline",
    "version": "0.1.0",
    "title": "Timeline",
    "category": "jvb",
    "icon": "shortcode",
    "description": "Outputs a single timeline post in a cool way",
    "example": {},
    "supports": {
        "html": false,
        "align": ["wide", "full"]
    },
    "textdomain": "jvb",
    "selectors": {
        "root": ".timeline"
    },
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "viewScript": "file:./view.js"
}
src/timeline/edit.js
New file
@@ -0,0 +1,38 @@
/**
 * Retrieves the translation of text.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
 */
import { __ } from '@wordpress/i18n';
/**
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * Those files can contain any CSS code that gets applied to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './editor.scss';
/**
 * The edit function describes the structure of your block in the context of the
 * editor. This represents what the editor will render when the block is used.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
 *
 * @return {Element} Element to render.
 */
export default function Edit() {
    return (
        <p { ...useBlockProps() }>
            { __( 'Will output the timeline', 'jvb' ) }
        </p>
    );
}
src/timeline/editor.scss
src/timeline/index.js
New file
@@ -0,0 +1,33 @@
/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';
/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';
/**
 * Internal dependencies
 */
import Edit from './edit';
import metadata from './block.json';
/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,
} );
src/timeline/index.php
src/timeline/render.php
New file
@@ -0,0 +1,8 @@
<?php
/**
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
    <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
</p>
src/timeline/style.scss
New file
@@ -0,0 +1,135 @@
main {
    --gap: 0;
    section:last-of-type {
        margin-bottom: 0;
    }
}
#at-a-glance {
    padding: 0 10vw;
    --gap: 0;
    img {
        width: 100%;
        height: auto;
        border: 2px solid var(--action-0);
    }
    h3 {
        font-size: var(--txt-x-small);
    }
    .before {
        img {
            border-right-width: 1px;
            border-left: 0;
            border-top: 0;
        }
    }
    .after {
        img {
            border-left-width: 1px;
            border-right: 0;
            border-bottom: 0;
        }
    }
}
.timeline-point.timeline-point {
    --lineWidth: 1px;
    --gap: 2rem;
    padding: 0;
    margin:0;
    background-color: var(--base);
    max-width: 100vw;
    position: relative;
    overflow: hidden;
    img {
        width: 40%;
        border-radius: 4px;
        position: sticky;
        padding: .5rem;
    }
    .info {
        padding: 1rem .5rem .5rem;
        width: 60%;
        position: relative;
        h2 {
            margin: 0 0 .5rem;
            font-size: var(--txt-medium);
            position: relative;
            .icon {
                --w: 2.5rem;
                transform: rotate(-90deg);
                position: absolute;
                left: -2.5rem;
                top: .25rem;
                background-color: var(--action-100);
            }
        }
    }
    &::before,
    &::after {
        content: '';
        display: block;
        position: absolute;
        left: 45%;
        height: 100%;
        width: var(--lineWidth);
        background-color: var(--action-0);
        //box-shadow: var(--action-shadow);
    }
    &::before {
        height: 1rem;
    }
    &::after {
        top: 4rem;
    }
    &#before-treatment::before,
    &:last-of-type::after {
        display: none;
    }
}
@media (min-width:768px) {
    #at-a-glance {
        h3  {
            font-size: var(--txt-x-large);
        }
    }
    .timeline-point.timeline-point {
        --gap: 4rem;
        img {
            width: 50%;
        }
        .info {
            width: 50%;
            padding: 25vh 1rem 1rem;
            h2 {
                .icon {
                    --w: 4rem;
                    left: -6.15rem;
                    top: 0;
                }
            }
            a {
                display: flex;
                flex-wrap: wrap;
                align-items: center;
            }
            time {
                text-transform: uppercase;
                font-size: var(--txt-x-small);
            }
        }
        &::before,
        &::after {
            left: calc(50% + 2rem);
        }
        &::before {
            height: calc(25vh - 2rem);
        }
        &::after {
            top: calc(25vh + 6rem);
        }
    }
}
src/timeline/view.js
src/video/block.json
New file
@@ -0,0 +1,79 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/video",
    "version": "1.0.0",
    "title": "Video Cover",
    "category": "jvb",
    "icon": "video-alt3",
    "description": "Self-hosted video cover with poster and multiple format support",
    "supports": {
        "html": false,
        "align": ["wide", "full"],
        "spacing": {
            "margin": true,
            "padding": true
        },
        "color": {
            "background": true,
            "text": true
        }
    },
    "attributes": {
        "title": {
            "type": "string",
            "default": ""
        },
        "description": {
            "type": "rich-text",
            "default": ""
        },
        "posterId": {
            "type": "number",
            "default": 0
        },
        "posterUrl": {
            "type": "string",
            "default": ""
        },
        "videoSources": {
            "type": "array",
            "default": [],
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "number"
                    },
                    "url": {
                        "type": "string"
                    },
                    "mime": {
                        "type": "string"
                    }
                }
            }
        },
        "fadeEffect": {
            "type": "boolean",
            "default": false
        },
        "overlayOpacity": {
            "type": "number",
            "default": 0
        },
        "contentAlignment": {
            "type": "string",
            "default": "center"
        },
        "minHeight": {
            "type": "number",
            "default": 0
        }
    },
    "textdomain": "jvb",
    "editorScript": "file:./index.js",
    "viewScript": "file:./view.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css"
}
src/video/edit.js
New file
@@ -0,0 +1,276 @@
//edit.js
import { __ } from '@wordpress/i18n';
import {
    useBlockProps,
    InspectorControls,
    MediaUpload,
    MediaUploadCheck,
    InnerBlocks,
    useInnerBlocksProps
} from '@wordpress/block-editor';
import {
    PanelBody,
    Button,
    ToggleControl,
    BaseControl,
    RangeControl,
    SelectControl
} from '@wordpress/components';
import './editor.scss';
const ALLOWED_VIDEO_TYPES = ['video'];
const INNER_BLOCKS_TEMPLATE = [
    ['core/heading', {
        level: 1,
        placeholder: 'Add heading...',
        textAlign: 'center'
    }],
    ['core/paragraph', {
        placeholder: 'Add description...',
        align: 'center'
    }],
    ['core/buttons', {
        layout: { type: 'flex', justifyContent: 'center' }
    }]
];
export default function Edit({ attributes, setAttributes }) {
    const {
        posterId,
        posterUrl,
        videoSources,
        fadeEffect,
        overlayOpacity,
        contentAlignment,
        minHeight
    } = attributes;
    const blockProps = useBlockProps({
        className: 'video-cover-editor',
        style: {
            minHeight: minHeight ? `${minHeight}px` : undefined
        }
    });
    const innerBlocksProps = useInnerBlocksProps(
        { className: 'video-cover-content' },
        {
            template: INNER_BLOCKS_TEMPLATE,
            templateLock: false
        }
    );
    const onSelectPoster = (media) => {
        setAttributes({
            posterId: media.id,
            posterUrl: media.url
        });
    };
    const onSelectVideos = (mediaItems) => {
        // multiple=true returns an array
        const items = Array.isArray(mediaItems) ? mediaItems : [mediaItems];
        const newSources = items
            .filter(media => !videoSources.some(s => s.id === media.id))
            .map(media => ({
                id: media.id,
                url: media.url,
                mime: media.mime
            }));
        if (newSources.length) {
            setAttributes({
                videoSources: [...videoSources, ...newSources]
            });
        }
    };
    const removeVideoSource = (index) => {
        const updated = [...videoSources];
        updated.splice(index, 1);
        setAttributes({ videoSources: updated });
    };
    const renderVideoSourceList = (sources, isMobile = false) => {
        if (sources.length === 0) return null;
        return (
            <ul className="video-source-list">
                {sources.map((source, index) => (
                    <li key={index} className="video-source-item">
                        <span className="video-source-mime">{source.mime}</span>
                        <Button
                            isDestructive
                            isSmall
                            onClick={() => removeVideoSource(index, isMobile)}
                        >
                            {__('Remove', 'jvb')}
                        </Button>
                    </li>
                ))}
            </ul>
        );
    };
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Video Settings', 'jvb')} initialOpen={true}>
                    <BaseControl
                        label={__('Poster Image', 'jvb')}
                        help={__('Image shown while video loads', 'jvb')}
                    >
                        <MediaUploadCheck>
                            <MediaUpload
                                onSelect={onSelectPoster}
                                allowedTypes={['image']}
                                value={posterId}
                                render={({ open }) => (
                                    <>
                                        {posterUrl && (
                                            <img
                                                src={posterUrl}
                                                alt={__('Poster preview', 'jvb')}
                                                style={{ maxWidth: '100%', marginBottom: '10px' }}
                                            />
                                        )}
                                        <Button
                                            onClick={open}
                                            variant={posterUrl ? 'secondary' : 'primary'}
                                        >
                                            {posterUrl
                                                ? __('Change Poster', 'jvb')
                                                : __('Select Poster', 'jvb')}
                                        </Button>
                                        {posterUrl && (
                                            <Button
                                                isDestructive
                                                onClick={() => setAttributes({ posterId: 0, posterUrl: '' })}
                                                style={{ marginLeft: '10px' }}
                                            >
                                                {__('Remove', 'jvb')}
                                            </Button>
                                        )}
                                    </>
                                )}
                            />
                        </MediaUploadCheck>
                    </BaseControl>
                    <BaseControl
                        label={__('Video Sources', 'jvb')}
                        help={__('Add multiple formats for better browser support (mp4, webm, etc.)', 'jvb')}
                    >
                        {videoSources.length > 0 && (
                            <ul className="video-source-list">
                                {videoSources.map((source, index) => (
                                    <li key={index} className="video-source-item">
                                        <span className="video-source-mime">{source.mime}</span>
                                        <Button
                                            isDestructive
                                            isSmall
                                            onClick={() => removeVideoSource(index)}
                                        >
                                            {__('Remove', 'jvb')}
                                        </Button>
                                    </li>
                                ))}
                            </ul>
                        )}
                        <MediaUploadCheck>
                            <MediaUpload
                                multiple={true}
                                onSelect={onSelectVideos}
                                allowedTypes={ALLOWED_VIDEO_TYPES}
                                render={({ open }) => (
                                    <Button onClick={open} variant="secondary">
                                        {__('Add Video', 'jvb')}
                                    </Button>
                                )}
                            />
                        </MediaUploadCheck>
                    </BaseControl>
                    <ToggleControl
                        label={__('Fade Effect', 'jvb')}
                        help={__('Add fade class to video element', 'jvb')}
                        checked={fadeEffect}
                        onChange={(value) => setAttributes({ fadeEffect: value })}
                    />
                </PanelBody>
                <PanelBody title={__('Overlay Settings', 'jvb')} initialOpen={true}>
                    <RangeControl
                        label={__('Overlay Opacity', 'jvb')}
                        help={__('Darken video for better text readability', 'jvb')}
                        value={overlayOpacity}
                        onChange={(value) => setAttributes({ overlayOpacity: value })}
                        min={0}
                        max={100}
                        step={5}
                    />
                    <SelectControl
                        label={__('Content Alignment', 'jvb')}
                        value={contentAlignment}
                        options={[
                            { label: __('Top Left', 'jvb'), value: 'top-left' },
                            { label: __('Top Center', 'jvb'), value: 'top-center' },
                            { label: __('Top Right', 'jvb'), value: 'top-right' },
                            { label: __('Center Left', 'jvb'), value: 'center-left' },
                            { label: __('Center', 'jvb'), value: 'center' },
                            { label: __('Center Right', 'jvb'), value: 'center-right' },
                            { label: __('Bottom Left', 'jvb'), value: 'bottom-left' },
                            { label: __('Bottom Center', 'jvb'), value: 'bottom-center' },
                            { label: __('Bottom Right', 'jvb'), value: 'bottom-right' }
                        ]}
                        onChange={(value) => setAttributes({ contentAlignment: value })}
                    />
                    <RangeControl
                        label={__('Minimum Height', 'jvb')}
                        help={__('Minimum height in pixels (leave 0 for auto)', 'jvb')}
                        value={minHeight}
                        onChange={(value) => setAttributes({ minHeight: value })}
                        min={0}
                        max={1000}
                        step={50}
                    />
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                {posterUrl || videoSources.length > 0 ? (
                    <div className="video-cover-preview">
                        {posterUrl && (
                            <>
                                <img src={posterUrl} alt={__('Video poster', 'jvb')} />
                                {overlayOpacity > 0 && (
                                    <div
                                        className="video-overlay-preview"
                                        style={{ opacity: overlayOpacity / 100 }}
                                    />
                                )}
                            </>
                        )}
                        <div className={`video-cover-content-preview align-${contentAlignment}`}>
                            <div {...innerBlocksProps} />
                        </div>
                        <div className="video-info">
                            <p>
                                {videoSources.length} {__('desktop source(s)', 'jvb')}
                            </p>
                        </div>
                    </div>
                ) : (
                    <div className="video-cover-placeholder">
                        <p>{__('Configure video sources in the sidebar →', 'jvb')}</p>
                    </div>
                )}
            </div>
        </>
    );
}
src/video/editor.scss
New file
@@ -0,0 +1,141 @@
/* editor.scss */
.video-cover-editor {
    position: relative;
    min-height: 200px;
    background: #f0f0f0;
    border: 2px dashed #ccc;
    border-radius: 4px;
    .video-cover-preview {
        position: relative;
        width: 100%;
        min-height: 300px;
        img {
            width: 100%;
            height: auto;
            display: block;
        }
        .video-overlay-preview {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0, 0, 0, 1);
            pointer-events: none;
        }
        .video-cover-content-preview {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            display: flex;
            z-index: 2;
            padding: 2rem;
            // Content alignment classes
            &.align-top-left {
                align-items: flex-start;
                justify-content: flex-start;
            }
            &.align-top-center {
                align-items: flex-start;
                justify-content: center;
            }
            &.align-top-right {
                align-items: flex-start;
                justify-content: flex-end;
            }
            &.align-center-left {
                align-items: center;
                justify-content: flex-start;
            }
            &.align-center {
                align-items: center;
                justify-content: center;
            }
            &.align-center-right {
                align-items: center;
                justify-content: flex-end;
            }
            &.align-bottom-left {
                align-items: flex-end;
                justify-content: flex-start;
            }
            &.align-bottom-center {
                align-items: flex-end;
                justify-content: center;
            }
            &.align-bottom-right {
                align-items: flex-end;
                justify-content: flex-end;
            }
            .video-cover-content {
                width: 100%;
                max-width: 1200px;
                color: white;
                text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
                // Style inner blocks for better visibility in editor
                h1, h2, h3, h4, h5, h6 {
                    color: white;
                }
                p {
                    color: white;
                }
            }
        }
        .video-info {
            position: absolute;
            bottom: 0;
            left: 0;
            right: 0;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px;
            font-size: 14px;
            z-index: 3;
            p {
                margin: 0;
            }
        }
    }
    .video-cover-placeholder {
        display: flex;
        align-items: center;
        justify-content: center;
        min-height: 200px;
        color: #666;
        font-size: 16px;
    }
}
.video-source-list {
    list-style: none;
    margin: 10px 0;
    padding: 0;
    .video-source-item {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 8px 12px;
        background: #f5f5f5;
        border-radius: 4px;
        margin-bottom: 5px;
        .video-source-mime {
            font-family: monospace;
            font-size: 13px;
        }
    }
}
src/video/index.js
New file
@@ -0,0 +1,21 @@
/* index.js */
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';
registerBlockType(metadata.name, {
    edit: Edit,
    save: ({ attributes }) => {
        const blockProps = useBlockProps.save({
            className: 'video-cover-wrapper-placeholder'
        });
        return (
            <div {...blockProps}>
                <InnerBlocks.Content />
            </div>
        );
    }
});
src/video/index.php
src/video/render.php
New file
@@ -0,0 +1 @@
<?php
src/video/save.js
New file
@@ -0,0 +1,23 @@
/**
 * save.js
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * The save function defines the way in which the different attributes should
 * be combined into the final markup, which is then serialized by the block
 * editor into `post_content`.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#save
 *
 * @return {WPElement} Element to render.
 */
export default function save() {
    // This is a dynamic block that is rendered on the server side
    // Return null to let WordPress handle the saving and rendering
    return null;
}
src/video/style.scss
New file
@@ -0,0 +1,178 @@
/** style.scss **/
.video-cover {
    position: relative;
    width: 100%;
    min-height: 75vh;
    overflow: hidden;
    display: flex;
    .wrap {
        background-color: var(--contrast-200);
        //&::before {
        //  position: absolute;
        //  top: 0;
        //  bottom: 0;
        //  left: 0;
        //  right: 0;
        //  background-color: var(--base);
        //  mix-blend-mode: lighten;
        //  content: '';
        //  z-index: 1;
        //}
    }
    /* Video background */
    .video-container {
        position: absolute;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        min-width: 100%;
        min-height: 100%;
        z-index: 0;
        display: flex;
        background-color: var(--action-50);
        &.fade {
            animation: fadeIn 1s ease-in;
        }
        video {
            pointer-events: none;
            opacity: .85;
            mix-blend-mode: multiply;
            filter: grayscale(100%) contrast(1);
            flex: 1 0 100%;
            object-fit: cover;
        }
    }
    .inner-wrap {
        position: relative;
        z-index: 2;
        width: 100%;
        padding: 2rem;
        color: var(--action-contrast);
        /* Better text readability */
        h1, h2, h3, h4, h5, h6 {
            word-spacing: 100vw;
            color: var(--action-contrast);
            text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
            margin: 2rem 0 0;
        }
        p {
            text-transform: uppercase;
            letter-spacing: 2px;
            margin: 0;
            color: var(--action-contrast);
            text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
        }
        .media-text {
        }
            .media-text figure {
                max-width: 50%;
            }
            @media (min-width: 768px) {
                .media-text {
                    --align: flex-start;
                    gap: 3rem;
                    max-width: var(--content);
                }
            }
            .media-text > div {
                width: fit-content;
            }
        .buttons a {
            font-weight: var(--fw-h-bold);
            color: var(--action-contrast);
            border-color: var(--action-contrast);
            &:visited {
                color: var(--action-contrast);
                &:hover {
                    color: var(--action-contrast);
                }
            }
            &:hover {
                background-color: var(--action-0);
                color: var(--action-contrast);
            }
        }
        .outline a {
            background-color: rgba(var(--base-rgb), rgba(var(--base-rgb),var(--op-3)));
        }
        .buttons {
            margin: 3rem 0;
            li {
                background-color: rgba(var(--action-rgb), var(--op-4));
            }
        }
        /* Button styles */
        .wp-block-button__link {
            text-shadow: none;
        }
    }
    /* Alignment classes */
    &.align-top-left {
        align-items: flex-start;
        justify-content: flex-start;
    }
    &.align-top-center {
        align-items: flex-start;
        justify-content: center;
    }
    &.align-top-right {
        align-items: flex-start;
        justify-content: flex-end;
    }
    &.align-center-left {
        align-items: center;
        justify-content: flex-start;
    }
    &.align-center {
        align-items: center;
        justify-content: center;
    }
    &.align-center-right {
        align-items: center;
        justify-content: flex-end;
    }
    &.align-bottom-left {
        align-items: flex-end;
        justify-content: flex-start;
    }
    &.align-bottom-center {
        align-items: flex-end;
        justify-content: center;
    }
    &.align-bottom-right {
        align-items: flex-end;
        justify-content: flex-end;
    }
    /* Full-width alignment  */
    &.alignfull {
        width: 100vw;
        max-width: none;
        margin-left: calc(50% - 50vw);
        margin-right: calc(50% - 50vw);
    }
    /* Wide alignment */
    &.alignwide {
        max-width: 1200px;
    }
}
@keyframes fadeIn {
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
src/video/view.js
New file
@@ -0,0 +1,47 @@
/** view.js **/
document.addEventListener("DOMContentLoaded", function () {
    const lazyVideos = [].slice.call(
        document.querySelectorAll(".video-container video")
    );
    // Build a helper to actually set sources + load
    function loadVideo(video) {
        const sources = video.querySelectorAll("source[data-src]");
        sources.forEach(source => {
            source.src = source.dataset.src;
        });
        video.load();
    }
    // --- 1. IntersectionObserver (best case) ---
    if ("IntersectionObserver" in window) {
        const lazyVideoObserver = new IntersectionObserver(
            function (entries, observer) {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        loadVideo(entry.target);
                        observer.unobserve(entry.target);
                    }
                });
            },
            {
                rootMargin: "200px 0px",
                threshold: 0.1,
            }
        );
        lazyVideos.forEach(video => lazyVideoObserver.observe(video));
        return;
    }
    // --- 2. Fallback: requestIdleCallback ---
    if ("requestIdleCallback" in window) {
        requestIdleCallback(() => {
            lazyVideos.forEach(video => loadVideo(video));
        });
        return;
    }
    // --- 3. Final fallback: load immediately ---
    lazyVideos.forEach(video => loadVideo(video));
});
templates/dashboard/sections/news.php
@@ -118,7 +118,7 @@
    </div>
    <details class="type-filters">
        <summary class="row btw">Filters:
        <summary class="row x-btw">Filters:
            <button class="clear-filters row">
                <?= jvbIcon('x', ['title' => 'Clear Filters'])?>
                <span>Clear Filters</span>
@@ -274,7 +274,7 @@
</dialog>
<template class="template-own">
    <details class="news item" data-keyboard-nav="true" tabindex="0">
        <summary class="row btw">
        <summary class="row x-btw">
            <div class="item-select">
                <input type="checkbox" class="select-checkbox">
                <label>
@@ -317,7 +317,7 @@
</template>
<template class="template-all template-watching">
    <details class="news item" data-keyboard-nav="true" tabindex="0">
        <summary class="row btw">
        <summary class="row x-btw">
            <button class="favourite-button" data-type="news" title="Add to watch list" onclick="toggleFavourite(this)">
            </button>
templates/dashboard/sections/notifications.php
@@ -29,7 +29,7 @@
?>
<div class="container">
    <nav class="tabs row start" role="tablist">
    <nav class="tabs row left" role="tablist">
    <?php
    $i =0;
webpack.jvb.js
@@ -36,13 +36,13 @@
        'quill':               './assets/js/concise/quill.js',
        'queue':               './assets/js/concise/Queue.js',
        'referral':            './assets/js/concise/Referral.js',
        'referralAdmin':            './assets/js/concise/ReferralAdmin.js',
        'referralAdmin':       './assets/js/concise/ReferralAdmin.js',
        'shopManager':         './assets/js/concise/ShopManager.js',
        'cache':               './assets/js/concise/SimpleCache.js',
        'schema':              './assets/js/concise/SchemaManager.js',
        'square':              './assets/js/concise/CheckoutSquare.js',
        'helcim':              './assets/js/concise/CheckoutHelcim.js',
        'checkout':              './assets/js/concise/Checkout.js',
        'checkout':            './assets/js/concise/Checkout.js',
        'tabs':                './assets/js/concise/Tabs.js',
        'creator':             './assets/js/concise/TaxonomyCreator.js',
        'selector':            './assets/js/concise/TaxonomySelector.js',