Jake Vanderwerf
2025-12-23 25be5747a6e462a3d09fc6607b3639b79e4d9374
=EmailManager.php refactor, Turnstile properly integrated with form submissions
14 files deleted
3 files added
64 files modified
28634 ■■■■■ changed files
SystemReport.php 2137 ●●●●● patch | view | raw | blame | history
assets/css/dash.min.css 2 ●●●●● patch | view | raw | blame | history
assets/css/forms.min.css 2 ●●●●● patch | view | raw | blame | history
assets/js/concise/DataStore.js 479 ●●●●● patch | view | raw | blame | history
assets/js/concise/DataStoreOld.js 1099 ●●●●● patch | view | raw | blame | history
assets/js/concise/ErrorHandler.js 87 ●●●●● patch | view | raw | blame | history
assets/js/concise/FormController.js 217 ●●●●● patch | view | raw | blame | history
assets/js/concise/Gallery.js 44 ●●●●● patch | view | raw | blame | history
assets/js/concise/HandleSelection.js 7 ●●●●● patch | view | raw | blame | history
assets/js/concise/Integrations.js 84 ●●●●● patch | view | raw | blame | history
assets/js/concise/PopulateForm.js 6 ●●●●● patch | view | raw | blame | history
assets/js/concise/Queue.js 128 ●●●●● patch | view | raw | blame | history
assets/js/concise/Referral.js 446 ●●●●● patch | view | raw | blame | history
assets/js/concise/ReferralAdmin.js 335 ●●●●● patch | view | raw | blame | history
assets/js/concise/UploadManager.js 141 ●●●●● patch | view | raw | blame | history
assets/js/concise/navigation.js 4 ●●●●● patch | view | raw | blame | history
assets/js/concise/quill.js 20 ●●●●● patch | view | raw | blame | history
assets/js/dash/CRUDOld.js 1824 ●●●●● patch | view | raw | blame | history
assets/js/dash/Cache.js 1910 ●●●●● patch | view | raw | blame | history
assets/js/dash/FormHandler.js 2834 ●●●●● patch | view | raw | blame | history
assets/js/dash/FormHandlerOld.js 2406 ●●●●● patch | view | raw | blame | history
assets/js/dash/InfiniteScroll.js 25 ●●●●● patch | view | raw | blame | history
assets/js/dash/LoadingManager.js 155 ●●●●● patch | view | raw | blame | history
assets/js/dash/QueueManager.js 2115 ●●●●● patch | view | raw | blame | history
assets/js/dash/QueueManagerBackup.js 2666 ●●●●● patch | view | raw | blame | history
assets/js/dash/TaxonomySelector.js 1151 ●●●●● patch | view | raw | blame | history
assets/js/dash/TaxonomySelectorOld.js 928 ●●●●● patch | view | raw | blame | history
assets/js/dash/TaxonomySelectorPrev.js 572 ●●●●● patch | view | raw | blame | history
assets/js/dash/UploadManager.js 4365 ●●●●● patch | view | raw | blame | history
assets/js/min/dataStore.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/error.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/form.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/gallery.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/handleSelection.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/integrations.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/navigation.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/populate.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/queue.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/quill.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/referral.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/referralAdmin.min.js 1 ●●●●● patch | view | raw | blame | history
assets/js/min/uploader.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/forms/view.asset.php 2 ●●●●● patch | view | raw | blame | history
build/forms/view.js 2 ●●●●● patch | view | raw | blame | history
build/gmbreviews/render.php 24 ●●●●● patch | view | raw | blame | history
build/gmbreviews/style-index-rtl.css 2 ●●●●● patch | view | raw | blame | history
build/gmbreviews/style-index.css 2 ●●●●● patch | view | raw | blame | history
inc/admin/Integrations.php 26 ●●●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 28 ●●●●● patch | view | raw | blame | history
inc/blocks/VideoCoverBlock.php 3 ●●●●● patch | view | raw | blame | history
inc/helpers/ui.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/Cloudflare.php 108 ●●●●● patch | view | raw | blame | history
inc/integrations/Helcim.php 13 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 12 ●●●●● patch | view | raw | blame | history
inc/integrations/Square.php 12 ●●●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 7 ●●●●● patch | view | raw | blame | history
inc/managers/EmailManager.php 378 ●●●●● patch | view | raw | blame | history
inc/managers/ErrorHandler.php 128 ●●●●● patch | view | raw | blame | history
inc/managers/FormManager.php 479 ●●●●● patch | view | raw | blame | history
inc/managers/LoginManager.php 6 ●●●●● patch | view | raw | blame | history
inc/managers/MagicLinkManager.php 60 ●●●●● patch | view | raw | blame | history
inc/managers/NotificationManager.php 28 ●●●●● patch | view | raw | blame | history
inc/managers/OperationQueue.php 84 ●●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 221 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/BreadcrumbManager.php 27 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SchemaOutputManager.php 63 ●●●●● patch | view | raw | blame | history
inc/managers/ScriptLoader.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FormRoutes.php 452 ●●●●● patch | view | raw | blame | history
inc/rest/routes/LoginRoutes.php 14 ●●●●● patch | view | raw | blame | history
inc/rest/routes/QueueRoutes.php 3 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ReferralRoutes.php 63 ●●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 17 ●●●●● patch | view | raw | blame | history
src/feed/view.js 7 ●●●●● patch | view | raw | blame | history
src/forms/view.js 68 ●●●●● patch | view | raw | blame | history
src/glossary/view.js 1 ●●●●● patch | view | raw | blame | history
src/gmbreviews/render.php 24 ●●●●● patch | view | raw | blame | history
src/gmbreviews/style.scss 42 ●●●●● patch | view | raw | blame | history
webpack.jvb.js 1 ●●●●● patch | view | raw | blame | history
SystemReport.php
File was deleted
assets/css/dash.min.css
@@ -1 +1 @@
:target{outline:0!important;padding:0!important}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--btn_)!important}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{position:absolute;bottom:0;right:0}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.grid-view .item .item-actions{bottom:unset;top:0}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:2rem 0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chipchip);padding:.5rem!important;min-width:0;min-height:var(--chipchip);width:var(--chipchip)}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}.dashboard.settings nav.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base-rgb),var(--rgb-medium)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}
:target{outline:0!important;padding:0!important}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin:0 auto 0 var(--btn_)!important}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chipchip);padding:.5rem!important;min-width:0;min-height:var(--chipchip);width:var(--chipchip)}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}
assets/css/forms.min.css
@@ -1 +1 @@
input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}.selected-items{--justify:flex-start;--gap:.5rem;margin-bottom:.5rem}.selected-item{padding:.25rem .5rem;margin:.125em;background:var(--base-100);border-radius:.25rem;font-size:var(--txt-medium);border:1px solid var(--base-200);position:relative}.remove-item{background:0 0;border:none;padding:.25rem;cursor:pointer;color:#666;border-radius:var(--radius);width:1.5em;height:1.5em}.remove-item .close{width:.5em;height:.5em}.remove-item:hover{color:var(--action-0);background:#fee}.clear-filters{margin-left:auto;border:1px solid var(--base-200)}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+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}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+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)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch: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}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:38px;height:38px;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.items-container{margin:0;padding:0;width:100%}.create-new-term{margin-top:1rem;width:100%}.create-new-term .field,.create-new-term[open] summary{margin-bottom:1rem}.create-new-term .field{max-width:100%}#jvb-selector>.wrap{--wrap:nowrap;--justify:flex-start}#jvb-selector .items-wrap{width:100%}#jvb-selector .items-container{display:grid;grid-template-columns:repeat(auto-fit,minmax(1fr,100%))}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group{margin-bottom:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .group-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:var(--btn_);right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--btn_);bottom:var(--btn_);left:1rem;right:1rem;border-radius:var(--radius-outer);padding:1rem;z-index:var(--z-7);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{--height:fit-content;padding:.5rem}.success-message{color:var(--success,#16a34a);background-color:var(--success-bg,#f0fdf4);border:1px solid var(--success,#16a34a);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--success-bg,#f0fdf4);border:2px solid var(--success,#16a34a);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success,#16a34a);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error,#dc2626)}.error-message{color:var(--error,#dc2626);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--error-bg,#fee);border:1px solid var(--error,#dc2626);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success,#16a34a)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.autocomplete-dropdown{width:100%;background-color:var(--base-100);padding:.5rem;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}
input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}.selected-items{--justify:flex-start;--gap:.5rem;margin-bottom:.5rem}.selected-item{padding:.25rem .5rem;margin:.125em;background:var(--base-100);border-radius:.25rem;font-size:var(--txt-medium);border:1px solid var(--base-200);position:relative}.remove-item{background:0 0;border:none;padding:.25rem;cursor:pointer;color:#666;border-radius:var(--radius);width:1.5em;height:1.5em}.remove-item .close{width:.5em;height:.5em}.remove-item:hover{color:var(--action-0);background:#fee}.clear-filters{margin-left:auto;border:1px solid var(--base-200)}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+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}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+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)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch: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}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:38px;height:38px;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.items-container{margin:0;padding:0;width:100%}.create-new-term{margin-top:1rem;width:100%}.create-new-term .field,.create-new-term[open] summary{margin-bottom:1rem}.create-new-term .field{max-width:100%}#jvb-selector>.wrap{--wrap:nowrap;--justify:flex-start}#jvb-selector .items-wrap{width:100%}#jvb-selector .items-container{display:grid;grid-template-columns:repeat(auto-fit,minmax(1fr,100%))}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .group-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:var(--btn_);right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--btn_);bottom:var(--btn_);left:1rem;right:1rem;border-radius:var(--radius-outer);padding:1rem;z-index:var(--z-7);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.autocomplete-dropdown{width:100%;background-color:var(--base-100);padding:.5rem;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}
assets/js/concise/DataStore.js
@@ -27,8 +27,6 @@
        this.loading = document.querySelector('dialog.loading');
        this.init();
        // window.addEventListener('beforeunload', () => this.destroy());
    }
    async init() {
@@ -69,7 +67,6 @@
                throw new Error(`Store "${config.storeName}" requires keyPath`);
            }
            const storeKey = `${name}_${config.storeName}`;
            const store = {
@@ -93,15 +90,13 @@
                    // Behavior
                    showLoading: false,
                    delayFetch: true,
                    validateData: true, // Validate data is serializable
                    validateData: true,
                    ...config
                },
                dbKey: name,
                storeKey: storeKey,
                data: new Map(),
                cache: new Map(),
                httpHeaders: new Map(),
                subscribers: new Map(),
                filters: {...(config.filters || {}) },
                isFetching: false,
                currentRequest: null,
@@ -122,7 +117,6 @@
            }
        });
        // Initialize database asynchronously
        this.initDB(name).catch(error => {
            console.error(`Failed to initialize store "${name}":`, error);
@@ -157,7 +151,6 @@
            // Cache methods
            clearCache: () => this.clearCache(name),
            clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
            // Event methods
            subscribe: (callback) => this.subscribe(name, callback),
@@ -242,9 +235,10 @@
        return formData;
    }
    /**
     * Initialize database for a specific store
     */
    /***********************************************************************
     * DATABASE INITIALIZATION
     ***********************************************************************/
    async initDB(name) {
        const db = this.dbConfig.get(name);
        if (!db || db._initialized) return;
@@ -290,7 +284,7 @@
                    this.loadStoreDataInBackground(storeName);
                    this.notify(storeName, 'db-init');
                }
            })
            });
        } catch (error) {
            console.error(`Failed to initialize database for store "${name}":`, error);
@@ -332,29 +326,55 @@
            });
        }
        // Cache store
        // Cache store (now includes HTTP headers)
        if (config.endpoint && !db.objectStoreNames.contains('cache')) {
            const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
            cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
        }
        // HTTP headers store
        if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
            db.createObjectStore('headers', { keyPath: 'key' });
        }
    /**
     * Generic loader for any object store
     */
    async loadFromObjectStore(name, storeName, processItem) {
        const store = this.stores.get(name);
        if (!store?.db || !store.db.objectStoreNames.contains(storeName)) {
            return [];
        }
        return new Promise((resolve) => {
            const tx = store.db.transaction([storeName], 'readonly');
            const objectStore = tx.objectStore(storeName);
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                const items = e.target.result || [];
                items.forEach(processItem);
                resolve(items);
            };
            request.onerror = () => resolve([]);
        });
    }
    loadStoreDataInBackground(name) {
        const store = this.stores.get(name);
        if (!store?.db) return;
        const tasks = [
            this.loadStoreData(name),
            this.loadStoreCache(name),
            this.loadStoreHeaders(name)
        ];
        Promise.all([
            // Load main data
            this.loadFromObjectStore(name, store.config.storeName, (item) => {
                const key = this.getItemKey(item, store.config.keyPath);
                store.data.set(key, item);
            }),
        Promise.all(tasks)
            // Load cache (includes HTTP headers now!)
            this.loadFromObjectStore(name, 'cache', (item) => {
                if (this.isCacheValid(item, store.config.TTL)) {
                    store.cache.set(item.key, item);
                }
            })
        ])
            .then(() => {
                this.notify(name, 'data-ready');
@@ -407,71 +427,6 @@
        }
    }
    async loadStoreData(name) {
        const store = this.stores.get(name);
        if (!store?.db) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction([store.config.storeName], 'readonly');
            const objectStore = tx.objectStore(store.config.storeName);
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                const items = e.target.result || [];
                items.forEach(item => {
                    const key = this.getItemKey(item, store.config.keyPath);
                    store.data.set(key, item);
                });
                this.notify(name, 'data-loaded', { count: items.length });
                resolve(items);
            };
            request.onerror = () => resolve([]);
        });
    }
    async loadStoreCache(name) {
        const store = this.stores.get(name);
        if (!store?.db || !store.db.objectStoreNames.contains('cache')) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction(['cache'], 'readonly');
            const objectStore = tx.objectStore('cache');
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                (e.target.result || []).forEach(item => {
                    if (this.isCacheValid(item, store.config.TTL)) {
                        store.cache.set(item.key, item);
                    }
                });
                resolve();
            };
            request.onerror = () => resolve();
        });
    }
    async loadStoreHeaders(name) {
        const store = this.stores.get(name);
        if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction(['headers'], 'readonly');
            const objectStore = tx.objectStore('headers');
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                (e.target.result || []).forEach(header => {
                    store.httpHeaders.set(header.key, header);
                });
                resolve();
            };
            request.onerror = () => resolve();
        });
    }
    async ensureStoreInitialized(name) {
        const store = this.stores.get(name);
        if (!store) {
@@ -483,6 +438,42 @@
        }
    }
    /***********************************************************************
     * TRANSACTION HELPER
     ***********************************************************************/
    /**
     * Create transaction helper - reduces boilerplate
     */
    async withTransaction(name, storeNames, mode, callback) {
        const store = this.stores.get(name);
        if (!store?.db) return null;
        // Ensure storeNames is an array
        if (typeof storeNames === 'string') storeNames = [storeNames];
        return new Promise((resolve, reject) => {
            const tx = store.db.transaction(storeNames, mode);
            const stores = storeNames.map(name => tx.objectStore(name));
            const objectStore = stores.length === 1 ? stores[0] : stores;
            let result;
            tx.oncomplete = () => resolve(result);
            tx.onerror = () => reject(tx.error);
            // Call callback immediately to queue operations
            try {
                result = callback(objectStore, tx);
            } catch (error) {
                reject(error);
            }
        });
    }
    /***********************************************************************
     * FETCH & DATA PROCESSING
     ***********************************************************************/
    async fetch(name) {
        await this.ensureStoreInitialized(name);
@@ -524,15 +515,11 @@
            const url = this.buildFetchUrl(name);
            const headers = { ...store.config.headers };
            const cachedHeaders = store.httpHeaders.get(cacheKey);
            if (store.config.useHttpCaching && cachedHeaders) {
                if (cachedHeaders.etag) {
                    headers['If-None-Match'] = cachedHeaders.etag;
                }
                if (cachedHeaders.lastModified) {
                    headers['If-Modified-Since'] = cachedHeaders.lastModified;
                }
            // Use HTTP cache headers from cache entry
            if (store.config.useHttpCaching && cached) {
                if (cached.etag) headers['If-None-Match'] = cached.etag;
                if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified;
            }
            const controller = new AbortController();
@@ -556,7 +543,6 @@
                }
                // No cached data but server says not modified - return empty result
                // This can happen on first load when cache headers exist but data doesn't
                this.notify(name, 'data-loaded', {
                    cached: false,
                    notModified: true,
@@ -581,10 +567,7 @@
            const data = await response.json();
            if (store.config.useHttpCaching) {
                this.storeResponseHeaders(name, cacheKey, response);
            }
            await this.processFetchedData(name, data, cacheKey);
            await this.processFetchedData(name, data, cacheKey, response);
            this.notify(name, 'data-loaded', {
                cached: false,
@@ -628,47 +611,53 @@
        return params.toString() ? `${baseUrl}?${params}` : baseUrl;
    }
    async processFetchedData(name, data, cacheKey) {
    /**
     * Process fetched data (batch from server)
     */
    async processFetchedData(name, data, cacheKey, response) {
        const store = this.stores.get(name);
        const items = data.items || [];
        const changes = []; // Track all changes
        // Batch process all items in a single transaction
        // Batch process with single transaction
        if (store.db && items.length > 0) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            for (const item of items) {
                const result = this.processForStorage(item, store.config.validateData);
                if (result.valid) {
                    const key = this.getItemKey(result.data, store.config.keyPath);
                    // Store in memory
                    store.data.set(key, item);
            await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
                items.forEach(item => {
                    try {
                        // Use shared save logic
                        const changeInfo = this._saveItem(name, item);
                        changes.push(changeInfo);
                    // Queue for batch write
                    await objectStore.put(result.data);
                        objectStore.put(changeInfo.processed);
                    } catch (error) {
                        console.error(`Error processing item:`, error);
                }
            }
            // Wait for transaction to complete
            await new Promise((resolve, reject) => {
                tx.oncomplete = () => resolve();
                tx.onerror = () => reject(tx.error);
            });
            });
        }
        // Update cache (now includes HTTP headers!)
        const cacheEntry = {
            key: cacheKey,
            items: items.map(item => this.getItemKey(item, store.config.keyPath)),
            timestamp: Date.now(),
            endpoint: store.config.endpoint,
            filters: { ...store.filters }
            filters: { ...store.filters },
            etag: response.headers.get('ETag'),
            lastModified: response.headers.get('Last-Modified')
        };
        store.cache.set(cacheKey, cacheEntry);
        await this.saveToCache(name, cacheKey, cacheEntry);
        // Save cache to IndexedDB
        if (store.db?.objectStoreNames.contains('cache')) {
            await this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
                objectStore.put(cacheEntry);
            });
        }
        // Update lastResponse metadata
        store.lastResponse = {
            ...data,
            has_more: data.has_more || false,
@@ -676,13 +665,28 @@
            pages: data.pages || 1,
            queue_stats: data.queue_stats || {}
        };
        // Emit events for items with status changes
        changes.forEach(changeInfo => {
            if (changeInfo.statusChanged) {
                this.notify(name, 'item-saved', {
                    item: changeInfo.item,
                    key: changeInfo.key,
                    previousItem: changeInfo.previousItem
                });
            }
        });
    }
    /***********************************************************************
     * SAVE OPERATIONS
     ***********************************************************************/
    /**
     * Save item to store
     * IMPORTANT: Item must be serializable (no DOM, FormData, Blobs)
     * Internal method: Save a single item with full tracking
     * Returns change info without writing to IndexedDB (caller handles that)
     */
    async save(name, item) {
    _saveItem(name, item) {
        const store = this.stores.get(name);
        const result = this.processForStorage(item, store.config.validateData);
@@ -693,18 +697,42 @@
        const key = this.getItemKey(processed, store.config.keyPath);
        // Store the original in memory (with original data intact)
        // Capture previous state
        const previousItem = store.data.get(key);
        // Update in-memory store (with original data intact)
        store.data.set(key, item);
        // Store processed in IndexedDB
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.put(processed);
        // Return change info for event emission
        return {
            item,
            previousItem,
            key,
            processed,
            statusChanged: previousItem && previousItem.status !== item.status
        };
        }
        this.notify(name, 'item-saved', { item, key });
        return key;
    /**
     * Save single item (public API)
     */
    async save(name, item) {
        const store = this.stores.get(name);
        const changeInfo = this._saveItem(name, item);
        // Write to IndexedDB immediately for single saves
        await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
            objectStore.put(changeInfo.processed);
        });
        // Always emit for explicit saves
        this.notify(name, 'item-saved', {
            item: changeInfo.item,
            key: changeInfo.key,
            previousItem: changeInfo.previousItem
        });
        return changeInfo.key;
    }
    processForStorage(obj, validate = true, path = 'root') {
@@ -777,15 +805,17 @@
            : { valid: true, data: null };
    }
    /***********************************************************************
     * DATA ACCESS
     ***********************************************************************/
    async delete(name, id) {
        const store = this.stores.get(name);
        store.data.delete(id);
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.delete(id);
        }
        await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
            objectStore.delete(id);
        });
        this.notify(name, 'item-deleted', { id });
    }
@@ -821,34 +851,52 @@
        store.data.clear();
        store.cache.clear();
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.clear();
        }
        await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
            objectStore.clear();
        });
        this.notify(name, 'data-cleared');
    }
    setFilter(name, key, value) {
        const store = this.stores.get(name);
        const oldValue = store.filters[key];
    /***********************************************************************
     * FILTER OPERATIONS (UNIFIED)
     ***********************************************************************/
    /**
     * Unified filter update - handles all filter operations
     */
    async updateFilters(name, updates, clearAll = false) {
        const store = this.stores.get(name);
        const oldFilters = { ...store.filters };
        if (clearAll) {
            store.filters = { ...store.config.filters };
        } else {
            // Apply updates (null/undefined/'' = delete)
            Object.entries(updates).forEach(([key, value]) => {
        if (value === null || value === undefined || value === '') {
            delete store.filters[key];
        } else {
            store.filters[key] = value;
        }
            });
        }
        this.notify(name, 'filters-changed', {
            oldFilters,
            filters: store.filters,
            changed: { key, oldValue, newValue: value }
            updates
        });
        if (store.config.endpoint) {
            this.fetch(name);
            await this.fetch(name);
        }
    }
    setFilter(name, key, value) {
        return this.updateFilters(name, { [key]: value });
    }
    async setFilters(name, filters) {
        const store = this.stores.get(name);
@@ -858,86 +906,53 @@
        if (!hasChanges) return;
        store.filters = { ...store.filters, ...filters };
        this.notify(name, 'filters-changed', {
            filters: store.filters,
            changed: filters
        });
        if (store.config.endpoint) {
            await this.fetch(name);
        }
        return this.updateFilters(name, filters);
    }
    removeFilter(name, key) {
        const store = this.stores.get(name);
        const oldValue = store.filters[key];
        if (oldValue !== undefined) {
            delete store.filters[key];
            this.notify(name, 'filters-changed', {
                filters: store.filters,
                removed: { key, oldValue }
            });
            if (store.config.endpoint) {
                this.fetch(name);
            }
        }
        return this.updateFilters(name, { [key]: null });
    }
    clearFilters(name) {
        const store = this.stores.get(name);
        const oldFilters = { ...store.filters };
        store.filters = { ...store.config.filters };
        this.notify(name, 'filters-cleared', {
            oldFilters,
            filters: store.filters
        });
        if (store.config.endpoint) {
            this.fetch(name);
        return this.updateFilters(name, {}, true);
        }
    }
    /***********************************************************************
     * CACHE OPERATIONS
     ***********************************************************************/
    clearCache(name) {
        const store = this.stores.get(name);
        store.cache.clear();
        if (store.db && store.db.objectStoreNames.contains('cache')) {
            const tx = store.db.transaction(['cache'], 'readwrite');
            const objectStore = tx.objectStore('cache');
        if (store.db?.objectStoreNames.contains('cache')) {
            this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
            objectStore.clear();
            });
        }
        this.notify(name, 'cache-cleared');
    }
    clearHttpHeaders(name, cacheKey = null) {
        const store = this.stores.get(name);
    generateCacheKey(filters) {
        const normalized = Object.keys(filters)
            .sort()
            .reduce((acc, key) => {
                acc[key] = filters[key];
                return acc;
            }, {});
        return JSON.stringify(normalized);
    }
        if (cacheKey) {
            store.httpHeaders.delete(cacheKey);
    isCacheValid(entry, ttl) {
        if (!entry || !entry.timestamp) return false;
        const age = Date.now() - entry.timestamp;
        return age < ttl;
    }
            if (store.db && store.db.objectStoreNames.contains('headers')) {
                const tx = store.db.transaction(['headers'], 'readwrite');
                const objectStore = tx.objectStore('headers');
                objectStore.delete(cacheKey);
            }
        } else {
            store.httpHeaders.clear();
            if (store.db && store.db.objectStoreNames.contains('headers')) {
                const tx = store.db.transaction(['headers'], 'readwrite');
                const objectStore = tx.objectStore('headers');
                objectStore.clear();
            }
        }
    }
    /***********************************************************************
     * EVENT SYSTEM
     ***********************************************************************/
    subscribe(name, callback) {
        if (!this.subscribers.has(name)) {
@@ -961,49 +976,9 @@
        });
    }
    storeResponseHeaders(name, key, response) {
        const store = this.stores.get(name);
        const headers = {
            key,
            etag: response.headers.get('ETag'),
            lastModified: response.headers.get('Last-Modified'),
            timestamp: Date.now()
        };
        store.httpHeaders.set(key, headers);
        if (store.db && store.db.objectStoreNames.contains('headers')) {
            const tx = store.db.transaction(['headers'], 'readwrite');
            const objectStore = tx.objectStore('headers');
            objectStore.put(headers);
        }
    }
    async saveToCache(name, key, data) {
        const store = this.stores.get(name);
        if (!store.db || !store.db.objectStoreNames.contains('cache')) return;
        const tx = store.db.transaction(['cache'], 'readwrite');
        const objectStore = tx.objectStore('cache');
        await objectStore.put(data);
    }
    generateCacheKey(filters) {
        const normalized = Object.keys(filters)
            .sort()
            .reduce((acc, key) => {
                acc[key] = filters[key];
                return acc;
            }, {});
        return JSON.stringify(normalized);
    }
    isCacheValid(entry, ttl) {
        if (!entry || !entry.timestamp) return false;
        const age = Date.now() - entry.timestamp;
        return age < ttl;
    }
    /***********************************************************************
     * UTILITIES
     ***********************************************************************/
    getItemKey(item, keyPath) {
        if (typeof keyPath === 'function') {
assets/js/concise/DataStoreOld.js
New file
@@ -0,0 +1,1099 @@
/**
 * DataStore - Singleton pattern managing multiple store namespaces
 *
 * Usage:
 *   window.jvbStore = new DataStore();
 *   this.store = window.jvbStore.register('feed', { config });
 */
class DataStoreOld {
    constructor() {
        // Singleton pattern
        if (DataStore.instance) {
            return DataStore.instance;
        }
        DataStore.instance = this;
        // Shared resources
        this.dbConfig = new Map();      // Definitions for the databases
        this.databases = new Map();     // Shared IndexedDB connections
        this.stores = new Map();        // Registered store namespaces
        this.subscribers = new Map();   // Per-store event subscribers
        this.pendingInits = new Map();  // Track initialization promises
        this.fetchQueue = [];
        // Global state
        this._initialized = false;
        this.body = document.body;
        this.loading = document.querySelector('dialog.loading');
        this.init();
        // window.addEventListener('beforeunload', () => this.destroy());
    }
    async init() {
        if (this._initialized) return;
        this._initialized = true;
        if (!('indexedDB' in window)) {
            console.warn('IndexedDB not supported');
        }
    }
    /**
     * Register a new store namespace
     * @param {string} name Database Name
     * @param {object|array} configs An object defining the store, or an array of objects defining the stores
     * @param {number} version the database version
     */
    register(name, configs = [], version = 1.1) {
        if (!Array.isArray(configs)) configs = [configs];
        if (configs.length === 0) return;
        if (!this.dbConfig.has(name)) {
            this.dbConfig.set(name, {
                dbName: `jvb_${name}`,
                version: version,
                stores: {},
                _initialized: false
            });
        }
        let dbEntry = this.dbConfig.get(name);
        configs.forEach(config => {
            if (!config.storeName) {
                throw new Error(`Store config for "${name}" missing storeName`);
            }
            if (!config.keyPath) {
                throw new Error(`Store "${config.storeName}" requires keyPath`);
            }
            const storeKey = `${name}_${config.storeName}`;
            const store = {
                config: {
                    // Storage
                    dbName: dbEntry.dbName,
                    storeName: 'items',
                    keyPath: 'id',
                    indexes: [],
                    // API
                    endpoint: null,
                    apiBase: jvbSettings.api,
                    filters: {},
                    required: null,
                    // Cache
                    TTL: 3600000, // 1 hour
                    useHttpCaching: true,
                    // Behavior
                    showLoading: false,
                    delayFetch: true,
                    validateData: true, // Validate data is serializable
                    ...config
                },
                dbKey: name,
                storeKey: storeKey,
                data: new Map(),
                cache: new Map(),
                httpHeaders: new Map(),
                subscribers: new Map(),
                filters: {...(config.filters || {}) },
                isFetching: false,
                currentRequest: null,
                lastResponse: null,
                _initialized: false
            };
            store.config.headers = {
                'X-WP-Nonce': window.auth.getNonce(),
                ...store.config.headers
            };
            dbEntry.stores[config.storeName] = storeKey;
            this.stores.set(storeKey, store);
            if (!this.subscribers.has(storeKey)) {
                this.subscribers.set(storeKey, new Set());
            }
        });
        // Initialize database asynchronously
        this.initDB(name).catch(error => {
            console.error(`Failed to initialize store "${name}":`, error);
        });
        const apis = {};
        for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) {
            apis[storeName] = this.getStoreAPI(storeKey);
        }
        return apis;
    }
    /**
     * Get the API object for a registered store
     */
    getStoreAPI(name) {
        const api = {
            // Data methods
            fetch: () => this.fetch(name),
            save: (item) => this.save(name, item),
            delete: (id) => this.delete(name, id),
            get: (id) => this.get(name, id),
            getAll: () => this.getAll(name),
            getFiltered: () => this.getFiltered(name),
            clear: () => this.clear(name),
            // Filter methods
            setFilter: (key, value) => this.setFilter(name, key, value),
            setFilters: (filters) => this.setFilters(name, filters),
            removeFilter: (key) => this.removeFilter(name, key),
            clearFilters: () => this.clearFilters(name),
            // Cache methods
            clearCache: () => this.clearCache(name),
            clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
            // Event methods
            subscribe: (callback) => this.subscribe(name, callback),
            // Utility
            ensureInitialized: () => this.ensureStoreInitialized(name),
            // Exposed properties (read-only)
            get filters() {
                return { ...api.getStore().filters };
            },
            get lastResponse() {
                return api.getStore().lastResponse;
            },
            get data() {
                return api.getStore().data;
            },
            getStore: () => this.stores.get(name)
        };
        return api;
    }
    /**
     * Convert FormData to plain object for storage
     */
    formDataToObject(formData) {
        const obj = {
            _isFormData: true,
            entries: {}
        };
        for (const [key, value] of formData.entries()) {
            // Skip File/Blob objects - they're stored separately in UploadManager
            if (value instanceof File || value instanceof Blob) {
                continue;
            }
            // Handle multiple values for same key
            if (obj.entries[key]) {
                if (!Array.isArray(obj.entries[key])) {
                    obj.entries[key] = [obj.entries[key]];
                }
                obj.entries[key].push(value);
            } else {
                obj.entries[key] = value;
            }
        }
        return obj;
    }
    /**
     * Convert stored object back to FormData
     */
    async objectToFormData(obj) {
        if (!obj._isFormData) return obj;
        const formData = new FormData();
        // Restore text entries
        for (const [key, value] of Object.entries(obj.entries)) {
            if (Array.isArray(value)) {
                value.forEach(v => formData.append(key, v));
            } else {
                formData.append(key, value);
            }
        }
        if (window.jvbUploads && obj.entries.upload_ids) {
            const uploadIds = JSON.parse(obj.entries.upload_ids);
            for (const uploadId of uploadIds) {
                const file = await window.jvbUploads.getBlobData(uploadId);
                if (file) {
                    formData.append('files[]', file);
                }
            }
        }
        return formData;
    }
    /**
     * Initialize database for a specific store
     */
    async initDB(name) {
        const db = this.dbConfig.get(name);
        if (!db || db._initialized) return;
        if (this.pendingInits.has(name)) {
            return this.pendingInits.get(name);
        }
        const initPromise = this._performDBInit(name);
        this.pendingInits.set(name, initPromise);
        try {
            await initPromise;
            db._initialized = true;
        } finally {
            this.pendingInits.delete(name);
        }
    }
    async _performDBInit(name) {
        const database = this.dbConfig.get(name);
        const { dbName, version } = database;
        const stores = Object.values(database.stores);
        try {
            if (!this.databases.has(dbName)) {
                const db = await this.openDatabase(dbName, version, (db) => {
                    stores.forEach(store => {
                        let storeObj = this.stores.get(store);
                        if (storeObj) {
                            this.setupStores(db, storeObj.config);
                        }
                    });
                });
                this.databases.set(dbName, db);
            }
            stores.forEach(storeName => {
                let store = this.stores.get(storeName);
                if (store) {
                    store.db = this.databases.get(dbName);
                    store._initialized = true;
                    this.loadStoreDataInBackground(storeName);
                    this.notify(storeName, 'db-init');
                }
            })
        } catch (error) {
            console.error(`Failed to initialize database for store "${name}":`, error);
            throw error;
        }
    }
    openDatabase(dbName, version, onUpgrade) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(dbName, version);
            request.onupgradeneeded = (e) => {
                if (onUpgrade) {
                    onUpgrade(e.target.result, e.oldVersion, e.newVersion);
                }
            };
            request.onsuccess = (e) => resolve(e.target.result);
            request.onerror = (e) => reject(e.target.error);
            request.onblocked = () => {
                console.warn(`Database ${dbName} blocked. Close other tabs.`);
            };
        });
    }
    setupStores(db, config) {
        // Main store
        if (!db.objectStoreNames.contains(config.storeName)) {
            const store = db.createObjectStore(config.storeName, {
                keyPath: config.keyPath
            });
            config.indexes.forEach(index => {
                store.createIndex(
                    index.name,
                    index.keyPath || index.name,
                    { unique: index.unique || false }
                );
            });
        }
        // Cache store
        if (config.endpoint && !db.objectStoreNames.contains('cache')) {
            const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
            cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
        }
        // HTTP headers store
        if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
            db.createObjectStore('headers', { keyPath: 'key' });
        }
    }
    loadStoreDataInBackground(name) {
        const store = this.stores.get(name);
        if (!store?.db) return;
        const tasks = [
            this.loadStoreData(name),
            this.loadStoreCache(name),
            this.loadStoreHeaders(name)
        ];
        Promise.all(tasks)
            .then(() => {
                this.notify(name, 'data-ready');
                // Add to fetch queue instead of immediate fetch
                if (store.config.endpoint && store.config.delayFetch) {
                    this.fetchQueue.push(name);
                    // Start processing queue if not already running
                    if (this.fetchQueue.length === 1) {
                        this.processFetchQueue();
                    }
                } else if (store.config.endpoint && !store.config.delayFetch) {
                    // Immediate fetch
                    if ('requestIdleCallback' in window) {
                        requestIdleCallback(() => this.fetch(name), { timeout: 2000 });
                    } else {
                        setTimeout(() => this.fetch(name), 100);
                    }
                }
            })
            .catch(error => {
                console.error(`Background load error for store "${name}":`, error);
            });
    }
    async processFetchQueue() {
        if (this.fetchQueue.length === 0) return;
        const name = this.fetchQueue.shift();
        const store = this.stores.get(name);
        if (!store) {
            // Store was removed, continue with next
            return this.processFetchQueue();
        }
        try {
            await this.fetch(name);
        } catch (error) {
            console.error(`Queue fetch error for "${name}":`, error);
        }
        // Process next item with idle callback
        if (this.fetchQueue.length > 0) {
            if ('requestIdleCallback' in window) {
                requestIdleCallback(() => this.processFetchQueue(), { timeout: 2000 });
            } else {
                setTimeout(() => this.processFetchQueue(), 50);
            }
        }
    }
    async loadStoreData(name) {
        const store = this.stores.get(name);
        if (!store?.db) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction([store.config.storeName], 'readonly');
            const objectStore = tx.objectStore(store.config.storeName);
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                const items = e.target.result || [];
                items.forEach(item => {
                    const key = this.getItemKey(item, store.config.keyPath);
                    store.data.set(key, item);
                });
                this.notify(name, 'data-loaded', { count: items.length });
                resolve(items);
            };
            request.onerror = () => resolve([]);
        });
    }
    async loadStoreCache(name) {
        const store = this.stores.get(name);
        if (!store?.db || !store.db.objectStoreNames.contains('cache')) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction(['cache'], 'readonly');
            const objectStore = tx.objectStore('cache');
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                (e.target.result || []).forEach(item => {
                    if (this.isCacheValid(item, store.config.TTL)) {
                        store.cache.set(item.key, item);
                    }
                });
                resolve();
            };
            request.onerror = () => resolve();
        });
    }
    async loadStoreHeaders(name) {
        const store = this.stores.get(name);
        if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
        return new Promise((resolve) => {
            const tx = store.db.transaction(['headers'], 'readonly');
            const objectStore = tx.objectStore('headers');
            const request = objectStore.getAll();
            request.onsuccess = (e) => {
                (e.target.result || []).forEach(header => {
                    store.httpHeaders.set(header.key, header);
                });
                resolve();
            };
            request.onerror = () => resolve();
        });
    }
    async ensureStoreInitialized(name) {
        const store = this.stores.get(name);
        if (!store) {
            throw new Error(`Store "${name}" not registered`);
        }
        if (!store._initialized) {
            await this.initDB(store.dbKey);
        }
    }
    async fetch(name) {
        await this.ensureStoreInitialized(name);
        const store = this.stores.get(name);
        if (store.isFetching) return;
        // Check required filters
        if (store.config.required) {
            const required = Array.isArray(store.config.required)
                ? store.config.required
                : [store.config.required];
            const missing = required.some(key =>
                !store.filters[key] || store.filters[key] === ''
            );
            if (missing) return;
        }
        store.isFetching = true;
        try {
            // Check cache
            const cacheKey = this.generateCacheKey(store.filters);
            const cached = store.cache.get(cacheKey);
            if (cached && this.isCacheValid(cached, store.config.TTL)) {
                this.notify(name, 'data-loaded', {
                    cached: true,
                    items: cached.items || []
                });
                return cached;
            }
            if (store.config.showLoading) {
                this.setLoading(true);
            }
            const url = this.buildFetchUrl(name);
            const headers = { ...store.config.headers };
            const cachedHeaders = store.httpHeaders.get(cacheKey);
            if (store.config.useHttpCaching && cachedHeaders) {
                if (cachedHeaders.etag) {
                    headers['If-None-Match'] = cachedHeaders.etag;
                }
                if (cachedHeaders.lastModified) {
                    headers['If-Modified-Since'] = cachedHeaders.lastModified;
                }
            }
            const controller = new AbortController();
            store.currentRequest = controller;
            const response = await fetch(url, {
                method: 'GET',
                headers,
                signal: controller.signal
            });
            if (response.status === 304) {
                // 304 means "Not Modified" - use cached data if available
                if (cached) {
                    this.notify(name, 'data-loaded', {
                        cached: true,
                        notModified: true,
                        items: cached.items || []
                    });
                    return cached;
                }
                // No cached data but server says not modified - return empty result
                // This can happen on first load when cache headers exist but data doesn't
                this.notify(name, 'data-loaded', {
                    cached: false,
                    notModified: true,
                    items: []
                });
                // Initialize empty lastResponse
                store.lastResponse = {
                    has_more: false,
                    total: 0,
                    pages: 1,
                    queue_stats: {}
                };
                return { items: [] };
            }
            // Now check for other non-OK responses
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }
            const data = await response.json();
            if (store.config.useHttpCaching) {
                this.storeResponseHeaders(name, cacheKey, response);
            }
            await this.processFetchedData(name, data, cacheKey);
            this.notify(name, 'data-loaded', {
                cached: false,
                items: data.items || []
            });
            return data;
        } catch (error) {
            if (error.name !== 'AbortError') {
                console.error(`Fetch error for store "${name}":`, error);
                this.notify(name, 'fetch-error', { error });
            }
            throw error;
        } finally {
            store.isFetching = false;
            store.currentRequest = null;
            if (store.config.showLoading) {
                this.setLoading(false);
            }
        }
    }
    buildFetchUrl(name) {
        const store = this.stores.get(name);
        const params = new URLSearchParams();
        Object.entries(store.filters).forEach(([key, value]) => {
            if (value !== null && value !== undefined && value !== '') {
                if (typeof value === 'object') {
                    params.set(key, JSON.stringify(value));
                } else {
                    params.set(key, value);
                }
            }
        });
        const baseUrl = store.config.apiBase + store.config.endpoint;
        return params.toString() ? `${baseUrl}?${params}` : baseUrl;
    }
    /**
     * Process fetched data (batch from server)
     */
    async processFetchedData(name, data, cacheKey) {
        const store = this.stores.get(name);
        const items = data.items || [];
        const changes = []; // Track all changes
        // Batch process with single transaction
        if (store.db && items.length > 0) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            for (const item of items) {
                try {
                    // Use shared save logic
                    const changeInfo = await this._saveItem(name, item, false);
                    changes.push(changeInfo);
                    // Queue for batch write
                    await objectStore.put(changeInfo.processed);
                } catch (error) {
                    console.error(`Error processing item:`, error);
                }
            }
            // Wait for transaction to complete
            await new Promise((resolve, reject) => {
                tx.oncomplete = () => resolve();
                tx.onerror = () => reject(tx.error);
            });
        }
        // Update cache
        const cacheEntry = {
            key: cacheKey,
            items: items.map(item => this.getItemKey(item, store.config.keyPath)),
            timestamp: Date.now(),
            endpoint: store.config.endpoint,
            filters: { ...store.filters }
        };
        store.cache.set(cacheKey, cacheEntry);
        await this.saveToCache(name, cacheKey, cacheEntry);
        // Update lastResponse metadata
        store.lastResponse = {
            ...data,
            has_more: data.has_more || false,
            total: data.total || items.length,
            pages: data.pages || 1,
            queue_stats: data.queue_stats || {}
        };
        // Emit events for items with status changes
        changes.forEach(changeInfo => {
            if (changeInfo.statusChanged) {
                this.notify(name, 'item-saved', {
                    item: changeInfo.item,
                    key: changeInfo.key,
                    previousItem: changeInfo.previousItem
                });
            }
        });
    }
    /**
     * Internal method: Save a single item with full tracking
     */
    async _saveItem(name, item, emitEvent = true) {
        const store = this.stores.get(name);
        const result = this.processForStorage(item, store.config.validateData);
        if (!result.valid) {
            throw new Error(`Non-serializable data: ${result.error}`);
        }
        const processed = result.data;
        const key = this.getItemKey(processed, store.config.keyPath);
        // Capture previous state
        const previousItem = store.data.get(key);
        // Update in-memory store (with original data intact)
        store.data.set(key, item);
        // Write to IndexedDB happens in batch transaction (if provided)
        // or individual transaction (if not)
        // Return change info for event emission
        return {
            item,
            previousItem,
            key,
            processed,
            statusChanged: previousItem && previousItem.status !== item.status
        };
    }
    /**
     * Save single item (public API)
     */
    async save(name, item) {
        const store = this.stores.get(name);
        const changeInfo = await this._saveItem(name, item);
        // Write to IndexedDB immediately for single saves
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.put(changeInfo.processed);
        }
        // Always emit for explicit saves
        this.notify(name, 'item-saved', {
            item: changeInfo.item,
            key: changeInfo.key,
            previousItem: changeInfo.previousItem
        });
        return changeInfo.key;
    }
    processForStorage(obj, validate = true, path = 'root') {
        if (obj === null || obj === undefined) return { valid: true, data: obj };
        const type = typeof obj;
        // Handle primitives
        if (['string', 'number', 'boolean'].includes(type)) {
            return { valid: true, data: obj };
        }
        // Reject functions
        if (type === 'function') {
            return validate ? { valid: false, error: `Function at ${path}` } : { valid: true, data: null };
        }
        // DOM elements
        if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
            return validate ? { valid: false, error: `DOM element at ${path}` } : { valid: true, data: null };
        }
        // FormData - convert and continue
        if (obj instanceof FormData) {
            return validate
                ? { valid: false, error: `FormData at ${path}` }
                : { valid: true, data: this.formDataToObject(obj) };
        }
        // Preserve safe types
        if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
            return { valid: true, data: obj };
        }
        // Convert Sets to Arrays
        if (obj instanceof Set) {
            const arr = Array.from(obj);
            return this.processForStorage(arr, validate, path);
        }
        // Convert Maps to Objects
        if (obj instanceof Map) {
            obj = Object.fromEntries(obj);
        }
        // Arrays
        if (Array.isArray(obj)) {
            const processed = [];
            for (let i = 0; i < obj.length; i++) {
                const result = this.processForStorage(obj[i], validate, `${path}[${i}]`);
                if (!result.valid) return result;
                if (result.data !== null) processed.push(result.data);
            }
            return { valid: true, data: processed };
        }
        // Objects
        if (type === 'object') {
            const processed = {};
            for (const [key, value] of Object.entries(obj)) {
                const result = this.processForStorage(value, validate, `${path}.${key}`);
                if (!result.valid) return result;
                if (result.data !== null) processed[key] = result.data;
            }
            return { valid: true, data: processed };
        }
        return validate
            ? { valid: false, error: `Unknown type at ${path}` }
            : { valid: true, data: null };
    }
    async delete(name, id) {
        const store = this.stores.get(name);
        store.data.delete(id);
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.delete(id);
        }
        this.notify(name, 'item-deleted', { id });
    }
    get(name, id) {
        const store = this.stores.get(name);
        return store.data.get(id);
    }
    getAll(name) {
        const store = this.stores.get(name);
        return Array.from(store.data.values());
    }
    getFiltered(name) {
        const store = this.stores.get(name);
        const cacheKey = this.generateCacheKey(store.filters);
        const cacheEntry = store.cache.get(cacheKey);
        if (cacheEntry && cacheEntry.items) {
            return cacheEntry.items.reduce((acc, id) => {
                const item = store.data.get(id);
                if (item) acc.push(item);
                return acc;
            }, []);
        }
        return this.getAll(name);
    }
    async clear(name) {
        const store = this.stores.get(name);
        store.data.clear();
        store.cache.clear();
        if (store.db) {
            const tx = store.db.transaction([store.config.storeName], 'readwrite');
            const objectStore = tx.objectStore(store.config.storeName);
            await objectStore.clear();
        }
        this.notify(name, 'data-cleared');
    }
    setFilter(name, key, value) {
        const store = this.stores.get(name);
        const oldValue = store.filters[key];
        if (value === null || value === undefined || value === '') {
            delete store.filters[key];
        } else {
            store.filters[key] = value;
        }
        this.notify(name, 'filters-changed', {
            filters: store.filters,
            changed: { key, oldValue, newValue: value }
        });
        if (store.config.endpoint) {
            this.fetch(name);
        }
    }
    async setFilters(name, filters) {
        const store = this.stores.get(name);
        const hasChanges = Object.keys(filters).some(
            key => store.filters[key] !== filters[key]
        );
        if (!hasChanges) return;
        store.filters = { ...store.filters, ...filters };
        this.notify(name, 'filters-changed', {
            filters: store.filters,
            changed: filters
        });
        if (store.config.endpoint) {
            await this.fetch(name);
        }
    }
    removeFilter(name, key) {
        const store = this.stores.get(name);
        const oldValue = store.filters[key];
        if (oldValue !== undefined) {
            delete store.filters[key];
            this.notify(name, 'filters-changed', {
                filters: store.filters,
                removed: { key, oldValue }
            });
            if (store.config.endpoint) {
                this.fetch(name);
            }
        }
    }
    clearFilters(name) {
        const store = this.stores.get(name);
        const oldFilters = { ...store.filters };
        store.filters = { ...store.config.filters };
        this.notify(name, 'filters-cleared', {
            oldFilters,
            filters: store.filters
        });
        if (store.config.endpoint) {
            this.fetch(name);
        }
    }
    clearCache(name) {
        const store = this.stores.get(name);
        store.cache.clear();
        if (store.db && store.db.objectStoreNames.contains('cache')) {
            const tx = store.db.transaction(['cache'], 'readwrite');
            const objectStore = tx.objectStore('cache');
            objectStore.clear();
        }
        this.notify(name, 'cache-cleared');
    }
    clearHttpHeaders(name, cacheKey = null) {
        const store = this.stores.get(name);
        if (cacheKey) {
            store.httpHeaders.delete(cacheKey);
            if (store.db && store.db.objectStoreNames.contains('headers')) {
                const tx = store.db.transaction(['headers'], 'readwrite');
                const objectStore = tx.objectStore('headers');
                objectStore.delete(cacheKey);
            }
        } else {
            store.httpHeaders.clear();
            if (store.db && store.db.objectStoreNames.contains('headers')) {
                const tx = store.db.transaction(['headers'], 'readwrite');
                const objectStore = tx.objectStore('headers');
                objectStore.clear();
            }
        }
    }
    subscribe(name, callback) {
        if (!this.subscribers.has(name)) {
            this.subscribers.set(name, new Set());
        }
        const subscribers = this.subscribers.get(name);
        subscribers.add(callback);
        return () => subscribers.delete(callback);
    }
    notify(name, event, data = {}) {
        const subscribers = this.subscribers.get(name);
        if (!subscribers) return;
        subscribers.forEach(callback => {
            try {
                callback(event, data);
            } catch (error) {
                console.error(`Subscriber error for store "${name}":`, error);
            }
        });
    }
    storeResponseHeaders(name, key, response) {
        const store = this.stores.get(name);
        const headers = {
            key,
            etag: response.headers.get('ETag'),
            lastModified: response.headers.get('Last-Modified'),
            timestamp: Date.now()
        };
        store.httpHeaders.set(key, headers);
        if (store.db && store.db.objectStoreNames.contains('headers')) {
            const tx = store.db.transaction(['headers'], 'readwrite');
            const objectStore = tx.objectStore('headers');
            objectStore.put(headers);
        }
    }
    async saveToCache(name, key, data) {
        const store = this.stores.get(name);
        if (!store.db || !store.db.objectStoreNames.contains('cache')) return;
        const tx = store.db.transaction(['cache'], 'readwrite');
        const objectStore = tx.objectStore('cache');
        await objectStore.put(data);
    }
    generateCacheKey(filters) {
        const normalized = Object.keys(filters)
            .sort()
            .reduce((acc, key) => {
                acc[key] = filters[key];
                return acc;
            }, {});
        return JSON.stringify(normalized);
    }
    isCacheValid(entry, ttl) {
        if (!entry || !entry.timestamp) return false;
        const age = Date.now() - entry.timestamp;
        return age < ttl;
    }
    getItemKey(item, keyPath) {
        if (typeof keyPath === 'function') {
            return keyPath(item);
        }
        const keys = keyPath.split('.');
        let value = item;
        for (const key of keys) {
            value = value?.[key];
        }
        return value;
    }
    setLoading(on) {
        this.body.classList.toggle('loading', on);
        if (on) {
            this.loading?.showModal();
        } else {
            this.loading?.close();
        }
    }
    destroy() {
        this.stores.forEach(store => {
            if (store.currentRequest) {
                store.currentRequest.abort();
            }
        });
        this.databases.forEach(db => db.close());
        this.stores.clear();
        this.subscribers.clear();
        this.databases.clear();
        this.pendingInits.clear();
    }
}
// Initialize singleton on DOMContentLoaded
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe((event) => {
        if (event === 'auth-loaded') {
            window.jvbStore = new DataStore();
        }
    });
});
assets/js/concise/ErrorHandler.js
@@ -272,11 +272,6 @@
    async retryWithBackoff(callback) {
        const backoffTime = Math.min(1000 * Math.pow(2, this.retryCount), 10000);
        // Display retry notification
        if (this.options.displayNotifications) {
            this.displayRetryNotification(backoffTime);
        }
        // Wait before retrying
        await new Promise(resolve => setTimeout(resolve, backoffTime));
@@ -284,20 +279,6 @@
        return callback();
    }
    /**
     * Display retry notification
     */
    displayRetryNotification(backoffTime) {
        if (window.jvbNotifications) {
            window.jvbNotifications.queuePopupNotification({
                type: 'info',
                message: `Retrying in ${backoffTime/1000} seconds...`,
                icon: 'refresh',
                priority: 'medium',
                displayDuration: backoffTime
            });
        }
    }
    /**
     * Reset retry counter
@@ -305,74 +286,6 @@
    resetRetryCount() {
        this.retryCount = 0;
    }
    /**
     * Handle user feedback for errors
     */
    collectUserFeedback(errorInfo) {
        // Create a modal for collecting feedback
        const modal = document.createElement('dialog');
        modal.className = 'error-feedback-modal';
        modal.innerHTML = `
            <h2>Help Us Improve</h2>
            <p>We encountered an error. Would you like to tell us what happened?</p>
            <form method="dialog" data-save="error">
                <textarea placeholder="What were you trying to do when this error occurred?"></textarea>
                <div class="actions">
                    <button value="cancel">Skip</button>
                    <button value="submit" class="primary">Send Feedback</button>
                </div>
            </form>
        `;
        document.body.appendChild(modal);
        return new Promise((resolve) => {
            modal.addEventListener('close', () => {
                const feedback = modal.returnValue === 'submit'
                    ? modal.querySelector('textarea').value
                    : null;
                document.body.removeChild(modal);
                resolve(feedback);
            });
            modal.showModal();
        });
    }
    /**
     * Handle global errors
     */
    setupGlobalErrorHandling() {
        // Handle uncaught errors
        window.addEventListener('error', event => {
            this.log(
                event.error || new Error(event.message),
                {
                    message: event.message,
                    filename: event.filename,
                    lineno: event.lineno,
                    colno: event.colno,
                    type: 'global_error'
                }
            );
            // Don't prevent default - let browser show its own error if needed
        });
        // Handle unhandled promise rejections
        window.addEventListener('unhandledrejection', event => {
            this.log(
                event.reason,
                {
                    type: 'unhandled_promise',
                    message: event.reason?.message || 'Unhandled promise rejection'
                }
            );
        });
    }
}
document.addEventListener('DOMContentLoaded', async function () {
    window.auth.subscribe((event) => {
assets/js/concise/FormController.js
@@ -289,6 +289,7 @@
            status: '',
            options: {
                autosave: 'autosave' in formElement.dataset,
                autoUpload: true,
                saveDelay: this.autoSaveDefaults.delay,
                endpoint: formElement.dataset.save ?? '',
                formStatus: true,
@@ -334,7 +335,7 @@
        this.initCharacterLimits(form);
        // Initialize image upload fields
        this.initImageUploadFields(form);
        this.initImageUploadFields(form, formConfig);
        // Initialize tabs if present
        if (window.jvbTabs && form.querySelector('nav.tabs')) {
@@ -958,8 +959,8 @@
    /**
     * Initialize image upload fields
     */
    initImageUploadFields(form) {
        window.jvbUploads.scanFields(form);
    initImageUploadFields(form, config) {
        window.jvbUploads.scanFields(form, config.options.autoUpload);
    }
    /* ========== Event Handlers ========== */
@@ -981,14 +982,7 @@
                fullData: formData,
                config: formConfig
            });
            // Don't delete yet - wait for success/error from subscriber
            return;
        }
        // For forms that submit normally (not prevented)
        // We can clean up the cache on successful submission
        // This would typically be called from handleFormSuccess
    }
    handleFormSuccess(form, data) {
@@ -1320,7 +1314,7 @@
                message: 'Please enter a valid URL starting with https://'
            },
            phone: {
                pattern: /^[\d\s\-\+\(\)\.]+$/,
                pattern: /^[\d\s\-+().]+$/,
                message: 'Please enter a valid phone number'
            },
            number: {
@@ -1543,145 +1537,6 @@
        }
    }
    /**
     * Validate all fields in a container (useful for step validation)
     */
    validateAllFields(container) {
        if (!container) return true;
        const fields = container.querySelectorAll('.field:not([hidden])');
        let allValid = true;
        fields.forEach(fieldWrapper => {
            // Skip complex parent wrappers (repeater, group) - validate their children
            if (this.isComplexFieldWrapper(fieldWrapper)) {
                return;
            }
            const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select');
            if (input && !input.closest('[hidden]')) {
                // Mark as touched so validation will run
                const fieldName = fieldWrapper.dataset.field;
                if (fieldName) {
                    this.touchedFields.add(fieldName);
                }
                const isValid = this.validateField(input, fieldWrapper);
                if (!isValid) {
                    allValid = false;
                    // Scroll to first error
                    if (allValid === false) {
                        input.scrollIntoView({ behavior: 'smooth', block: 'center' });
                        input.focus();
                    }
                }
            }
        });
        return allValid;
    }
    /**
     * Check if field wrapper is a complex type (repeater, group, etc.)
     */
    isComplexFieldWrapper(fieldWrapper) {
        return fieldWrapper.classList.contains('repeater') ||
            fieldWrapper.classList.contains('group') ||
            fieldWrapper.classList.contains('upload');
    }
    /**
     * Special validation for repeater fields
     */
    attachRepeaterValidation(form) {
        // When a repeater row is added, attach validation to its fields
        form.addEventListener('click', (e) => {
            if (e.target.closest('.add-repeater-row')) {
                // Wait for the DOM to update
                setTimeout(() => {
                    const repeaterRows = form.querySelectorAll('.repeater-row');
                    repeaterRows.forEach(row => {
                        const inputs = row.querySelectorAll('input, textarea, select');
                        inputs.forEach(input => {
                            const fieldWrapper = this.findFieldWrapper(input);
                            if (fieldWrapper) {
                                // Validation listeners are already attached via event delegation
                                // Just clear any existing validation state for new rows
                                this.clearValidation(fieldWrapper);
                            }
                        });
                    });
                }, 100);
            }
        });
    }
    /**
     * Special validation for group fields
     */
    attachGroupValidation(form) {
        // Group fields might have conditional fields
        // Validate when conditions change
        form.addEventListener('change', (e) => {
            const changedInput = e.target.closest('input, select');
            if (!changedInput) return;
            // Check if this change affects conditional fields
            const fieldName = changedInput.name;
            if (!fieldName) return;
            // Find any conditional fields that depend on this field
            const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`);
            conditionalFields.forEach(conditionalField => {
                // Clear validation for hidden fields
                if (conditionalField.hidden) {
                    this.clearValidation(conditionalField);
                }
            });
        });
    }
    /**
     * Reset validation state for a form
     */
    resetForm(form) {
        if (!form) return;
        // Clear all touched fields
        this.touchedFields.clear();
        // Clear all validation states
        const fields = form.querySelectorAll('.field');
        fields.forEach(fieldWrapper => {
            this.clearValidation(fieldWrapper);
        });
    }
    /**
     * Get validation errors for a form
     */
    getFormErrors(form) {
        const errors = {};
        const fields = form.querySelectorAll('.field.has-error');
        fields.forEach(fieldWrapper => {
            const fieldName = fieldWrapper.dataset.field;
            const message = fieldWrapper.querySelector('.validation-message');
            if (fieldName && message) {
                errors[fieldName] = message.textContent;
            }
        });
        return errors;
    }
    /**
     * Add custom validator
     */
    addValidator(name, validator) {
        this.validators[name] = validator;
    }
    /* ========== Auto-save functionality ========== */
    /**
     * Get appropriate delay based on field type and context
@@ -1875,7 +1730,7 @@
    /* ========== Form Data Methods ========== */
    collectFormData(form, isInit = false) {
    collectFormData(form) {
        if (Object.hasOwn(form.dataset, 'timeline')) {
            return this.collectTimeline(form);
        }
@@ -1912,7 +1767,7 @@
            if (this.ignore.includes(key) || key.endsWith('_temp')) {
                continue;
            }
            const match = key.match(/^\[(\d+)\](.+)$/);
            const match = key.match(/^\[(\d+)](.+)$/);
            if (match) {
                // Timeline-specific field: [postId]fieldName
                const [, postId, fieldName] = match;
@@ -1950,7 +1805,7 @@
    getFieldProcessor(key) {
        if (key.includes('::')) return this.processGroupField;
        if (key.includes(':')) return this.processRepeaterField;
        if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
        if (/\[[^\]]+]/.test(key)) return this.processLocationField;
        return this.processRegularField;
    }
@@ -2122,7 +1977,9 @@
            let p = field.querySelector('p');
            title.textContent = fieldInfo.label;
            let formatted = this.formatFieldValue(value, fieldInfo.type);
            let formatted = this.formatFieldValue(value, fieldInfo.type, form);
            if (this.isHtmlContent(formatted)) {
                p.innerHTML = formatted;
            } else {
@@ -2131,6 +1988,27 @@
            summary.append(field);
        }
        let uploads = form.querySelectorAll('[data-upload-field]');
        if (uploads) {
            uploads.forEach(upload => {
                let label = upload.querySelector('h2').textContent;
                let imgs = upload.querySelectorAll('.item-grid.preview img');
                if (imgs) {
                    let field = wrapper.cloneNode(true);
                    let title = field.querySelector('h3');
                    let p = field.querySelector('p');
                    p.remove();
                    title.textContent = label;
                    imgs.forEach(img => {
                        img = img.cloneNode(true);
                        field.append(img);
                    });
                    summary.append(field);
                }
            });
        }
        // Remove template
        wrapper.remove();
@@ -2152,10 +2030,8 @@
        if (Array.isArray(value) && value.length === 0) {
            return true;
        }
        if (typeof value === 'object' && Object.keys(value).length === 0) {
            return true;
        }
        return false;
        return typeof value === 'object' && Object.keys(value).length === 0;
    }
    /**
@@ -2166,8 +2042,6 @@
        // Try to find label by 'for' attribute (exact match)
        let label = form.querySelector(`label[for="${fieldName}"]`);
        let input = form.querySelector(`[name=${fieldName}]`);
        let fieldWrapper = input?.closest('.field');
        // Try to find the input field - check multiple patterns
        if (!input) {
            // Try exact match first
@@ -2193,12 +2067,12 @@
            // Try closest field wrapper first
            const field = input.closest('.field, fieldset');
            if (field) {
                label = field.querySelector('label, legend');
                label = field.querySelector('label, legend, h2');
            }
        }
        // Get field wrapper - always use base name (no special characters)
        fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
        let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
        // Determine field type
        let fieldType = 'text';
@@ -2258,7 +2132,7 @@
                if (Array.isArray(value)) {
                    return this.formatArrayValue(value);
                }
                return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
                return (value === '1' || value === 1 || value === true) ? 'Yes' : value;
            case 'select':
                // Handle both single and multi-select
@@ -2285,8 +2159,7 @@
            case 'location':
                return this.formatLocationValue(value);
            case 'file':
            case 'image':
            case 'upload':
                return this.formatFileValue(value);
            case 'number':
@@ -2529,13 +2402,6 @@
    }
    /**
     * Convert newlines to <br> tags (kept for backwards compatibility)
     */
    nl2br(text) {
        return this.formatPlainText(text);
    }
    /**
     * Event system
     */
    subscribe(callback) {
@@ -2594,6 +2460,11 @@
    }
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async function () {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
    window.jvbForm = FormController;
        }
    });
});
assets/js/concise/Gallery.js
@@ -56,7 +56,7 @@
                closeMessage: 'Closed Gallery',
            }
        );
        this.modal.subscribe((event, data) => {
        this.modal.subscribe((event) => {
            if (event === 'modal-close') {
                this.toggleGallery(false);
            }
@@ -260,45 +260,6 @@
        this.handleZoom(increment, e.clientX, e.clientY);
    }
    clampPan() {
        const BORDER = 32; // 2rem
        const wrap = this.ui.gallery.wrap;
        if (!wrap) return;
        const wrapRect = wrap.getBoundingClientRect();
        // MUST use natural dimensions
        const naturalW = 1920;
        const naturalH = 1920;
        // But they must be constrained by how the CSS fits them (max-width: 90vw, max-height: 85vh)
        // So compute the base display size:
        const displayRatio = Math.min(
            wrapRect.width / naturalW,
            wrapRect.height / naturalH
        );
        const baseWidth  = naturalW * displayRatio;
        const baseHeight = naturalH * displayRatio;
        const scaledWidth  = baseWidth  * this.zoom.scale;
        const scaledHeight = baseHeight * this.zoom.scale;
        // Allowed pan range
        const minX = wrapRect.width - scaledWidth - BORDER;
        const maxX = BORDER;
        const minY = wrapRect.height - scaledHeight - BORDER;
        const maxY = BORDER;
        // clamp
        this.zoom.x = Math.min(maxX, Math.max(minX, this.zoom.x));
        this.zoom.y = Math.min(maxY, Math.max(minY, this.zoom.y));
    }
    handleZoom(increment, clientX=null, clientY=null) {
        const oldScale = this.zoom.scale;
        let newScale = oldScale + increment;
@@ -357,9 +318,8 @@
    /**
     *
     * @param {boolean} open
     * @param {null|number} index
     */
    toggleGallery(open, index= null) {
    toggleGallery(open) {
        if (open) {
            // Disable native image dragging
            this.ui.gallery.image.draggable = false;
assets/js/concise/HandleSelection.js
@@ -294,13 +294,6 @@
    }
    /**
     * Get all selected item IDs as array
     */
    getSelected() {
        return Array.from(this.selectedItems);
    }
    /**
     * Check if an item is selected
     */
    isSelected(id) {
assets/js/concise/Integrations.js
@@ -196,90 +196,6 @@
        return this.forms.get(service)??false;
    }
    /**
     * Handle OAuth authorization link clicks
     * Opens OAuth in a popup window with proper handling
     */
    handleOAuthClick(link) {
        const service = link.dataset.service;
        const href = link.href;
        // Calculate center position for popup
        const width = 600;
        const height = 700;
        const left = (screen.width - width) / 2;
        const top = (screen.height - height) / 2;
        // Show loading notification
        this.showNotification('Opening authorization window...', 'info');
        // Add loading state to the link
        link.classList.add('loading');
        link.setAttribute('aria-busy', 'true');
        // Open OAuth in popup
        const popup = window.open(
            href,
            'oauth_' + service,
            `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=yes,status=yes,resizable=yes`
        );
        if (!popup) {
            // Popup was blocked
            this.showNotification('Popup was blocked. Please allow popups and try again.', 'error');
            link.classList.remove('loading');
            link.removeAttribute('aria-busy');
            return true; // Allow default behavior as fallback
        }
        // Focus the popup
        popup.focus();
        // Update notification
        this.showNotification('Waiting for authorization...', 'info');
        // Poll for popup close
        const pollTimer = setInterval(() => {
            try {
                if (popup.closed) {
                    clearInterval(pollTimer);
                    // Remove loading state
                    link.classList.remove('loading');
                    link.removeAttribute('aria-busy');
                    // Show checking notification
                    this.showNotification('Checking authorization status...', 'info');
                    // Wait a moment for redirect to complete, then check for messages
                    setTimeout(() => {
                        this.checkForOAuthMessages();
                        // If no messages found, reload to get updated connection status
                        setTimeout(() => {
                            const urlParams = new URLSearchParams(window.location.search);
                            if (!urlParams.has('success') && !urlParams.has('error')) {
                                // No messages in URL, reload to check server-side status
                                window.location.reload();
                            }
                        }, 500);
                    }, 500);
                }
            } catch (error) {
                // Ignore cross-origin errors during polling
            }
        }, 500);
        // Safety timeout - stop polling after 5 minutes
        setTimeout(() => {
            clearInterval(pollTimer);
            link.classList.remove('loading');
            link.removeAttribute('aria-busy');
        }, 300000);
        return false; // Prevent default link behavior
    }
    async handleAction(input) {
        const form = input.closest('form');
        const service = form.dataset.service;
assets/js/concise/PopulateForm.js
@@ -166,7 +166,6 @@
            fieldWrapper.querySelector('textarea:not([data-editor="true"])');
        if (textarea) {
            const oldValue = textarea.value;
            textarea.value = String(fieldValue || '');
            // Trigger change event to update any dependencies
@@ -511,6 +510,7 @@
    }
    populateTimelineGallery(fieldWrapper, fieldName, fieldValue, imagesData) {
        console.log('populating Timeline Gallery');
        if (!fieldValue || typeof fieldValue !== 'object') {
            return;
        }
@@ -528,7 +528,7 @@
        if (grid) {
            window.removeChildren(grid);
            console.log(imageIds);
            for (let [index, data] of Object.entries(fieldValue)) {
            for (let data of Object.entries(fieldValue)) {
                let imageId = data['post_thumbnail'];
                const template = window.getTemplate('timelineItem');
                if (!template) return;
@@ -596,7 +596,7 @@
    /**
     * Populate repeater fields
     */
    populateRepeaterField(fieldWrapper, fieldName, fieldValue, options = {}) {
    populateRepeaterField(fieldWrapper, fieldName, fieldValue) {
        if (!fieldValue || !Array.isArray(fieldValue)) {
            return;
        }
assets/js/concise/Queue.js
@@ -87,36 +87,25 @@
    }
    async initQueue() {
        const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false)
        if (incomplete.length > 0) {
            this.startPolling();
        } else {
        let polling = this.maybeStartPolling();
        if (!polling) {
            this.updateStatusPanel('synced');
        }
        this.store.subscribe((event, data) => {
            switch (event) {
                case 'data-loaded':
                case 'items-saved':
                    // Initial load from IndexedDB
                    const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
                    if (incomplete.length > 0) {
                        this.startPolling();
                    }
                    this.maybeStartPolling();
                    this.updateUI();
                    break;
                case 'item-saved':
                    // Check for status changes
                    if (data.item) {
                        const oldItem = this.store.data.get(data.item.id);
                        if (oldItem && oldItem.status !== data.item.status) {
                            this.handleOperationStatusChange(data.item, oldItem.status);
                    console.log(data,'Item saved data');
                    if (data.previousItem && data.previousItem.status !== data.item.status) {
                        this.handleOperationStatusChange(data.item, data.previousItem.status);
                        }
                    }
                    if (this.hasQueuedOperations()) {
                        this.startPolling();
                    }
                    this.maybeStartPolling();
                    break;
                default:
                    this.updateUI();
@@ -124,18 +113,27 @@
            }
        });
        this.notify('queue-initialized', {operations: incomplete});
    }
    maybeStartPolling()
    {
        const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
        if (incomplete.length > 0) {
            this.startPolling();
            return true;
        }
        return false;
    }
    /**
     * Handle operation status changes and notify subscribers
     */
    handleOperationStatusChange(operation, oldStatus) {
        if (!operation || oldStatus === operation.status) return;
    handleOperationStatusChange(operation) {
        // Notify based on new status
        switch(operation.status) {
            case 'completed':
                console.log(operation);
                this.notify('operation-completed', operation);
                break;
            case 'failed':
@@ -239,7 +237,6 @@
    }
    clearQueue(itemID) {
        const item = this.store.get(itemID);
        this.store.delete(itemID);
    }
@@ -256,8 +253,6 @@
    }
    resetActivityTimer() {
        this.lastActivity = Date.now();
        if (this.activityTimer) {
            clearTimeout(this.activityTimer);
        }
@@ -315,13 +310,7 @@
        this.setProcessing(false);
        this.stopActivityTracking();
        const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false);
        if (pending.length > 0) {
            this.startPolling();
            this.showQueue();
        } else {
            this.hideQueue();
        }
        this.maybeStartPolling() ? this.showQueue() : this.hideQueue();
    }
    async processOperation(operation) {
@@ -359,39 +348,11 @@
            if (response.ok && result.success !== false) {
                // Handle server-side merge
                if (result.id && operation.id !== result.id) {
                    // Check if the returned ID exists locally
                    const existingOp = this.getQueue(result.id);
                    if (existingOp) {
                        // Merge data from both operations
                        existingOp.data = window.deepMerge(existingOp.data, operation.data);
                        existingOp.status = result.status || 'pending';
                        existingOp.serverData = result;
                        this.updateOperationStatus(existingOp.id, existingOp.status);
                        // Update the existing operation
                        this.setQueue(existingOp);
                        this.removeOperationFromUI(operation.id);
                        // Switch reference to the merged operation
                        operation = existingOp;
                    operation = await this.handleServerMerge(operation, result);
                    } else {
                        // Server merged with an operation we don't have locally
                        // Update the ID and continue
                        this.clearQueue(operation.id);
                        operation.id = result.id;
                        operation.status = result.status || 'pending';
                        operation.serverData = result;
                        this.updateOperationStatus(operation.id, operation.status);
                        this.setQueue(operation);
                    }
                } else {
                    // Normal processing - no merge
                    operation.status = result.status || 'pending';
                    operation.serverData = result;
                    this.updateOperationStatus(operation.id, operation.status);
                    this.setQueue(operation);
                }
                this.a11y.announce(`${operation.title} sent to server for processing.`);
@@ -417,6 +378,29 @@
        }
    }
    async handleServerMerge(operation, result) {
        const existingOp = this.getQueue(result.id);
        if (existingOp) {
            // Merge with existing local operation
            existingOp.data = window.deepMerge(existingOp.data, operation.data);
            existingOp.status = result.status || 'pending';
            existingOp.serverData = result;
            this.updateOperationStatus(existingOp.id, existingOp.status);
            this.removeOperationFromUI(operation.id);
            this.clearQueue(operation.id);
            return existingOp;
        } else {
            // Server merged with unknown operation
            this.clearQueue(operation.id);
            operation.id = result.id;
            operation.status = result.status || 'pending';
            operation.serverData = result;
            this.updateOperationStatus(operation.id, operation.status);
            return operation;
        }
    }
    startPolling() {
        if (this.isPolling) return;
@@ -428,8 +412,7 @@
                this.store.clearCache();
                await this.store.fetch(); // Fetches from server, updates store.data
                const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
                if (incomplete.length === 0) {
                if (!this.maybeStartPolling()) {
                    this.stopPolling();
                    this.updateStatusPanel('synced');
                }
@@ -451,7 +434,9 @@
            this.countdownTimer = null;
        }
    }
    getOperationIds(operations) {
        return operations.map(op => op.id);
    }
    /***********************************************************
    USER ACTIONS
     ***********************************************************/
@@ -560,10 +545,9 @@
        };
        this.handleOffline = () => this.updateStatusPanel('offline');
        this.handleBeforeUnload = (e) => {
            const hasPending = this.getOperationsByStatus(['queued', 'uploading']);
            if (hasPending.length > 0) {
            if (this.isPolling || this.isProcessing) {
                e.preventDefault();
                return 'You have unsaved changes in the queue.';
                return 'You have unsaved changes in the queue. Proceed?';
            }
        };
@@ -580,16 +564,14 @@
            this.store.clearHttpHeaders(); // Clear cached headers first
            this.store.fetch();
        } else if (e.target.closest(this.selectors.clearButton)) {
            const completedOps = this.getOperationsByStatus('completed');
            const completedOps = this.getOperationIds(this.getOperationsByStatus('completed'));
            if (completedOps.length > 0) {
                const ids = completedOps.map(op => op.id);
                this.updateServerOperations(ids, 'dismiss');
                this.updateServerOperations(completedOps, 'dismiss');
            }
        } else if (e.target.closest(this.selectors.retryButton)) {
            const failedOps = this.getOperationsByStatus('failed');
            const failedOps = this.getOperationIds(this.getOperationsByStatus('failed'));
            if (failedOps.length > 0) {
                const ids = failedOps.map(op => op.id);
                this.updateServerOperations(ids, 'retry');
                this.updateServerOperations(failedOps, 'retry');
            }
        } else if (e.target.closest('[data-action]')) {
            const button = e.target.closest('[data-action]');
assets/js/concise/Referral.js
@@ -15,7 +15,6 @@
        this.hasCopy = navigator.clipboard && navigator.clipboard.writeText;
        this.initElements();
        this.storesInited = false;
        this.initStore();
        this.initListeners();
        this.checkForReferral();
@@ -27,16 +26,12 @@
            checkCode: '.check-code-btn',
            submit: '[type=submit]',
            recentList: '.recent-referrals-list',
            invite: 'form.invite',
            adminList: '.items-list.referral',
            dash: '.replace .referral-dashboard',
            stats: {
                codeUsed: '[data-stat="code_used"]',
                consultations: '[data-stat="consultations"]',
                treatments: '[data-stat="treatments"]',
                rewards: '[data-stat="total_rewards"]'
            },
            list: '.referrals-list'
        };
        this.forms = this.container.querySelectorAll('form');
@@ -61,10 +56,7 @@
        this.ui = window.uiFromSelectors(this.selectors);
        this.dashTabs = null;
        if (this.ui.dash) {
            this.dashTabs = new window.jvbTabs(this.ui.dash);
        }
        if (!this.hasCopy) {
            document.querySelectorAll(this.selectors.copyBtn).forEach(btn => {
@@ -72,32 +64,6 @@
            });
        }
        this.formController = null;
        if (this.ui.invite) {
            this.formController = new window.jvbForm();
            this.formController.registerForm(
                this.ui.invite,
                {
                    autosave: true,
                    endpoint: 'referrals',
                    formStatus: false,
                }
            );
            this.formController.subscribe((event, data) => {
                if (event === 'form-submit') {
                    data = data.fullData;
                    data.action = 'invite';
                    window.jvbQueue.addToQueue(
                        {
                            endpoint: 'referrals',
                            data: data,
                            title: 'Submitting invitations',
                        }
                    );
                }
            });
        }
    }
    initStore() {
@@ -147,27 +113,9 @@
        if (this.listStore) {
            this.listStore.subscribe(this.handleListEvent.bind(this));
        }
        if (this.ui.dash) {
            this.initViewController();
        }
    }
    initViewController() {
        if (!this.listStore || !this.ui.adminList) return;
        this.view = new window.jvbViews(this.ui.adminList, this.listStore);
        this.view.subscribe((event, data) => {
            switch(event) {
                case 'item-action':
                    this.handleItemAction(data);
                    break;
                case 'bulk-action':
                    this.handleBulkAction(data);
                    break;
            }
        });
    }
    initListeners() {
        this.clickHandler = this.handleClick.bind(this);
@@ -254,82 +202,7 @@
        }
    }
    /**
     * Handle item actions (remove, resend)
     */
    handleItemAction(data) {
        const { action, itemId } = data;
        switch(action) {
            case 'remove':
                this.removeReferral(itemId);
                break;
            case 'resend':
                this.resendInvite(itemId);
                break;
        }
    }
    /**
     * Remove referral from list
     */
    async removeReferral(id) {
        if (!confirm('Remove this referral from your list?')) return;
        try {
            const response = await fetch(`${jvbSettings.api}referrals`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce': window.auth.getNonce()
                },
                body: JSON.stringify({
                    action: 'remove',
                    referral_id: id
                })
            });
            const result = await response.json();
            if (result.success) {
                // Refresh DataStore
                if (this.listStore) this.listStore.fetch();
                if (this.statsStore) this.statsStore.fetch();
                this.a11y?.announce('Referral removed');
            }
        } catch (error) {
            console.error('Error removing referral:', error);
        }
    }
    /**
     * Resend invite email
     */
    async resendInvite(id) {
        try {
            const response = await fetch(`${jvbSettings.api}referrals`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce': window.auth.getNonce()
                },
                body: JSON.stringify({
                    action: 'resend',
                    referral_id: id
                })
            });
            const result = await response.json();
            if (result.success) {
                this.a11y?.announce('Invitation resent');
            } else {
                alert(result.message || 'Cannot resend yet. Wait 7 days between invites.');
            }
        } catch (error) {
            console.error('Error resending invite:', error);
        }
    }
    handleClick(e) {
@@ -359,65 +232,203 @@
        // Try clipboard API first
        if (this.hasCopy) {
            navigator.clipboard.writeText(text).then(() => {
                this.showCopySuccess(button);
            }).catch(() => {
                // Fallback to selection
                this.selectText(codeElement);
                this.showCopyFallback(button);
                button.classList.toggle('success');
                setTimeout(() => {
                    button.classList.remove('success');
                }, 1500);
            });
        }
    }
    /**
     * Select text in element
     * Handle error response with field-specific feedback
     */
    selectText(element) {
        if (window.getSelection && document.createRange) {
            const selection = window.getSelection();
            const range = document.createRange();
            range.selectNodeContents(element);
            selection.removeAllRanges();
            selection.addRange(range);
        } else if (document.body.createTextRange) {
            // IE fallback
            const range = document.body.createTextRange();
            range.moveToElementText(element);
            range.select();
    handleError(form, result) {
        const { message, code, field } = result;
        // If there's a specific field, highlight it
        if (field) {
            this.showFieldError(form, field, message);
        } else {
            // Show general form error using FormController pattern
            this.showFormStatus(form, 'error', message || 'Something went wrong. Please try again.');
        }
        // Handle specific error codes
        switch(code) {
            case 'duplicate_email':
                // Could add additional UI feedback
                break;
            case 'invalid_code':
                // Unlock the referral code field so user can correct it
                const codeInput = form.querySelector('[name="referral_code"]');
                if (codeInput) {
                    codeInput.readOnly = false;
                    codeInput.focus();
                }
                break;
            case 'turnstile_failed':
                // Refresh Turnstile widget if available
                if (window.turnstile && form.querySelector('.cf-turnstile')) {
                    window.turnstile.reset();
                }
                break;
        }
    }
    /**
     * Show copy success feedback
     * Show error for specific field
     */
    showCopySuccess(button) {
        const originalHTML = button.innerHTML;
        button.innerHTML = window.jvbIcon('check', {size: 16}) + ' Copied!';
        button.classList.add('success');
    showFieldError(form, fieldName, message) {
        // Find the field wrapper (handles both direct names and referral_ prefixed names)
        let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"]`);
        if (!fieldWrapper) {
            fieldWrapper = form.querySelector(`.field[data-field="referral_${fieldName}"]`);
        }
        setTimeout(() => {
            button.innerHTML = originalHTML;
            button.classList.remove('success');
        }, 2000);
        if (!fieldWrapper) {
            // If no field wrapper found, show as general form error
            this.showFormStatus(form, 'error', message);
            return;
        }
        const input = fieldWrapper.querySelector('input, textarea, select');
        const validationMessage = fieldWrapper.querySelector('.validation-message');
        const errorIcon = fieldWrapper.querySelector('.validation-icon.error');
        const successIcon = fieldWrapper.querySelector('.validation-icon.success');
        if (!input) {
            this.showFormStatus(form, 'error', message);
            return;
        }
        // Apply error state (following FormController pattern)
        fieldWrapper.classList.remove('has-success');
        fieldWrapper.classList.add('has-error');
        input.classList.add('error');
        input.setAttribute('aria-invalid', 'true');
        // Show error icon, hide success icon
        if (errorIcon) errorIcon.hidden = false;
        if (successIcon) successIcon.hidden = true;
        // Show error message
        if (validationMessage) {
            validationMessage.textContent = message;
            validationMessage.hidden = false;
        }
        // Focus the problematic field
        input.focus();
        // Announce to screen readers
        this.a11y?.announce(`Error in ${fieldName}: ${message}`);
    }
    showFormStatus(form, status, message = '') {
        const statusWrap = form.querySelector('.fstatus');
        if (!statusWrap) {
            console.warn('No .fstatus element found in form');
            return;
        }
        statusWrap.hidden = false;
        const statusElement = statusWrap.querySelector('.message');
        // Clear previous state
        statusWrap.querySelector('.icon')?.remove();
        statusWrap.querySelector('.actions')?.remove();
        // Status messages
        const messages = {
            'saving': 'Sending...',
            'submitted': 'Sent successfully!',
            'error': 'Something went wrong',
            'checking': 'Checking code...'
        };
        // Status icons (using window.getIcon like FormController)
        const icons = {
            'submitted': 'check-circle',
            'error': 'close-circle',
            'checking': 'loading'
        };
        // Add icon if available
        if (icons[status] && window.getIcon) {
            const icon = window.getIcon(icons[status]);
            if (icon) {
                statusWrap.prepend(icon);
            }
        }
        // Set message
        if (statusElement) {
            statusElement.textContent = message || messages[status] || status;
        }
        // Add loading class for pending states
        statusWrap.classList.toggle('loading', ['saving', 'checking'].includes(status));
        // Auto-hide success messages
        if (status === 'submitted') {
            setTimeout(() => statusWrap.hidden = true, 3000);
        }
        // Announce to screen readers
        if (this.a11y) {
            this.a11y.announce(message || messages[status] || status);
        }
    }
    /**
     * Show fallback message
     * Clear all form errors
     */
    showCopyFallback(button) {
        const originalHTML = button.innerHTML;
        button.innerHTML = '✓ Selected - Press Ctrl+C';
        button.classList.add('selected');
    clearFormErrors(form) {
        // Clear field-level errors
        form.querySelectorAll('.field.has-error, .field.has-success').forEach(fieldWrapper => {
            this.clearFieldValidation(fieldWrapper);
        });
        setTimeout(() => {
            button.innerHTML = originalHTML;
            button.classList.remove('selected');
        }, 3000);
        // Hide form status
        const statusWrap = form.querySelector('.fstatus');
        if (statusWrap) {
            statusWrap.hidden = true;
        }
    }
    clearFieldValidation(fieldWrapper) {
        if (!fieldWrapper) return;
        const input = fieldWrapper.querySelector('input, textarea, select');
        const validationMessage = fieldWrapper.querySelector('.validation-message');
        const validationIcons = fieldWrapper.querySelectorAll('.validation-icon');
        // Remove classes
        fieldWrapper.classList.remove('has-error', 'has-success');
        if (input) {
            input.classList.remove('error');
            input.removeAttribute('aria-invalid');
        }
        // Hide icons and messages
        validationIcons.forEach(icon => icon.hidden = true);
        if (validationMessage) {
            validationMessage.hidden = true;
            validationMessage.textContent = '';
        }
    }
    handleInput(e) {
        if (e.target.id === 'referral_code' || e.target.name === 'referral_code') {
            e.target.value = e.target.value.toUpperCase();
        }
        // Clear field error when user types
        const fieldWrapper = e.target.closest('.field');
        if (fieldWrapper && fieldWrapper.classList.contains('has-error')) {
            this.clearFieldValidation(fieldWrapper);
        }
    }
    /**
@@ -585,63 +596,6 @@
    }
    /**
     * Load user stats
     */
    async loadStats() {
        const statsContainer = this.container.querySelector('.stats-summary');
        if (!statsContainer) return;
        try {
            const response = await fetch(`${jvbSettings.api}referrals/my-stats?user=${window.auth.getUser()}`, {
                headers: { 'X-WP-Nonce': window.auth.getNonce() }
            });
            const data = await response.json();
            if (data.success && data.stats) {
                this.updateStats(data.stats);
            }
        } catch (error) {
            console.error('Error loading stats:', error);
        }
    }
    async loadSidebarStats() {
        try {
            const response = await fetch(
                `${jvbSettings.api}referrals/stats?user=${window.auth.getUser()}&type=quick`,
                { headers: { 'X-WP-Nonce': window.auth.getNonce() } }
            );
            const data = await response.json();
            if (data.success && data.stats) {
                this.updateSidebarStats(data.stats);
            }
        } catch (error) {
            console.error('Error loading sidebar stats:', error);
        }
    }
    /**
     * Update stats display
     */
    updateStats(stats) {
        const elements = {
            total: this.container.querySelector('[data-stat="total"]'),
            treated: this.container.querySelector('[data-stat="treated"]'),
            pending: this.container.querySelector('[data-stat="pending"]'),
            rewards: this.container.querySelector('[data-stat="rewards"]')
        };
        if (elements.total) elements.total.textContent = stats.total_referrals || 0;
        if (elements.treated) elements.treated.textContent = stats.treated_count || 0;
        if (elements.pending) elements.pending.textContent = stats.pending_count || 0;
        if (elements.rewards) {
            elements.rewards.textContent = '$' + parseFloat(stats.available_rewards || 0).toFixed(2);
        }
    }
    /**
     * Render recent referrals list
     */
    renderRecentReferrals() {
@@ -664,22 +618,6 @@
    }
    /**
     * Format date nicely
     */
    formatDate(dateString) {
        const date = new Date(dateString);
        const now = new Date();
        const diffTime = Math.abs(now - date);
        const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
        if (diffDays === 0) return 'Today';
        if (diffDays === 1) return 'Yesterday';
        if (diffDays < 7) return `${diffDays} days ago`;
        return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
    }
    /**
     * Handle form submission
     */
    async handleFormSubmit(event) {
@@ -688,6 +626,8 @@
        const form = event.target;
        const formData = new FormData(form);
        // Clear any existing errors
        this.clearFormErrors(form);
        this.setFormLoading(true, form);
        try {
@@ -695,32 +635,43 @@
            if (form.id === 'referral-code-form') {
                // Registration with referral code - goes to LoginRoutes
                const data = {
                let data = {
                    name: formData.get('referral_name'),
                    email: formData.get('referral_email'),
                    referral_code: formData.get('referral_code')
                };
                if (formData.get('cf-turnstile-response')) {
                    data['cf-turnstile-response'] = formData.get('cf-turnstile-response');
                }
                if (!data.name || !data.email || !data.referral_code) {
                    result.message = 'Please fill in all fields';
                } else {
                    result = await this.makeRequest('auth/register', data); // UPDATED endpoint
                }
            } else if (form.id === 'login-form') {
                const data = {
                let data = {
                    type: 'login',
                    email: formData.get('login_email'),
                    context: {
                        redirect_to: window.location.href + '?seeReferral=1'
                    }
                };
                if (formData.get('cf-turnstile-response')) {
                    data['cf-turnstile-response'] = formData.get('cf-turnstile-response');
                }
                if (!data.email) {
                    result.message = 'Please fill in your email';
                } else {
                result = await this.makeRequest('magic', data);
            }
            }
            if (result.success) {
                this.handleSuccess(form, result);
            } else {
                this.showFormMessage(form, result.message || 'Something went wrong. Please try again.', 'error');
                this.handleError(form, result);
            }
        } catch (error) {
            console.error('Error submitting form:', error);
@@ -814,20 +765,11 @@
     * Set form loading state
     */
    setFormLoading(loading, form) {
        const inputs = form.querySelectorAll('input, button');
        const inputs = form.querySelectorAll('input, button, textarea, select');
        inputs.forEach(input => input.disabled = loading);
        const status = form.querySelector('.status');
        if (status) {
            status.classList.toggle('loading', loading);
            if (loading) {
                status.hidden = false;
                const message = status.querySelector('.message');
                if (message) {
                    message.textContent = 'Sending...';
                }
            }
            this.showFormStatus(form, 'saving');
        }
    }
@@ -841,6 +783,8 @@
        });
        this.container.dispatchEvent(event);
    }
}
document.addEventListener('DOMContentLoaded', async function () {
assets/js/concise/ReferralAdmin.js
New file
@@ -0,0 +1,335 @@
class ReferralAdmin {
    constructor() {
        this.a11y = window.jvbA11y;
        this.referral = window.jvbReferral;
        this.hasCopy = navigator.clipboard && navigator.clipboard.writeText;
        this.initElements();
        this.initListeners();
    }
    initElements() {
        this.selectors = {
            copyBtn: '.copy-btn',
            invite: 'form.invite',
            adminList: '.items-list.referral',
            dash: '.replace .referral-dashboard',
            list: '.referrals-list'
        }
        this.ui = window.uiFromSelectors(this.selectors);
        this.tabs = null;
        if (this.ui.dash) {
            this.tabs = new window.jvbTabs(this.ui.dash);
            this.initViewController();
        }
        if (this.ui.invite) {
            this.formController = new window.jvbForm();
            this.formController.registerForm(
                this.ui.invite,
                {
                    autosave: true,
                    endpoint: 'referrals',
                    formStatus: false,
                }
            );
            this.formController.subscribe((event, data) => {
                if (event === 'form-submit') {
                    data = data.fullData;
                    data.action = 'invite';
                    window.jvbQueue.addToQueue(
                        {
                            endpoint: 'referrals',
                            data: data,
                            title: 'Submitting invitations',
                        }
                    );
                }
            });
        }
    }
    initListeners() {
        this.clickHandler = this.handleClick.bind(this);
        document.addEventListener('click', this.clickHandler);
        if (window.jvbQueue) {
            window.jvbQueue.subscribe(this.handleQueueEvent.bind(this));
        }
    }
    handleClick(e) {
        const target = e.target.closest('.copy-btn');
        if (target) {
            this.handleCopyClick(target);
        }
    }
    handleCopyClick(button) {
        const targetId = button.dataset.target;
        const codeElement = button.closest('.row').querySelector(`#${targetId}`);
        if (!codeElement) return;
        const text = codeElement.textContent.trim();
        // Try clipboard API first
        if (this.hasCopy) {
            navigator.clipboard.writeText(text).then(() => {
                button.classList.toggle('success');
                setTimeout(() => {
                    button.classList.remove('success');
                }, 1500);
            });
        }
    }
    initViewController() {
        if (!this.referral.listStore || !this.ui.adminList) return;
        this.view = new window.jvbViews(this.ui.adminList, this.referral.listStore);
        this.view.subscribe((event, data) => {
            switch(event) {
                case 'item-action':
                    this.handleItemAction(data);
                    break;
                case 'bulk-action':
                    this.handleBulkAction(data);
                    break;
            }
        });
    }
    /**
     * Handle item actions (remove, resend)
     */
    handleItemAction(data) {
        const { action, itemId } = data;
        switch(action) {
            case 'remove':
                this.removeReferral(itemId);
                break;
            case 'resend':
                this.resendInvite(itemId);
                break;
        }
    }
    /**
     * Remove referral from list
     */
    async removeReferral(id) {
        if (!confirm('Remove this referral from your list?')) return;
        try {
            const response = await fetch(`${jvbSettings.api}referrals`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce': window.auth.getNonce()
                },
                body: JSON.stringify({
                    action: 'remove',
                    referral_id: id
                })
            });
            const result = await response.json();
            if (result.success) {
                // Refresh DataStore
                if (this.referral.listStore) this.referral.listStore.fetch();
                if (this.referral.statsStore) this.referral.statsStore.fetch();
                this.a11y?.announce('Referral removed');
            }
        } catch (error) {
            console.error('Error removing referral:', error);
        }
    }
    /**
     * Resend invite email
     */
    async resendInvite(id) {
        try {
            const response = await fetch(`${jvbSettings.api}referrals`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce': window.auth.getNonce()
                },
                body: JSON.stringify({
                    action: 'resend',
                    referral_id: id
                })
            });
            const result = await response.json();
            if (result.success) {
                this.a11y?.announce('Invitation resent');
            } else {
                alert(result.message || 'Cannot resend yet. Wait 7 days between invites.');
            }
        } catch (error) {
            console.error('Error resending invite:', error);
        }
    }
    /***************************
    BACKEND stuff
     ***************************/
    handleQueueEvent(event, data) {
        if (event !== 'operation-complete') return;
        if (!data.details) return; // Not our operation
        // Check if it's our invite operation
        if (data.details.successful || data.details.failed) {
            this.showInviteResults(data.details);
        }
    }
    // showInviteResults(details) {
    //  const successful = details.successful?.length || 0;
    //  const failed = details.failed?.length || 0;
    //
    //  if (failed === 0) {
    //      this.a11y?.announce(`All ${successful} invitations sent successfully!`);
    //      // Clear the form
    //      this.ui.invite?.reset();
    //  } else {
    //      // Show which ones failed
    //      const failureList = details.failed
    //          .map(f => `• ${f.name} (${f.email}): ${f.reason}`)
    //          .join('\n');
    //
    //      const message = `${successful} sent, ${failed} failed:\n${failureList}`;
    //
    //      // Show in a modal or persistent notification
    //      alert(message); // Or use a nicer notification system
    //  }
    // }
    showInviteResults(details) {
        if (!this.ui.invite) return;
        const tagListField = this.ui.invite.querySelector('[data-field="invite"]');
        if (!tagListField) return;
        const tagList = tagListField.querySelector('.tag-list');
        if (!tagList) return;
        // Map results by email for easy lookup
        const resultMap = new Map();
        details.successful?.forEach(item => {
            resultMap.set(item.email, { status: 'success', name: item.name });
        });
        details.failed?.forEach(item => {
            resultMap.set(item.email, {
                status: 'error',
                name: item.name,
                reason: item.reason
            });
        });
        // Update each tag with status
        const tags = tagList.querySelectorAll('.tag');
        tags.forEach(tag => {
            const tagData = JSON.parse(tag.dataset.value || '{}');
            const result = resultMap.get(tagData.email);
            if (result) {
                this.updateTagStatus(tag, result);
            }
        });
        // Show summary notification
        this.showInviteSummary(details);
    }
    updateTagStatus(tag, result) {
        // Remove existing status
        tag.classList.remove('success', 'error');
        const existingIcon = tag.querySelector('.status-icon');
        if (existingIcon) existingIcon.remove();
        // Add new status
        tag.classList.add(result.status);
        const icon = document.createElement('span');
        icon.className = 'status-icon';
        icon.innerHTML = result.status === 'success'
            ? window.jvbIcon('check-circle', { size: 14 })
            : window.jvbIcon('warning-circle', { size: 14 });
        if (result.reason) {
            icon.title = result.reason;
        }
        // Insert icon before the remove button
        const removeBtn = tag.querySelector('.remove-tag');
        if (removeBtn) {
            tag.insertBefore(icon, removeBtn);
        } else {
            tag.appendChild(icon);
        }
    }
    showInviteSummary(details) {
        const successful = details.successful?.length || 0;
        const failed = details.failed?.length || 0;
        let message = `Invites sent! ${successful} successful`;
        if (failed > 0) {
            message += `, ${failed} failed`;
        }
        // Show in form status or as toast notification
        if (this.formController) {
            this.formController.showStatus(this.ui.invite, 'submitted', message);
        }
        // Optionally show detailed failures
        if (failed > 0) {
            this.showFailureDetails(details.failed);
        }
    }
    showFailureDetails(failed) {
        // Create a details element or modal showing why each failed
        const detailsHTML = `
        <details class="invite-failures" open>
            <summary>${failed.length} invitation(s) failed - click for details</summary>
            <ul>
                ${failed.map(item => `
                    <li>
                        <strong>${window.escapeHtml(item.name)}</strong>
                        (${window.escapeHtml(item.email)}):
                        <em>${window.escapeHtml(item.reason)}</em>
                    </li>
                `).join('')}
            </ul>
        </details>
    `;
        // Insert after the form or in a notification area
        const statusArea = this.ui.invite.querySelector('.fstatus');
        if (statusArea) {
            const existingDetails = statusArea.querySelector('.invite-failures');
            if (existingDetails) existingDetails.remove();
            statusArea.insertAdjacentHTML('beforeend', detailsHTML);
        }
    }
}
document.addEventListener('DOMContentLoaded', async function () {
    window.auth.subscribe((event) => {
        if (event === 'auth-loaded') {
            window.jvbAdminReferral = new ReferralAdmin();
        }
    });
});
assets/js/concise/UploadManager.js
@@ -112,7 +112,7 @@
    }
    async init() {
        this.initializeFields();
        // this.initializeFields();
        this.initListeners();
        // Queue integration - handle completion/failure
@@ -165,21 +165,18 @@
    /*******************************************************************************
     * FIELD MANAGEMENT
     *******************************************************************************/
    initializeFields() {
        const fields = document.querySelectorAll(this.selectors.field.field);
        fields.forEach(uploader => this.registerUploader(uploader));
    }
    scanFields(container) {
    scanFields(container, autoUpload) {
        console.log(autoUpload, 'autoUpload');
        const fields = container.querySelectorAll(this.selectors.field.field);
        fields.forEach(uploader => this.registerUploader(uploader));
        fields.forEach(uploader => this.registerUploader(uploader, autoUpload));
    }
    registerUploader(uploader) {
    registerUploader(uploader, autoUpload) {
        const fieldId = this.determineFieldId(uploader);
        const config = this.extractFieldConfig(uploader);
        const config = this.extractFieldConfig(uploader, autoUpload);
        const ui = this.buildFieldUI(uploader);
        console.log(config, 'registering with config');
        // Store field data with Sets for runtime
        const fieldData = {
            id: fieldId,
@@ -206,8 +203,9 @@
        return fieldId;
    }
    extractFieldConfig(fieldElement) {
    extractFieldConfig(fieldElement, autoUpload) {
        return {
            autoUpload: autoUpload,
            destination: fieldElement.dataset.destination || 'meta',
            content: fieldElement.dataset.content || null,
            mode: fieldElement.dataset.mode || 'direct',
@@ -493,8 +491,6 @@
        const fieldWrapper = grid.closest('.field, .upload');
        if (!fieldWrapper) return;
        const movedItems = evt.items && evt.items.length > 0 ? evt.items : [evt.item];
        // Get current order from DOM
        let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)'))
            .map(upload => upload.dataset.uploadId)
@@ -640,6 +636,9 @@
        // Meta field changes
        if (fieldId) {
            const fieldData = this.getFieldData(fieldId);
            if (!fieldData.config.autoUpload) {
                return;
            }
            if (fieldData?.config.destination === 'post_group') {
                this.handleGroupMetaChange(e.target);
            } else {
@@ -760,7 +759,7 @@
        this.refreshSortable(fieldId);
        // Queue for upload if in direct mode
        if (fieldData.config.destination !== 'post_group') {
        if (fieldData.config.autoUpload && fieldData.config.destination !== 'post_group') {
            await this.queueUpload(fieldId);
            this.maybeLockUploads(fieldId);
        }
@@ -1554,7 +1553,8 @@
        this.refreshSortable(fieldKey);
        // Queue for upload if needed
        if (config.mode === 'direct' && config.destination !== 'post_group') {
        console.log(config);
        if (config.autoUpload && config.mode === 'direct' && config.destination !== 'post_group') {
            await this.queueUpload(fieldKey);
        }
    }
@@ -2488,16 +2488,6 @@
        // Can add custom logic here if needed
    }
    getCurrentSelection(fieldId) {
        let selected = [];
        for (let [key, handler] of this.selectionHandlers) {
            if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) {
                selected = selected.concat([...handler.selectedItems]);
            }
        }
        return selected;
    }
    /*******************************************************************************
     * HELPER METHODS
     *******************************************************************************/
@@ -2632,27 +2622,6 @@
        return progress[status] || 0;
    }
    getModalType(fieldEl) {
        if (!fieldEl?.element) return null;
        if (fieldEl._cachedModalType !== undefined) {
            return fieldEl._cachedModalType;
        }
        const dialog = fieldEl.element.closest('dialog');
        if (!dialog) {
            fieldEl._cachedModalType = null;
            return null;
        }
        let modalType = null;
        if (dialog.classList.contains('edit')) modalType = 'edit';
        else if (dialog.classList.contains('create')) modalType = 'create';
        else if (dialog.classList.contains('bulkEdit')) modalType = 'bulkEdit';
        else modalType = dialog.className;
        fieldEl._cachedModalType = modalType;
        return modalType;
    }
    createUploadElement(upload, draggable = false) {
        let image = window.getTemplate('uploadItem');
@@ -2728,34 +2697,6 @@
     * PERSISTENCE
     *******************************************************************************/
    /**
     * Normalize field data loaded from IndexedDB
     * Converts Arrays back to Sets, handles missing properties
     */
    normalizeFieldData(fieldData) {
        if (!fieldData) return null;
        // Convert uploads array back to Set
        if (Array.isArray(fieldData.uploads)) {
            fieldData.uploads = new Set(fieldData.uploads);
        } else if (!fieldData.uploads) {
            fieldData.uploads = new Set();
        }
        // Convert groups array, ensure proper structure
        if (!Array.isArray(fieldData.groups)) {
            fieldData.groups = [];
        }
        // Ensure each group has uploads array
        fieldData.groups = fieldData.groups.map(group => ({
            ...group,
            uploads: Array.isArray(group.uploads) ? group.uploads : []
        }));
        return fieldData;
    }
    schedulePersistance(fieldId) {
        const key = `persist_${fieldId}`;
        window.debouncer.schedule(
@@ -2804,6 +2745,56 @@
    }
    /*******************************************************************************
     HELPER to GET UPLOADED FILES
    *******************************************************************************/
    /**
     * Get all files for a form (searches all upload fields within form)
     */
    async getFilesForForm(formElement) {
        const uploadFields = formElement.querySelectorAll('[data-upload-field]');
        const allFiles = [];
        for (const field of uploadFields) {
            const fieldId = this.determineFieldId(field);
            const files = await this.getFilesForField(fieldId);
            allFiles.push(...files);
        }
        return allFiles;
    }
    /**
     * Get all files for a specific field
     */
    async getFilesForField(fieldId) {
        const fieldData = this.getFieldData(fieldId);
        if (!fieldData?.uploads) return [];
        const files = [];
        const uploadsArray = fieldData.uploads instanceof Set
            ? Array.from(fieldData.uploads)
            : fieldData.uploads;
        for (const uploadId of uploadsArray) {
            const upload = this.uploadStore.get(uploadId);
            if (!upload) continue;
            // Get the actual File object from blob data
            const file = await this.getBlobData(uploadId);
            if (file) {
                files.push({
                    file: file,
                    uploadId: uploadId,
                    fieldName: fieldData.config.name,
                    meta: upload.meta || {}
                });
            }
        }
        return files;
    }
    /*******************************************************************************
     * RECOVERY & RESTORATION
     *******************************************************************************/
@@ -2856,7 +2847,7 @@
        });
        if (pendingFields.length === 0) return;
        this.showRecoveryNotification(pendingFields);
        await this.showRecoveryNotification(pendingFields);
    }
    async showRecoveryNotification(pendingFields) {
assets/js/concise/navigation.js
@@ -44,10 +44,6 @@
        });
    }
    navIDs() {
        return Array.from(this.navs.keys()).map((nav) => `#${nav}`);
    }
    initListeners() {
        this.clickListener = this.handleClick.bind(this);
        this.escapeListener = this.handleEscape.bind(this);
assets/js/concise/quill.js
@@ -12,7 +12,7 @@
            editor.className = 'editor';
            toolbar = document.createElement('div');
            toolbar.className = 'toolbar';
            const image = textarea.dataset.allowimage === true ? `<button type="button" class="ql-jvb_image">\n                    ${dashboardSettings.icons.image}\n                </button>` : '';
            const image = textarea.dataset.allowimage === true ? `<button type="button" class="ql-jvb_image">\n                    ${window.getIcon('image')}\n                </button>` : '';
            toolbar.id = `toolbar-${textarea.id}`;
            toolbar.innerHTML = `
                <span class="ql-formats">
@@ -93,10 +93,10 @@
                        h1: function() { this.quill.format('header', 1); },
                        h2: function() { this.quill.format('header', 2); },
                        h3: function() { this.quill.format('header', 3); },
                        jvb_bold: function() {this.quill.format('bold', true)},
                        jvb_italic: function() {this.quill.format('italic', true)},
                        jvb_strike: function() {this.quill.format('strike', true)},
                        jvb_underline: function() {this.quill.format('underline', true)},
                        'jvb_bold': function() {this.quill.format('bold', true)},
                        'jvb_italic': function() {this.quill.format('italic', true)},
                        'jvb_strike': function() {this.quill.format('strike', true)},
                        'jvb_underline': function() {this.quill.format('underline', true)},
                        'jvb_align': function(value) {
                            this.quill.format('align', value === this.quill.getFormat().list ? false : value);
                        },
@@ -107,8 +107,6 @@
                            if (value) {
                                const range = this.quill.getSelection();
                                if (range == null || range.length === 0) return;
                                // Get the existing link if any
                                const preview = this.quill.getText(range.index, range.length);
                                const existingLink = this.quill.getFormat(range).link;
                                // Create modal for link input
@@ -196,11 +194,6 @@
                                    formData.append('post_id', objectID);
                                }
                                // Show loading state
                                if (window.jvbLoading) {
                                    window.jvbLoading.showLoading('Uploading image...', 'Processing Upload');
                                }
                                try {
                                    const response = await fetch(
                                        `${jvbSettings.api}uploads/`,
@@ -229,9 +222,6 @@
                                        'italic': true
                                    }, true);
                                } finally {
                                    if (window.jvbLoading) {
                                        window.jvbLoading.hide();
                                    }
                                    input.remove();
                                }
                            };
assets/js/dash/CRUDOld.js
File was deleted
assets/js/dash/Cache.js
File was deleted
assets/js/dash/FormHandler.js
File was deleted
assets/js/dash/FormHandlerOld.js
File was deleted
assets/js/dash/InfiniteScroll.js
File was deleted
assets/js/dash/LoadingManager.js
File was deleted
assets/js/dash/QueueManager.js
File was deleted
assets/js/dash/QueueManagerBackup.js
File was deleted
assets/js/dash/TaxonomySelector.js
File was deleted
assets/js/dash/TaxonomySelectorOld.js
File was deleted
assets/js/dash/TaxonomySelectorPrev.js
File was deleted
assets/js/dash/UploadManager.js
File was deleted
assets/js/min/dataStore.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.1){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,httpHeaders:new Map,subscribers:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),delete:t=>this.delete(e,t),get:t=>this.get(e,t),getAll:()=>this.getAll(e),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),clearHttpHeaders:t=>this.clearHttpHeaders(e,t),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}t.useHttpCaching&&!e.objectStoreNames.contains("headers")&&e.createObjectStore("headers",{keyPath:"key"})}loadStoreDataInBackground(e){const t=this.stores.get(e);if(!t?.db)return;const s=[this.loadStoreData(e),this.loadStoreCache(e),this.loadStoreHeaders(e)];Promise.all(s).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async loadStoreData(e){const t=this.stores.get(e);if(t?.db)return new Promise((s=>{const r=t.db.transaction([t.config.storeName],"readonly").objectStore(t.config.storeName).getAll();r.onsuccess=r=>{const i=r.target.result||[];i.forEach((e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.notify(e,"data-loaded",{count:i.length}),s(i)},r.onerror=()=>s([])}))}async loadStoreCache(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("cache"))return new Promise((e=>{const s=t.db.transaction(["cache"],"readonly").objectStore("cache").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)})),e()},s.onerror=()=>e()}))}async loadStoreHeaders(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("headers"))return new Promise((e=>{const s=t.db.transaction(["headers"],"readonly").objectStore("headers").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{t.httpHeaders.set(e.key,e)})),e()},s.onerror=()=>e()}))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:r.items||[]}),r;t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers},o=t.httpHeaders.get(s);t.config.useHttpCaching&&o&&(o.etag&&(a["If-None-Match"]=o.etag),o.lastModified&&(a["If-Modified-Since"]=o.lastModified));const n=new AbortController;t.currentRequest=n;const c=await fetch(i,{method:"GET",headers:a,signal:n.signal});if(304===c.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!c.ok)throw new Error(`HTTP ${c.status}: ${c.statusText}`);const d=await c.json();return t.config.useHttpCaching&&this.storeResponseHeaders(e,s,c),await this.processFetchedData(e,d,s),this.notify(e,"data-loaded",{cached:!1,items:d.items||[]}),d}catch(t){throw"AbortError"!==t.name&&(console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t})),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s){const r=this.stores.get(e),i=t.items||[];if(r.db&&i.length>0){const e=r.db.transaction([r.config.storeName],"readwrite"),t=e.objectStore(r.config.storeName);for(const e of i){const s=this.processForStorage(e,r.config.validateData);if(s.valid){const i=this.getItemKey(s.data,r.config.keyPath);r.data.set(i,e),await t.put(s.data)}}await new Promise(((t,s)=>{e.oncomplete=()=>t(),e.onerror=()=>s(e.error)}))}const a={key:s,items:i.map((e=>this.getItemKey(e,r.config.keyPath))),timestamp:Date.now(),endpoint:r.config.endpoint,filters:{...r.filters}};r.cache.set(s,a),await this.saveToCache(e,s,a),r.lastResponse={...t,has_more:t.has_more||!1,total:t.total||i.length,pages:t.pages||1,queue_stats:t.queue_stats||{}}}async save(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath);if(s.data.set(a,t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.put(i)}return this.notify(e,"item-saved",{item:t,key:a}),a}processForStorage(e,t=!0,s="root"){if(null==e)return{valid:!0,data:e};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:null};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:null};if(e instanceof FormData)return t?{valid:!1,error:`FormData at ${s}`}:{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e))return{valid:!0,data:e};if(e instanceof Set){const r=Array.from(e);return this.processForStorage(r,t,s)}if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;null!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;null!==e.data&&(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:null}}async delete(e,t){const s=this.stores.get(e);if(s.data.delete(t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.delete(t)}this.notify(e,"item-deleted",{id:t})}get(e,t){return this.stores.get(e).data.get(t)}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);return r&&r.items?r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]):this.getAll(e)}async clear(e){const t=this.stores.get(e);if(t.data.clear(),t.cache.clear(),t.db){const e=t.db.transaction([t.config.storeName],"readwrite").objectStore(t.config.storeName);await e.clear()}this.notify(e,"data-cleared")}setFilter(e,t,s){const r=this.stores.get(e),i=r.filters[t];null==s||""===s?delete r.filters[t]:r.filters[t]=s,this.notify(e,"filters-changed",{filters:r.filters,changed:{key:t,oldValue:i,newValue:s}}),r.config.endpoint&&this.fetch(e)}async setFilters(e,t){const s=this.stores.get(e);Object.keys(t).some((e=>s.filters[e]!==t[e]))&&(s.filters={...s.filters,...t},this.notify(e,"filters-changed",{filters:s.filters,changed:t}),s.config.endpoint&&await this.fetch(e))}removeFilter(e,t){const s=this.stores.get(e),r=s.filters[t];void 0!==r&&(delete s.filters[t],this.notify(e,"filters-changed",{filters:s.filters,removed:{key:t,oldValue:r}}),s.config.endpoint&&this.fetch(e))}clearFilters(e){const t=this.stores.get(e),s={...t.filters};t.filters={...t.config.filters},this.notify(e,"filters-cleared",{oldFilters:s,filters:t.filters}),t.config.endpoint&&this.fetch(e)}clearCache(e){const t=this.stores.get(e);if(t.cache.clear(),t.db&&t.db.objectStoreNames.contains("cache")){t.db.transaction(["cache"],"readwrite").objectStore("cache").clear()}this.notify(e,"cache-cleared")}clearHttpHeaders(e,t=null){const s=this.stores.get(e);if(t){if(s.httpHeaders.delete(t),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").delete(t)}}else if(s.httpHeaders.clear(),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").clear()}}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}storeResponseHeaders(e,t,s){const r=this.stores.get(e),i={key:t,etag:s.headers.get("ETag"),lastModified:s.headers.get("Last-Modified"),timestamp:Date.now()};if(r.httpHeaders.set(t,i),r.db&&r.db.objectStoreNames.contains("headers")){r.db.transaction(["headers"],"readwrite").objectStore("headers").put(i)}}async saveToCache(e,t,s){const r=this.stores.get(e);if(!r.db||!r.db.objectStoreNames.contains("cache"))return;const i=r.db.transaction(["cache"],"readwrite").objectStore("cache");await i.put(s)}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.1){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let i=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,r={config:{dbName:i.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};r.config.headers={"X-WP-Nonce":window.auth.getNonce(),...r.config.headers},i.stores[t.storeName]=s,this.stores.set(s,r),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const r={};for(const[e,t]of Object.entries(i.stores))r[e]=this.getStoreAPI(t);return r}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),delete:t=>this.delete(e,t),get:t=>this.get(e,t),getAll:()=>this.getAll(e),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,i]of e.entries())i instanceof File||i instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(i)):t.entries[s]=i);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,i]of Object.entries(e.entries))Array.isArray(i)?i.forEach((e=>t.append(s,e))):t.append(s,i);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:i}=t,r=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,i,(e=>{r.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}r.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((i,r)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>i(e.target.result),a.onerror=e=>r(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const i=this.stores.get(e);return i?.db&&i.db.objectStoreNames.contains(t)?new Promise((e=>{const r=i.db.transaction([t],"readonly").objectStore(t).getAll();r.onsuccess=t=>{const i=t.target.result||[];i.forEach(s),e(i)},r.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,i){const r=this.stores.get(e);return r?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=r.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>a(o.error);try{h=i(c,o)}catch(e){a(e)}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),i=t.cache.get(s);if(i&&this.isCacheValid(i,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:i.items||[]}),i;t.config.showLoading&&this.setLoading(!0);const r=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&i&&(i.etag&&(a["If-None-Match"]=i.etag),i.lastModified&&(a["If-Modified-Since"]=i.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(r,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return i?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:i.items||[]}),i):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){throw"AbortError"!==t.name&&(console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t})),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const i=t.config.apiBase+t.config.endpoint;return s.toString()?`${i}?${s}`:i}async processFetchedData(e,t,s,i){const r=this.stores.get(e),a=t.items||[],o=[];r.db&&a.length>0&&await this.withTransaction(e,r.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const i=this._saveItem(e,s);o.push(i),t.put(i.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,r.config.keyPath))),timestamp:Date.now(),endpoint:r.config.endpoint,filters:{...r.filters},etag:i.headers.get("ETag"),lastModified:i.headers.get("Last-Modified")};r.cache.set(s,n),r.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),r.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}},o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}_saveItem(e,t){const s=this.stores.get(e),i=this.processForStorage(t,s.config.validateData);if(!i.valid)throw new Error(`Non-serializable data: ${i.error}`);const r=i.data,a=this.getItemKey(r,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:r,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),i=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(i.processed)})),this.notify(e,"item-saved",{item:i.item,key:i.key,previousItem:i.previousItem}),i.key}processForStorage(e,t=!0,s="root"){if(null==e)return{valid:!0,data:e};const i=typeof e;if(["string","number","boolean"].includes(i))return{valid:!0,data:e};if("function"===i)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:null};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:null};if(e instanceof FormData)return t?{valid:!1,error:`FormData at ${s}`}:{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e))return{valid:!0,data:e};if(e instanceof Set){const i=Array.from(e);return this.processForStorage(i,t,s)}if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const i=[];for(let r=0;r<e.length;r++){const a=this.processForStorage(e[r],t,`${s}[${r}]`);if(!a.valid)return a;null!==a.data&&i.push(a.data)}return{valid:!0,data:i}}if("object"===i){const i={};for(const[r,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${r}`);if(!e.valid)return e;null!==e.data&&(i[r]=e.data)}return{valid:!0,data:i}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:null}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}get(e,t){return this.stores.get(e).data.get(t)}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),i=t.cache.get(s);return i&&i.items?i.items.reduce(((e,s)=>{const i=t.data.get(s);return i&&e.push(i),e}),[]):this.getAll(e)}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const i=this.stores.get(e),r={...i.filters};s?i.filters={...i.config.filters}:Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete i.filters[e]:i.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:r,filters:i.filters,updates:t}),i.config.endpoint&&await this.fetch(e)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e])))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const i=this.subscribers.get(e);i&&i.forEach((i=>{try{i(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let i=e;for(const e of s)i=i?.[e];return i}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
assets/js/min/error.min.js
@@ -1 +1 @@
(()=>{class e{constructor(e={}){this.options={apiUrl:"",logToServer:!0,displayNotifications:!0,notificationDuration:5e3,retryEnabled:!0,maxRetries:3,...e},this.retryCount=0}async log(e,t={},o=null){console.error("API Error:",e,t);const r=this.getErrorType(e),n=this.getErrorMessage(e,r);switch(this.options.logToServer&&await this.logErrorToServer(r,n,t),r){case"network":case"server":if(this.options.retryEnabled&&this.retryCount<this.options.maxRetries&&o)return this.retryCount++,this.retryWithBackoff(o);break;case"auth":this.handleAuthError();break;case"rate_limit":return this.handleRateLimitError(o)}return this.options.displayNotifications&&this.displayErrorNotification(n,r,o),o&&this.options.retryEnabled||(this.retryCount=0),{success:!1,error:r,message:n,context:t}}getErrorType(e){if("AbortError"===e.name)return"timeout";if(!navigator.onLine)return"offline";if(e.response){const t=e.response.status;if(t>=400&&t<500)return 401===t||403===t?"auth":429===t?"rate_limit":"client";if(t>=500)return"server"}return"network"}getErrorMessage(e,t){const o={network:"We couldn't connect to the server. Please check your connection and try again.",timeout:"The request took too long to complete. Please try again.",offline:"You appear to be offline. Please check your internet connection.",auth:"Your session may have expired. Please log in again.",rate_limit:"You've made too many requests. Please wait a moment and try again.",server:"We're experiencing technical difficulties. Please try again later.",client:"Something went wrong with your request. Please try again.",unknown:"An unexpected error occurred. Please try again."};return e.response&&e.response.data&&e.response.data.message?e.response.data.message:e.message?e.message:o[t]||o.unknown}async logErrorToServer(e,t,o){try{if(!this.options.apiUrl)return;const r={...o,url:window.location.href,pathname:window.location.pathname,userAgent:navigator.userAgent,timestamp:(new Date).toISOString(),viewport:`${window.innerWidth}x${window.innerHeight}`,component:o.component||this.extractComponentFromStack(o.stack),method:o.method||this.extractMethodFromStack(o.stack),stack:o.stack||o.error?.stack,isLoggedIn:window.auth.isAuthenticated(),source:"frontend"},n=new FormData;n.append("error_type",e),n.append("message",t),n.append("context",JSON.stringify(r)),await fetch(`${this.options.apiUrl}errors/log`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:n})}catch(e){console.warn("Failed to log error to server",e)}}extractComponentFromStack(e){if(!e)return"Unknown";const t=e.match(/at\s+(\w+)\./);return t?t[1]:"Unknown"}extractMethodFromStack(e){if(!e)return null;const t=e.match(/at\s+\w+\.(\w+)\s+/);return t?t[1]:null}displayErrorNotification(e,t,o){if(window.jvbNotifications){const t=[];return o&&t.push({label:"Try Again",icon:"refresh",action:o}),void window.jvbNotifications.queuePopupNotification({type:"error",message:e,icon:"alert",priority:"high",displayDuration:this.options.notificationDuration,actions:t})}alert(e)}handleAuthError(){window.jvbSettings&&window.jvbSettings.loginUrl?window.location.href=window.jvbSettings.loginUrl:window.location.reload()}async handleRateLimitError(e){const t=2e3*(this.retryCount+1);if(await new Promise((e=>setTimeout(e,t))),e)return this.retryCount++,e()}async retryWithBackoff(e){const t=Math.min(1e3*Math.pow(2,this.retryCount),1e4);return this.options.displayNotifications&&this.displayRetryNotification(t),await new Promise((e=>setTimeout(e,t))),e()}displayRetryNotification(e){window.jvbNotifications&&window.jvbNotifications.queuePopupNotification({type:"info",message:`Retrying in ${e/1e3} seconds...`,icon:"refresh",priority:"medium",displayDuration:e})}resetRetryCount(){this.retryCount=0}collectUserFeedback(e){const t=document.createElement("dialog");return t.className="error-feedback-modal",t.innerHTML='\n            <h2>Help Us Improve</h2>\n            <p>We encountered an error. Would you like to tell us what happened?</p>\n            <form method="dialog" data-save="error">\n                <textarea placeholder="What were you trying to do when this error occurred?"></textarea>\n                <div class="actions">\n                    <button value="cancel">Skip</button>\n                    <button value="submit" class="primary">Send Feedback</button>\n                </div>\n            </form>\n        ',document.body.appendChild(t),new Promise((e=>{t.addEventListener("close",(()=>{const o="submit"===t.returnValue?t.querySelector("textarea").value:null;document.body.removeChild(t),e(o)})),t.showModal()}))}setupGlobalErrorHandling(){window.addEventListener("error",(e=>{this.log(e.error||new Error(e.message),{message:e.message,filename:e.filename,lineno:e.lineno,colno:e.colno,type:"global_error"})})),window.addEventListener("unhandledrejection",(e=>{this.log(e.reason,{type:"unhandled_promise",message:e.reason?.message||"Unhandled promise rejection"})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbError=new e({api:jvbSettings.api,logToServer:!0,displayNotifications:!0,notificationDuration:5e3,retryEnabled:!0,maxRetries:3}))}))}))})();
(()=>{class t{constructor(t={}){this.options={apiUrl:"",logToServer:!0,displayNotifications:!0,notificationDuration:5e3,retryEnabled:!0,maxRetries:3,...t},this.retryCount=0}async log(t,e={},r=null){console.error("API Error:",t,e);const o=this.getErrorType(t),n=this.getErrorMessage(t,o);switch(this.options.logToServer&&await this.logErrorToServer(o,n,e),o){case"network":case"server":if(this.options.retryEnabled&&this.retryCount<this.options.maxRetries&&r)return this.retryCount++,this.retryWithBackoff(r);break;case"auth":this.handleAuthError();break;case"rate_limit":return this.handleRateLimitError(r)}return this.options.displayNotifications&&this.displayErrorNotification(n,o,r),r&&this.options.retryEnabled||(this.retryCount=0),{success:!1,error:o,message:n,context:e}}getErrorType(t){if("AbortError"===t.name)return"timeout";if(!navigator.onLine)return"offline";if(t.response){const e=t.response.status;if(e>=400&&e<500)return 401===e||403===e?"auth":429===e?"rate_limit":"client";if(e>=500)return"server"}return"network"}getErrorMessage(t,e){const r={network:"We couldn't connect to the server. Please check your connection and try again.",timeout:"The request took too long to complete. Please try again.",offline:"You appear to be offline. Please check your internet connection.",auth:"Your session may have expired. Please log in again.",rate_limit:"You've made too many requests. Please wait a moment and try again.",server:"We're experiencing technical difficulties. Please try again later.",client:"Something went wrong with your request. Please try again.",unknown:"An unexpected error occurred. Please try again."};return t.response&&t.response.data&&t.response.data.message?t.response.data.message:t.message?t.message:r[e]||r.unknown}async logErrorToServer(t,e,r){try{if(!this.options.apiUrl)return;const o={...r,url:window.location.href,pathname:window.location.pathname,userAgent:navigator.userAgent,timestamp:(new Date).toISOString(),viewport:`${window.innerWidth}x${window.innerHeight}`,component:r.component||this.extractComponentFromStack(r.stack),method:r.method||this.extractMethodFromStack(r.stack),stack:r.stack||r.error?.stack,isLoggedIn:window.auth.isAuthenticated(),source:"frontend"},n=new FormData;n.append("error_type",t),n.append("message",e),n.append("context",JSON.stringify(o)),await fetch(`${this.options.apiUrl}errors/log`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:n})}catch(t){console.warn("Failed to log error to server",t)}}extractComponentFromStack(t){if(!t)return"Unknown";const e=t.match(/at\s+(\w+)\./);return e?e[1]:"Unknown"}extractMethodFromStack(t){if(!t)return null;const e=t.match(/at\s+\w+\.(\w+)\s+/);return e?e[1]:null}displayErrorNotification(t,e,r){if(window.jvbNotifications){const e=[];return r&&e.push({label:"Try Again",icon:"refresh",action:r}),void window.jvbNotifications.queuePopupNotification({type:"error",message:t,icon:"alert",priority:"high",displayDuration:this.options.notificationDuration,actions:e})}alert(t)}handleAuthError(){window.jvbSettings&&window.jvbSettings.loginUrl?window.location.href=window.jvbSettings.loginUrl:window.location.reload()}async handleRateLimitError(t){const e=2e3*(this.retryCount+1);if(await new Promise((t=>setTimeout(t,e))),t)return this.retryCount++,t()}async retryWithBackoff(t){const e=Math.min(1e3*Math.pow(2,this.retryCount),1e4);return await new Promise((t=>setTimeout(t,e))),t()}resetRetryCount(){this.retryCount=0}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((e=>{"auth-loaded"===e&&(window.jvbError=new t({api:jvbSettings.api,logToServer:!0,displayNotifications:!0,notificationDuration:5e3,retryEnabled:!0,maxRetries:3}))}))}))})();
assets/js/min/form.min.js
@@ -1 +1 @@
(()=>{class e{constructor(e={}){this.config={collectFormData:!1,...e},this.isRestoring=!1;const t=window.jvbStore.register("forms",{storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4,validateData:!0,delayFetch:!0});this.store=t.forms,this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.isTimeline=window.crudManager&&window.crudManager.isTimeline,this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.blurHandler=this.handleBlur.bind(this),this.processRepeaterField=this.processRepeaterField.bind(this),this.processGroupField=this.processGroupField.bind(this),this.processLocationField=this.processLocationField.bind(this),this.processRegularField=this.processRegularField.bind(this),this.init()}async init(){this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners(),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.type&&this.handleOperationComplete(t)}))}async handleOperationComplete(e){if(e.formId)try{await this.store.delete(e.formId)}catch(e){console.warn("Failed to clear form cache:",e)}const t=this.forms.get(e.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){const e=await this.store.getAll(),t=window.location.pathname;e.filter((e=>{if("draft"!==e.status)return!1;const r=e.data?._wp_http_referer;return r===t})).forEach((e=>{const t=this.findFormElement(e);if(!t)return;let r=this.forms.get(e.formId);t.dataset.formId||(r=this.registerForm(t)),this.isRestoring=!0,new this.populateForm(t,e.data),setTimeout((()=>{this.isRestoring=!1}),0),this.showFormStatus(e.formId,"restored"),window.jvbA11y&&window.jvbA11y.announce("Your previous entry has been restored")}))}findFormElement(e){if(e.data?.form_id){const t=document.querySelector(`[name="form_id"][value="${e.data.form_id}"]`)?.closest("form");if(t)return t}if(e.data?.form_type){const t=document.querySelector(`[name="form_type"][value="${e.data.form_type}"]`)?.closest("form");if(t)return t}return document.querySelector(`[data-form-id="${e.formId}"]`)}showPendingNotification(e,t){const r=document.querySelector(`[data-form-id="${e}"]`);if(!r)return;const s=document.createElement("div");s.className="pending-changes-notification",s.innerHTML=`\n        <p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n        <button class="restore-changes" data-form-id="${e}">Restore</button>\n        <button class="discard-changes" data-form-id="${e}">Discard</button>\n    `,r.insertBefore(s,r.firstChild),s.querySelector(".restore-changes").addEventListener("click",(async()=>{await this.restorePendingForm(e,t),s.remove()})),s.querySelector(".discard-changes").addEventListener("click",(async()=>{await this.discardPendingForm(e),s.remove()}))}async restorePendingForm(e,t){const r=document.querySelector(`[data-form-id="${e}"]`);r&&(new this.populateForm(r,t),await this.store.save({formId:e,data:t,status:"restored",timestamp:Date.now()}),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){try{await this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}catch(e){console.error("Failed to discard pending form:",e)}}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){if(!e)return;const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,status:"",options:{autosave:"autosave"in e.dataset,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save??"",formStatus:!0,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e,!0)};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.data&&this.showPendingNotification(r,e.data)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),this.initTagListFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=s.switchTab.bind(s);s.switchTab=(e,t)=>{c(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initTagListFields(e,t){e.querySelectorAll(".field.tag-list").forEach((e=>{const r=e.querySelector(".tag-input-row"),s=e.querySelector(".add-tag-item"),a=e.querySelector(".tag-items"),i=e.querySelector(".tag-template"),n=e.dataset.field,o=e.dataset.tagFormat||"first_field";if(!(r&&s&&a&&i))return;const l=()=>Array.from(r.querySelectorAll("input, select, textarea")).filter((e=>!e.closest("button"))),c=()=>{const r=l(),s={};let c=!1;if(r.forEach((e=>{const t=e.name.replace("new_",""),r=this.getFieldValue(e);r&&(c=!0),s[t]=r})),!c)return window.jvbA11y&&window.jvbA11y.announce("Please fill in at least one field","error"),void r[0].focus();const d=r.find((e=>{const t="required"in e.dataset&&"1"===e.dataset.required,r=this.getFieldValue(e);return t&&!r}));if(d){const e=d.closest(".field"),t=e?.querySelector("label")?.textContent||"This field";return this.showError(e,`${t} is required.`),void d.focus()}for(let t of r){let r=e.closest(".field");if(!this.validateField(t,r))return void t.focus()}const u=a.children.length,h=i.content.cloneNode(!0).firstElementChild;h.dataset.index=u;const m=h.querySelector(".tag-label");m&&(m.textContent=this.getTagDisplayText(s,o)),h.querySelectorAll('input[type="hidden"]').forEach((e=>{const t=e.dataset.field;e.name=`${n}:${u}:${t}`,e.value=s[t]||""})),a.appendChild(h),r.forEach((e=>{"checkbox"===e.type||"radio"===e.type?e.checked=!1:e.value="";let t=e.closest(".field");this.clearValidation(t)})),r.length>0&&r[0].focus(),t&&this.scheduleSave(t,{type:"tag_list",action:"add",fieldName:n,delay:this.autoSaveDefaults.delay}),window.jvbA11y&&window.jvbA11y.announce("Item added")};s.addEventListener("click",c);const d=l();d.length>0&&(d[d.length-1].addEventListener("keypress",(e=>{"Enter"===e.key&&(e.preventDefault(),c())})),d.slice(0,-1).forEach(((e,t)=>{e.addEventListener("keypress",(e=>{"Enter"===e.key&&(e.preventDefault(),d[t+1].focus())}))}))),a.addEventListener("click",(e=>{if(e.target.closest(".remove-tag")){const r=e.target.closest(".tag-item"),s=r.querySelector(".tag-label")?.textContent||"Item";r.remove(),this.reindexTagList(a,n),t&&this.scheduleSave(t,{type:"tag_list",action:"remove",fieldName:n,delay:this.autoSaveDefaults.delay}),window.jvbA11y&&window.jvbA11y.announce(`${s} removed`)}}))}))}reindexTagList(e,t){Array.from(e.children).forEach(((e,r)=>{e.dataset.index=r,e.querySelectorAll('input[type="hidden"]').forEach((e=>{const s=e.dataset.field;e.name=`${t}:${r}:${s}`}))}))}getTagDisplayText(e,t){const r=Object.values(e).filter((e=>e));if(0===r.length)return"New Item";switch(t){case"first_field":return r[0];case"all_fields":return r.join(", ");default:if(t.includes("{")){let r=t;for(const[t,s]of Object.entries(e))r=r.replace(`{${t}}`,s);return r}return e[t]||r[0]}}escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s===a;case"!=":return s!==a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}async handleSubmit(e){const t=e.target;if(!t.dataset.formId)return;const r=this.forms.get(t.dataset.formId);if(this.subscribers.size>0){e.preventDefault();const s=this.collectFormData(t);this.notify("form-submit",{formId:t.dataset.formId,fullData:s,config:r})}else;}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const r=document.createElement("div");r.className="form-success-message success-message",r.textContent=t.message,e.insertBefore(r,e.firstChild);const s=window.getIcon?.("check-circle");s&&(s.classList.add("success-icon"),r.prepend(s))}if(t.title||t.description){const r=document.createElement("div");if(r.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,r.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,r.appendChild(t)}))}e.insertBefore(r,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const r=e.querySelector(`[data-field="${t.field}"]`);if(r){this.showError(r,t.message),this.touchedFields.add(t.field),r.scrollIntoView({behavior:"smooth",block:"center"});const e=r.querySelector("input, textarea, select");e&&e.focus()}}else{const r=document.createElement("div");r.className="form-error error-message",r.textContent=t.message;const s=window.getIcon?.("close-circle");s&&(s.classList.add("error-icon"),r.prepend(s)),e.insertBefore(r,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]"),r=t.dataset.action,s=t.closest("form");switch(r){case"clear-form":s?.dataset.formId&&(this.store.delete(s.dataset.formId),s.reset(),s.querySelector(".fstatus").hidden=!0),window.jvbA11y&&window.jvbA11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":s.querySelector(".fstatus").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(s&&(s.options.autosave||this.subscribers.size>0)){const e=s.dependencies.get(t.name);e&&e.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)}));const a=this.getDelayForField(t);this.scheduleSave(s,a)}}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){if(e.target.closest("[data-ignore]")||!e.target.closest("form")||this.isRestoring)return;const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,(()=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),this.notify("field-validated",e),!0}showSuccess(e,t=""){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-error"),i?.classList.remove("error"),e.classList.add("has-success"),r&&(r.hidden=!1),s&&(s.hidden=!0),a&&(""===t?(a.hidden=!0,a.textContent=""):(a.hidden=!1,a.textContent=t))}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let r=!0;return t.forEach((e=>{if(this.isComplexFieldWrapper(e))return;const t=e.querySelector('input:not([type="hidden"]), textarea, select');if(t&&!t.closest("[hidden]")){const s=e.dataset.field;s&&this.touchedFields.add(s);this.validateField(t,e)||(r=!1,!1===r&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),r}isComplexFieldWrapper(e){return e.classList.contains("repeater")||e.classList.contains("group")||e.classList.contains("upload")}attachRepeaterValidation(e){e.addEventListener("click",(t=>{t.target.closest(".add-repeater-row")&&setTimeout((()=>{e.querySelectorAll(".repeater-row").forEach((e=>{e.querySelectorAll("input, textarea, select").forEach((e=>{const t=this.findFieldWrapper(e);t&&this.clearValidation(t)}))}))}),100)}))}attachGroupValidation(e){e.addEventListener("change",(t=>{const r=t.target.closest("input, select");if(!r)return;const s=r.name;if(!s)return;e.querySelectorAll(`[data-show-if*="${s}"]`).forEach((e=>{e.hidden&&this.clearValidation(e)}))}))}resetForm(e){if(!e)return;this.touchedFields.clear();e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)}))}getFormErrors(e){const t={};return e.querySelectorAll(".field.has-error").forEach((e=>{const r=e.dataset.field,s=e.querySelector(".validation-message");r&&s&&(t[r]=s.textContent)})),t}addValidator(e,t){this.validators[e]=t}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){if(!e.options.autosave)return;document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")})).catch((t=>{console.error("Autosave failed:",t),this.showFormStatus(e.id,"error","Failed to save changes")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.data,r);return Object.keys(s).length>0}showFormStatus(e,t,r=""){let s=this.forms.get(e);if(!s?.options.formStatus)return;if(s.status===t)return;s.status=t;const a=s.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.remove(),a.querySelector(".actions")?.remove();const n={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"};let o=window.getIcon({autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);if(o&&a.prepend(o),""===r&&(r=n[t]||t),i.textContent=r,a.classList.toggle("loading",["uploading","saving"].includes(t)),"restored"===t){const e=document.createElement("div");e.className="actions",e.innerHTML='\n            <button type="button" class="button button-small" data-action="dismiss-restore">Got it</button>\n            <button type="button" class="button button-small button-link" data-action="clear-form">Start over</button>\n        ',a.appendChild(e),setTimeout((()=>a.hidden=!0),1e4)}"submitted"===t&&setTimeout((()=>a.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e,t=!1){if(Object.hasOwn(e.dataset,"timeline"))return this.collectTimeline(e);if(e.classList.contains("table")&&"FORM"===e.tagName)return{};const r=new FormData(e);let s={};const a={},i={};for(let[t,n]of r.entries()){if(this.ignore.includes(t)||t.endsWith("_temp"))continue;this.getFieldProcessor(t)(t,n,s,a,i,e)}return 0!==Object.keys(i).length?(s=this.mergeRepeaterData(s,a),this.mergePostData(s,i)):this.mergeRepeaterData(s,a)}collectTimeline(e){let t={},r={},s=[],a=new FormData(e);for(const[i,n]of a.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;const a=i.match(/^\[(\d+)\](.+)$/);if(a){const[,t,o]=a;if(r[t]||(r[t]={id:parseInt(t)},s.push(t)),"post_thumbnail"===o)r[t].post_thumbnail=parseInt(e.querySelector(`[name="${i}"]`).closest(".item")?.dataset.id);else{this.getFieldProcessor(o)(o,n,r[t],{},{},e)}}else{this.getFieldProcessor(i)(i,n,t,{},{},e)}}return t.timeline=s.map((e=>r[e])),delete t["form-id"],delete t.sendAll,delete t.timeline_temp,delete t[""],t}getFieldProcessor(e){return e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[r,s]of Object.entries(t))e[r]=s;return e}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),c||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const c=n[n.length-1];void 0!==l[c]?(Array.isArray(l[c])||(l[c]=[l[c]]),l[c].push(t)):l[c]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary");if(!a)return;const i=a.querySelector(".result"),n=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(n.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;let o=i.cloneNode(!0),l=o.querySelector("h3"),c=o.querySelector("p");l.textContent=r.label;let d=this.formatFieldValue(t,r.type);this.isHtmlContent(d)?c.innerHTML=d:c.textContent=d,a.append(o)}i.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=e.querySelector(`[name=${t}]`),a=s?.closest(".field");if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend"))}a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`);let i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":"No";case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"file":case"image":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")).split(/\n\n+/);return t.length>1?t.map((e=>`<p>${e.replace(/\n/g,"<br>")}</p>`)).join(""):e.replace(/\n/g,"<br>")}nl2br(e){return this.formatPlainText(e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((r=>r(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("change",this.changeHandler),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})();
(()=>{class e{constructor(e={}){this.config={collectFormData:!1,...e},this.isRestoring=!1;const t=window.jvbStore.register("forms",{storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4,validateData:!0,delayFetch:!0});this.store=t.forms,this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.isTimeline=window.crudManager&&window.crudManager.isTimeline,this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.blurHandler=this.handleBlur.bind(this),this.processRepeaterField=this.processRepeaterField.bind(this),this.processGroupField=this.processGroupField.bind(this),this.processLocationField=this.processLocationField.bind(this),this.processRegularField=this.processRegularField.bind(this),this.init()}async init(){this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners(),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.type&&this.handleOperationComplete(t)}))}async handleOperationComplete(e){if(e.formId)try{await this.store.delete(e.formId)}catch(e){console.warn("Failed to clear form cache:",e)}const t=this.forms.get(e.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){const e=await this.store.getAll(),t=window.location.pathname;e.filter((e=>{if("draft"!==e.status)return!1;const r=e.data?._wp_http_referer;return r===t})).forEach((e=>{const t=this.findFormElement(e);if(!t)return;let r=this.forms.get(e.formId);t.dataset.formId||(r=this.registerForm(t)),this.isRestoring=!0,new this.populateForm(t,e.data),setTimeout((()=>{this.isRestoring=!1}),0),this.showFormStatus(e.formId,"restored"),window.jvbA11y&&window.jvbA11y.announce("Your previous entry has been restored")}))}findFormElement(e){if(e.data?.form_id){const t=document.querySelector(`[name="form_id"][value="${e.data.form_id}"]`)?.closest("form");if(t)return t}if(e.data?.form_type){const t=document.querySelector(`[name="form_type"][value="${e.data.form_type}"]`)?.closest("form");if(t)return t}return document.querySelector(`[data-form-id="${e.formId}"]`)}showPendingNotification(e,t){const r=document.querySelector(`[data-form-id="${e}"]`);if(!r)return;const s=document.createElement("div");s.className="pending-changes-notification",s.innerHTML=`\n        <p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n        <button class="restore-changes" data-form-id="${e}">Restore</button>\n        <button class="discard-changes" data-form-id="${e}">Discard</button>\n    `,r.insertBefore(s,r.firstChild),s.querySelector(".restore-changes").addEventListener("click",(async()=>{await this.restorePendingForm(e,t),s.remove()})),s.querySelector(".discard-changes").addEventListener("click",(async()=>{await this.discardPendingForm(e),s.remove()}))}async restorePendingForm(e,t){const r=document.querySelector(`[data-form-id="${e}"]`);r&&(new this.populateForm(r,t),await this.store.save({formId:e,data:t,status:"restored",timestamp:Date.now()}),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){try{await this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}catch(e){console.error("Failed to discard pending form:",e)}}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){if(!e)return;const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,status:"",options:{autosave:"autosave"in e.dataset,autoUpload:!0,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save??"",formStatus:!0,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e,!0)};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.data&&this.showPendingNotification(r,e.data)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),this.initTagListFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e,t),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const d=s.switchTab.bind(s);s.switchTab=(e,t)=>{d(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initTagListFields(e,t){e.querySelectorAll(".field.tag-list").forEach((e=>{const r=e.querySelector(".tag-input-row"),s=e.querySelector(".add-tag-item"),a=e.querySelector(".tag-items"),i=e.querySelector(".tag-template"),n=e.dataset.field,o=e.dataset.tagFormat||"first_field";if(!(r&&s&&a&&i))return;const l=()=>Array.from(r.querySelectorAll("input, select, textarea")).filter((e=>!e.closest("button"))),d=()=>{const r=l(),s={};let d=!1;if(r.forEach((e=>{const t=e.name.replace("new_",""),r=this.getFieldValue(e);r&&(d=!0),s[t]=r})),!d)return window.jvbA11y&&window.jvbA11y.announce("Please fill in at least one field","error"),void r[0].focus();const c=r.find((e=>{const t="required"in e.dataset&&"1"===e.dataset.required,r=this.getFieldValue(e);return t&&!r}));if(c){const e=c.closest(".field"),t=e?.querySelector("label")?.textContent||"This field";return this.showError(e,`${t} is required.`),void c.focus()}for(let t of r){let r=e.closest(".field");if(!this.validateField(t,r))return void t.focus()}const u=a.children.length,h=i.content.cloneNode(!0).firstElementChild;h.dataset.index=u;const m=h.querySelector(".tag-label");m&&(m.textContent=this.getTagDisplayText(s,o)),h.querySelectorAll('input[type="hidden"]').forEach((e=>{const t=e.dataset.field;e.name=`${n}:${u}:${t}`,e.value=s[t]||""})),a.appendChild(h),r.forEach((e=>{"checkbox"===e.type||"radio"===e.type?e.checked=!1:e.value="";let t=e.closest(".field");this.clearValidation(t)})),r.length>0&&r[0].focus(),t&&this.scheduleSave(t,{type:"tag_list",action:"add",fieldName:n,delay:this.autoSaveDefaults.delay}),window.jvbA11y&&window.jvbA11y.announce("Item added")};s.addEventListener("click",d);const c=l();c.length>0&&(c[c.length-1].addEventListener("keypress",(e=>{"Enter"===e.key&&(e.preventDefault(),d())})),c.slice(0,-1).forEach(((e,t)=>{e.addEventListener("keypress",(e=>{"Enter"===e.key&&(e.preventDefault(),c[t+1].focus())}))}))),a.addEventListener("click",(e=>{if(e.target.closest(".remove-tag")){const r=e.target.closest(".tag-item"),s=r.querySelector(".tag-label")?.textContent||"Item";r.remove(),this.reindexTagList(a,n),t&&this.scheduleSave(t,{type:"tag_list",action:"remove",fieldName:n,delay:this.autoSaveDefaults.delay}),window.jvbA11y&&window.jvbA11y.announce(`${s} removed`)}}))}))}reindexTagList(e,t){Array.from(e.children).forEach(((e,r)=>{e.dataset.index=r,e.querySelectorAll('input[type="hidden"]').forEach((e=>{const s=e.dataset.field;e.name=`${t}:${r}:${s}`}))}))}getTagDisplayText(e,t){const r=Object.values(e).filter((e=>e));if(0===r.length)return"New Item";switch(t){case"first_field":return r[0];case"all_fields":return r.join(", ");default:if(t.includes("{")){let r=t;for(const[t,s]of Object.entries(e))r=r.replace(`{${t}}`,s);return r}return e[t]||r[0]}}escapeHtml(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s===a;case"!=":return s!==a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload)}async handleSubmit(e){const t=e.target;if(!t.dataset.formId)return;const r=this.forms.get(t.dataset.formId);if(this.subscribers.size>0){e.preventDefault();const s=this.collectFormData(t);this.notify("form-submit",{formId:t.dataset.formId,fullData:s,config:r})}}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const r=document.createElement("div");r.className="form-success-message success-message",r.textContent=t.message,e.insertBefore(r,e.firstChild);const s=window.getIcon?.("check-circle");s&&(s.classList.add("success-icon"),r.prepend(s))}if(t.title||t.description){const r=document.createElement("div");if(r.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,r.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,r.appendChild(t)}))}e.insertBefore(r,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const r=e.querySelector(`[data-field="${t.field}"]`);if(r){this.showError(r,t.message),this.touchedFields.add(t.field),r.scrollIntoView({behavior:"smooth",block:"center"});const e=r.querySelector("input, textarea, select");e&&e.focus()}}else{const r=document.createElement("div");r.className="form-error error-message",r.textContent=t.message;const s=window.getIcon?.("close-circle");s&&(s.classList.add("error-icon"),r.prepend(s)),e.insertBefore(r,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]"),r=t.dataset.action,s=t.closest("form");switch(r){case"clear-form":s?.dataset.formId&&(this.store.delete(s.dataset.formId),s.reset(),s.querySelector(".fstatus").hidden=!0),window.jvbA11y&&window.jvbA11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":s.querySelector(".fstatus").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(s&&(s.options.autosave||this.subscribers.size>0)){const e=s.dependencies.get(t.name);e&&e.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)}));const a=this.getDelayForField(t);this.scheduleSave(s,a)}}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){if(e.target.closest("[data-ignore]")||!e.target.closest("form")||this.isRestoring)return;const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,(()=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),this.notify("field-validated",e),!0}showSuccess(e,t=""){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-error"),i?.classList.remove("error"),e.classList.add("has-success"),r&&(r.hidden=!1),s&&(s.hidden=!0),a&&(""===t?(a.hidden=!0,a.textContent=""):(a.hidden=!1,a.textContent=t))}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){if(!e.options.autosave)return;document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")})).catch((t=>{console.error("Autosave failed:",t),this.showFormStatus(e.id,"error","Failed to save changes")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.data,r);return Object.keys(s).length>0}showFormStatus(e,t,r=""){let s=this.forms.get(e);if(!s?.options.formStatus)return;if(s.status===t)return;s.status=t;const a=s.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.remove(),a.querySelector(".actions")?.remove();const n={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"};let o=window.getIcon({autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);if(o&&a.prepend(o),""===r&&(r=n[t]||t),i.textContent=r,a.classList.toggle("loading",["uploading","saving"].includes(t)),"restored"===t){const e=document.createElement("div");e.className="actions",e.innerHTML='\n            <button type="button" class="button button-small" data-action="dismiss-restore">Got it</button>\n            <button type="button" class="button button-small button-link" data-action="clear-form">Start over</button>\n        ',a.appendChild(e),setTimeout((()=>a.hidden=!0),1e4)}"submitted"===t&&setTimeout((()=>a.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){if(Object.hasOwn(e.dataset,"timeline"))return this.collectTimeline(e);if(e.classList.contains("table")&&"FORM"===e.tagName)return{};const t=new FormData(e);let r={};const s={},a={};for(let[i,n]of t.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;this.getFieldProcessor(i)(i,n,r,s,a,e)}return 0!==Object.keys(a).length?(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a)):this.mergeRepeaterData(r,s)}collectTimeline(e){let t={},r={},s=[],a=new FormData(e);for(const[i,n]of a.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;const a=i.match(/^\[(\d+)](.+)$/);if(a){const[,t,o]=a;if(r[t]||(r[t]={id:parseInt(t)},s.push(t)),"post_thumbnail"===o)r[t].post_thumbnail=parseInt(e.querySelector(`[name="${i}"]`).closest(".item")?.dataset.id);else{this.getFieldProcessor(o)(o,n,r[t],{},{},e)}}else{this.getFieldProcessor(i)(i,n,t,{},{},e)}}return t.timeline=s.map((e=>r[e])),delete t["form-id"],delete t.sendAll,delete t.timeline_temp,delete t[""],t}getFieldProcessor(e){return e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[r,s]of Object.entries(t))e[r]=s;return e}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const d=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),d||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const d=n[n.length-1];void 0!==l[d]?(Array.isArray(l[d])||(l[d]=[l[d]]),l[d].push(t)):l[d]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary");if(!a)return;const i=a.querySelector(".result"),n=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(n.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;let o=i.cloneNode(!0),l=o.querySelector("h3"),d=o.querySelector("p");l.textContent=r.label;let c=this.formatFieldValue(t,r.type,s);this.isHtmlContent(c)?d.innerHTML=c:d.textContent=c,a.append(o)}let o=s.querySelectorAll("[data-upload-field]");o&&o.forEach((e=>{let t=e.querySelector("h2").textContent,r=e.querySelectorAll(".item-grid.preview img");if(r){let e=i.cloneNode(!0),s=e.querySelector("h3");e.querySelector("p").remove(),s.textContent=t,r.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),a.append(e)}})),i.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=e.querySelector(`[name=${t}]`);if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend, h2"))}let a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`),i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":e;case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"upload":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")).split(/\n\n+/);return t.length>1?t.map((e=>`<p>${e.replace(/\n/g,"<br>")}</p>`)).join(""):e.replace(/\n/g,"<br>")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((r=>r(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("change",this.changeHandler),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=e)}))}))})();
assets/js/min/gallery.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.index=0,this.images=[],this.zoom={scale:1,min:1,max:4,threshold:50,x:0,y:0,startX:0,startY:0,ease:.2,panning:!1},this.swipe=this.resetSwipe(),this.activePointers=new Map,this.lastTap=0,this.initElements(),this.initModal(),this.initListeners(),this.initSubscribers()}initElements(){this.elements={imageSelector:"img[data-gallery]",gallery:{modal:"dialog.gallery",wrap:".wrap",nextButton:".next",prevButton:".prev",image:".image",leftImage:".image-left",rightImage:".image-right",counter:".counter"}},this.ui=window.uiFromSelectors(this.elements)}initModal(){this.modal=new window.jvbModal(this.ui.gallery.modal,{openMessage:"Opened Gallery",closeMessage:"Closed Gallery"}),this.modal.subscribe(((e,t)=>{"modal-close"===e&&this.toggleGallery(!1)}))}buildGalleryItems(e=null){let t=e?`[data-gallery="${e}"]`:this.elements.imageSelector;this.items=Array.from(document.querySelectorAll(t)).map(((e,t)=>({id:e.dataset.id||t,srcset:e.srcset||e.src,sizes:e.sizes||"100vw",src:e.currentSrc||e.src,full:e.dataset.full||e.src,alt:e.alt||"",element:e})))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.pointerDownHandler=this.onPointerDown.bind(this),this.pointerMoveHandler=this.onPointerMove.bind(this),this.pointerUpHandler=this.onPointerUp.bind(this),this.wheelHandler=this.onWheel.bind(this),this.keyHandler=this.handleKeys.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){let t=window.targetCheck(e,this.elements.imageSelector);t&&!this.modal.isOpen?(e.preventDefault(),this.buildGalleryItems(t.dataset.gallery||null),this.index=this.items.findIndex((e=>e.element===t)),this.toggleGallery(!0)):this.modal.isOpen&&(window.targetCheck(e,this.elements.gallery.nextButton)?(console.log("Next"),this.nextElement()):window.targetCheck(e,this.elements.gallery.prevButton)&&(console.log("Previous"),this.prevElement()))}handleKeys(e){if(this.modal.isOpen){switch(e.key){case"ArrowLeft":e.preventDefault(),this.prevElement();break;case"ArrowRight":e.preventDefault(),this.nextElement()}e.ctrlKey&&("+"!==e.key&&"="!==e.key||(e.preventDefault(),this.handleZoom(.2)),"-"===e.key&&(e.preventDefault(),this.handleZoom(-.2)),"0"===e.key&&(e.preventDefault(),this.resetZoom()))}}onPointerDown(e){e.preventDefault(),this.swipe.startX=e.clientX,this.swipe.startY=e.clientY,this.ui.gallery.image.setPointerCapture(e.pointerId),this.activePointers.set(e.pointerId,{x:e.clientX,y:e.clientY});const t=performance.now();if(t-this.lastTap<300&&1===this.activePointers.size)return this.zoom.scale>1?this.resetZoom():this.handleZoom(1,e.clientX,e.clientY),void(this.lastTap=0);if(this.lastTap=t,2===this.activePointers.size){const e=[...this.activePointers.values()];return this.pinchStartDist=Math.hypot(e[0].x-e[1].x,e[0].y-e[1].y),void(this.pinchStartScale=this.zoom.scale)}this.zoom.scale>1&&(this.zoom.panning=!0,this.zoom.startX=e.clientX-this.zoom.x,this.zoom.startY=e.clientY-this.zoom.y,this.ui.gallery.image.style.cursor="grabbing")}onPointerMove(e){if(this.activePointers.has(e.pointerId))if(this.activePointers.set(e.pointerId,{x:e.clientX,y:e.clientY}),2!==this.activePointers.size)this.zoom.panning&&(this.zoom.x=e.clientX-this.zoom.startX,this.zoom.y=e.clientY-this.zoom.startY,this.applyTransform());else{const e=[...this.activePointers.values()],t=Math.hypot(e[0].x-e[1].x,e[0].y-e[1].y),i=this.pinchStartScale*(t/this.pinchStartDist)-this.zoom.scale;this.handleZoom(i)}}onPointerUp(e){if(this.activePointers.delete(e.pointerId),this.activePointers.size<2&&(this.pinchStartDist=0),!this.zoom.panning&&0===this.activePointers.size){this.swipe.endX=e.clientX,this.swipe.endY=e.clientY;const t=this.swipe.endX-this.swipe.startX;this.swipe.endY,this.swipe.startY;Math.abs(t)>this.zoom.threshold&&(t>0?(console.log("Swipe right"),this.prevElement()):(console.log("Swipe left"),this.nextElement()))}0===this.activePointers.size&&(this.zoom.panning=!1,this.ui.gallery.image.style.cursor=this.zoom.scale>1?"grab":"default")}onWheel(e){if(!e.ctrlKey)return;e.preventDefault();const t=e.deltaY<0?.2:-.2;this.handleZoom(t,e.clientX,e.clientY)}clampPan(){const e=this.ui.gallery.wrap;if(!e)return;const t=e.getBoundingClientRect(),i=Math.min(t.width/1920,t.height/1920),s=1920*i,n=1920*i*this.zoom.scale,o=s*this.zoom.scale,l=t.width-n-32,r=t.height-o-32;this.zoom.x=Math.min(32,Math.max(l,this.zoom.x)),this.zoom.y=Math.min(32,Math.max(r,this.zoom.y))}handleZoom(e,t=null,i=null){const s=this.zoom.scale;let n=s+e;if(n=Math.min(this.zoom.max,Math.max(this.zoom.min,n)),n===s)return;const o=n/s;let l=this.ui.gallery.image.getBoundingClientRect();null!==t&&null!==i||(t=l.left+l.width/2,i=l.top+l.height/2);const r=t-l.left,a=i-l.top;this.zoom.x=(this.zoom.x-r)*o+r,this.zoom.y=(this.zoom.y-a)*o+a,this.zoom.scale=n,this.applyTransform(),this.notify("zoom",{scale:this.zoom.scale})}applyTransform(){const e=this.ui.gallery.image;e.style.transform=`translate(${this.zoom.x}px, ${this.zoom.y}px) scale(${this.zoom.scale})`,e.style.cursor=this.zoom.scale>1?"grab":"default"}resetZoom(){this.zoom.scale=1,this.zoom.x=0,this.zoom.y=0,this.zoom.startX=0,this.zoom.startY=0,this.zoom.panning=!1,this.applyTransform()}resetSwipe(){return{startX:null,startY:null,endX:null,endY:null}}toggleGallery(e,t=null){e?(this.ui.gallery.image.draggable=!1,this.ui.gallery.image.style.userSelect="none",this.ui.gallery.image.addEventListener("pointerdown",this.pointerDownHandler),this.ui.gallery.image.addEventListener("pointermove",this.pointerMoveHandler),this.ui.gallery.image.addEventListener("pointerup",this.pointerUpHandler),this.ui.gallery.image.addEventListener("pointercancel",this.pointerUpHandler),window.addEventListener("wheel",this.wheelHandler,{passive:!1}),window.addEventListener("keydown",this.keyHandler),this.moveIntoView()):(this.ui.gallery.image.removeEventListener("pointerdown",this.pointerDownHandler),this.ui.gallery.image.removeEventListener("pointermove",this.pointerMoveHandler),this.ui.gallery.image.removeEventListener("pointerup",this.pointerUpHandler),this.ui.gallery.image.removeEventListener("pointercancel",this.pointerUpHandler),window.removeEventListener("wheel",this.wheelHandler),window.removeEventListener("keydown",this.keyHandler),this.resetZoom(),this.resetSwipe(),this.activePointers.clear(),this.lastTap=0),e&&!this.modal.isOpen&&this.modal.handleOpen()}moveIntoView(e=0){let t=this.index+e;t<0?t=this.items.length-1:t>=this.items.length?t=0:t===this.items.length-3&&this.notify("load-more"),this.index=t,this.updateDisplay(),this.preloadAdjacent(),this.a11y.announce(`Image ${this.index+1} of ${this.items.length}`)}nextElement(){this.resetZoom(),this.moveIntoView(1)}prevElement(){this.resetZoom(),this.moveIntoView(-1)}updateDisplay(){const e=this.items[this.index];if(!e)return;const t=this.ui.gallery.image;if(e.srcset&&(t.srcset=e.srcset,t.sizes=e.sizes),t.src=e.src,t.alt=e.alt,e.full&&e.full!==e.src){const i=new Image;i.onload=()=>{this.items[this.index]===e&&(t.src=e.full,t.removeAttribute("srcset"),t.removeAttribute("sizes"))},i.src=e.full}this.ui.gallery.counter.textContent=`${this.index+1} / ${this.items.length}`,this.ui.gallery.prevButton.disabled=this.items.length<=1,this.ui.gallery.nextButton.disabled=this.items.length<=1}preloadAdjacent(){[-1,1].forEach((e=>{const t=this.index+e;if(t>0&&t<this.items.length){const i=this.items[t];(e<0?this.ui.gallery.leftImage:this.ui.gallery.rightImage).src=i.full}}))}initSubscribers(){this.subscribers=new Set}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.toggleGallery(!1),document.removeEventListener("click",this.clickHandler)}}document.addEventListener("DOMContentLoaded",(function(){document.querySelector("dialog.gallery")&&(window.jvbGallery=new e)}))})();
(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.index=0,this.images=[],this.zoom={scale:1,min:1,max:4,threshold:50,x:0,y:0,startX:0,startY:0,ease:.2,panning:!1},this.swipe=this.resetSwipe(),this.activePointers=new Map,this.lastTap=0,this.initElements(),this.initModal(),this.initListeners(),this.initSubscribers()}initElements(){this.elements={imageSelector:"img[data-gallery]",gallery:{modal:"dialog.gallery",wrap:".wrap",nextButton:".next",prevButton:".prev",image:".image",leftImage:".image-left",rightImage:".image-right",counter:".counter"}},this.ui=window.uiFromSelectors(this.elements)}initModal(){this.modal=new window.jvbModal(this.ui.gallery.modal,{openMessage:"Opened Gallery",closeMessage:"Closed Gallery"}),this.modal.subscribe((e=>{"modal-close"===e&&this.toggleGallery(!1)}))}buildGalleryItems(e=null){let t=e?`[data-gallery="${e}"]`:this.elements.imageSelector;this.items=Array.from(document.querySelectorAll(t)).map(((e,t)=>({id:e.dataset.id||t,srcset:e.srcset||e.src,sizes:e.sizes||"100vw",src:e.currentSrc||e.src,full:e.dataset.full||e.src,alt:e.alt||"",element:e})))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.pointerDownHandler=this.onPointerDown.bind(this),this.pointerMoveHandler=this.onPointerMove.bind(this),this.pointerUpHandler=this.onPointerUp.bind(this),this.wheelHandler=this.onWheel.bind(this),this.keyHandler=this.handleKeys.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){let t=window.targetCheck(e,this.elements.imageSelector);t&&!this.modal.isOpen?(e.preventDefault(),this.buildGalleryItems(t.dataset.gallery||null),this.index=this.items.findIndex((e=>e.element===t)),this.toggleGallery(!0)):this.modal.isOpen&&(window.targetCheck(e,this.elements.gallery.nextButton)?(console.log("Next"),this.nextElement()):window.targetCheck(e,this.elements.gallery.prevButton)&&(console.log("Previous"),this.prevElement()))}handleKeys(e){if(this.modal.isOpen){switch(e.key){case"ArrowLeft":e.preventDefault(),this.prevElement();break;case"ArrowRight":e.preventDefault(),this.nextElement()}e.ctrlKey&&("+"!==e.key&&"="!==e.key||(e.preventDefault(),this.handleZoom(.2)),"-"===e.key&&(e.preventDefault(),this.handleZoom(-.2)),"0"===e.key&&(e.preventDefault(),this.resetZoom()))}}onPointerDown(e){e.preventDefault(),this.swipe.startX=e.clientX,this.swipe.startY=e.clientY,this.ui.gallery.image.setPointerCapture(e.pointerId),this.activePointers.set(e.pointerId,{x:e.clientX,y:e.clientY});const t=performance.now();if(t-this.lastTap<300&&1===this.activePointers.size)return this.zoom.scale>1?this.resetZoom():this.handleZoom(1,e.clientX,e.clientY),void(this.lastTap=0);if(this.lastTap=t,2===this.activePointers.size){const e=[...this.activePointers.values()];return this.pinchStartDist=Math.hypot(e[0].x-e[1].x,e[0].y-e[1].y),void(this.pinchStartScale=this.zoom.scale)}this.zoom.scale>1&&(this.zoom.panning=!0,this.zoom.startX=e.clientX-this.zoom.x,this.zoom.startY=e.clientY-this.zoom.y,this.ui.gallery.image.style.cursor="grabbing")}onPointerMove(e){if(this.activePointers.has(e.pointerId))if(this.activePointers.set(e.pointerId,{x:e.clientX,y:e.clientY}),2!==this.activePointers.size)this.zoom.panning&&(this.zoom.x=e.clientX-this.zoom.startX,this.zoom.y=e.clientY-this.zoom.startY,this.applyTransform());else{const e=[...this.activePointers.values()],t=Math.hypot(e[0].x-e[1].x,e[0].y-e[1].y),i=this.pinchStartScale*(t/this.pinchStartDist)-this.zoom.scale;this.handleZoom(i)}}onPointerUp(e){if(this.activePointers.delete(e.pointerId),this.activePointers.size<2&&(this.pinchStartDist=0),!this.zoom.panning&&0===this.activePointers.size){this.swipe.endX=e.clientX,this.swipe.endY=e.clientY;const t=this.swipe.endX-this.swipe.startX;this.swipe.endY,this.swipe.startY;Math.abs(t)>this.zoom.threshold&&(t>0?(console.log("Swipe right"),this.prevElement()):(console.log("Swipe left"),this.nextElement()))}0===this.activePointers.size&&(this.zoom.panning=!1,this.ui.gallery.image.style.cursor=this.zoom.scale>1?"grab":"default")}onWheel(e){if(!e.ctrlKey)return;e.preventDefault();const t=e.deltaY<0?.2:-.2;this.handleZoom(t,e.clientX,e.clientY)}handleZoom(e,t=null,i=null){const s=this.zoom.scale;let n=s+e;if(n=Math.min(this.zoom.max,Math.max(this.zoom.min,n)),n===s)return;const l=n/s;let o=this.ui.gallery.image.getBoundingClientRect();null!==t&&null!==i||(t=o.left+o.width/2,i=o.top+o.height/2);const r=t-o.left,a=i-o.top;this.zoom.x=(this.zoom.x-r)*l+r,this.zoom.y=(this.zoom.y-a)*l+a,this.zoom.scale=n,this.applyTransform(),this.notify("zoom",{scale:this.zoom.scale})}applyTransform(){const e=this.ui.gallery.image;e.style.transform=`translate(${this.zoom.x}px, ${this.zoom.y}px) scale(${this.zoom.scale})`,e.style.cursor=this.zoom.scale>1?"grab":"default"}resetZoom(){this.zoom.scale=1,this.zoom.x=0,this.zoom.y=0,this.zoom.startX=0,this.zoom.startY=0,this.zoom.panning=!1,this.applyTransform()}resetSwipe(){return{startX:null,startY:null,endX:null,endY:null}}toggleGallery(e){e?(this.ui.gallery.image.draggable=!1,this.ui.gallery.image.style.userSelect="none",this.ui.gallery.image.addEventListener("pointerdown",this.pointerDownHandler),this.ui.gallery.image.addEventListener("pointermove",this.pointerMoveHandler),this.ui.gallery.image.addEventListener("pointerup",this.pointerUpHandler),this.ui.gallery.image.addEventListener("pointercancel",this.pointerUpHandler),window.addEventListener("wheel",this.wheelHandler,{passive:!1}),window.addEventListener("keydown",this.keyHandler),this.moveIntoView()):(this.ui.gallery.image.removeEventListener("pointerdown",this.pointerDownHandler),this.ui.gallery.image.removeEventListener("pointermove",this.pointerMoveHandler),this.ui.gallery.image.removeEventListener("pointerup",this.pointerUpHandler),this.ui.gallery.image.removeEventListener("pointercancel",this.pointerUpHandler),window.removeEventListener("wheel",this.wheelHandler),window.removeEventListener("keydown",this.keyHandler),this.resetZoom(),this.resetSwipe(),this.activePointers.clear(),this.lastTap=0),e&&!this.modal.isOpen&&this.modal.handleOpen()}moveIntoView(e=0){let t=this.index+e;t<0?t=this.items.length-1:t>=this.items.length?t=0:t===this.items.length-3&&this.notify("load-more"),this.index=t,this.updateDisplay(),this.preloadAdjacent(),this.a11y.announce(`Image ${this.index+1} of ${this.items.length}`)}nextElement(){this.resetZoom(),this.moveIntoView(1)}prevElement(){this.resetZoom(),this.moveIntoView(-1)}updateDisplay(){const e=this.items[this.index];if(!e)return;const t=this.ui.gallery.image;if(e.srcset&&(t.srcset=e.srcset,t.sizes=e.sizes),t.src=e.src,t.alt=e.alt,e.full&&e.full!==e.src){const i=new Image;i.onload=()=>{this.items[this.index]===e&&(t.src=e.full,t.removeAttribute("srcset"),t.removeAttribute("sizes"))},i.src=e.full}this.ui.gallery.counter.textContent=`${this.index+1} / ${this.items.length}`,this.ui.gallery.prevButton.disabled=this.items.length<=1,this.ui.gallery.nextButton.disabled=this.items.length<=1}preloadAdjacent(){[-1,1].forEach((e=>{const t=this.index+e;if(t>0&&t<this.items.length){const i=this.items[t];(e<0?this.ui.gallery.leftImage:this.ui.gallery.rightImage).src=i.full}}))}initSubscribers(){this.subscribers=new Set}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.toggleGallery(!1),document.removeEventListener("click",this.clickHandler)}}document.addEventListener("DOMContentLoaded",(function(){document.querySelector("dialog.gallery")&&(window.jvbGallery=new e)}))})();
assets/js/min/handleSelection.min.js
@@ -1 +1 @@
window.jvbHandleSelection=class{constructor(e){this.container=e.container,this.ui=e.ui||{},this.itemSelector=e.itemSelector||".item",this.checkboxSelector=e.checkboxSelector||'[name*="select-item"]',this.selectedItems=new Set,this.lastSelected=null,this.subscribers=new Set,this.init()}init(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.keyHandler=this.handleKeys.bind(this),this.container.addEventListener("click",this.clickHandler),this.container.addEventListener("change",this.changeHandler),this.container.addEventListener("keydown",this.keyHandler)}handleKeys(e){(e.ctrlKey||e.metaKey)&&"a"===e.key&&(e.preventDefault(),this.ui.selectAll&&(this.ui.selectAll.checked=!0,this.selectAll(!0),window.jvbA11y&&window.jvbA11y.announce("All items selected"))),"Escape"===e.key&&this.selectedItems.size>0&&(this.selectAll(!1),window.jvbA11y&&window.jvbA11y.announce("Selection cleared")),("Delete"===e.key||"Backspace"===e.key)&&!e.target.matches("input, textarea")&&this.selectedItems>0&&(e.preventDefault(),confirm(`Remove ${this.selectedItems.size} selected item${1!==this.selectedItems.size?"s":""}?`)&&this.deselect(this.selectedItems))}handleClick(e){const t=e.target.closest(`${this.checkboxSelector}, label[for]`);if(!t)return;const s="LABEL"===t.tagName?document.getElementById(t.getAttribute("for")):t;if(s)if(e.shiftKey&&this.lastSelected)e.preventDefault(),this.handleRangeSelection(s);else{const e=s.closest(this.itemSelector);e&&(this.lastSelected=e)}}handleChange(e){if(this.ui.selectAll&&e.target===this.ui.selectAll)this.selectAll(e.target.checked);else{const t=e.target.closest(this.checkboxSelector);if(!t)return;this.toggleSelection(this.getItemId(t))}}toggleSelection(e){if(!e)return;let t=!0;this.selectedItems.has(e)?(t=!1,this.selectedItems.delete(e)):this.selectedItems.add(e),t?this.notify("item-selected",{selectedItem:e,selectedItems:this.selectedItems,container:this.container}):this.notify("item-deselected",{selectedItem:e,selectedItems:this.selectedItems,container:this.container}),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(this.itemSelector);e||(this.selectedItems.clear(),this.ui.selectAll&&(this.ui.selectAll.checked=!1)),t.forEach((t=>{const s=this.getItemId(t),i=t.querySelector(this.checkboxSelector);i&&(i.checked=e),e&&s&&this.selectedItems.add(s)})),this.notify("select-all",{container:this.container,selected:e,items:t}),this.updateSelectionUI()}clearSelection(){this.selectAll(!1)}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(this.itemSelector));const t=e.closest(this.itemSelector);if(!t)return;const s=Array.from(this.container.querySelectorAll(this.itemSelector)),i=s.indexOf(this.lastSelected),c=s.indexOf(t);if(-1===i||-1===c)return;const l=Math.min(i,c),n=Math.max(i,c);let h=!e.checked;for(let e=l;e<=n;e++){const t=s[e],i=t.querySelector(this.checkboxSelector),c=this.getItemId(t);i&&c&&(i.checked=h,this.selectedItems.add(c))}this.lastSelected=t,this.updateSelectionUI(),this.notify("range-selected",{selectedItems:this.selectedItems,container:this.container});const r=n-l+1;window.jvbA11y&&window.jvbA11y.announce(`Selected ${r} items in range`)}updateSelectionUI(){const e=this.selectedItems.size,t=this.container.querySelectorAll(this.itemSelector).length;if(this.ui.bulkControls&&(this.ui.bulkControls.hidden=0===e),this.ui.count){const t=1===e?"item":"items";this.ui.count.textContent=0===e?"":`{ ${e} ${t} selected }`,this.ui.count.hidden=0===e}if(this.ui.selectAll){this.ui.selectAll.checked=t>0&&e===t,this.ui.selectAll.indeterminate=e>0&&e<t;const s=this.ui.selectAll.nextElementSibling||this.ui.selectAll.previousElementSibling;s&&"LABEL"===s.tagName&&(s.textContent=t>0&&e===t?"Clear Selection":"Select All")}}getItemId(e){const t=e.closest(this.itemSelector);return t?t.dataset.id||t.dataset.itemId||t.dataset.uploadId||t.id:null}getSelected(){return Array.from(this.selectedItems)}isSelected(e){return this.selectedItems.has(e)}select(e){(Array.isArray(e)?e:[e]).forEach((e=>{this.selectedItems.add(e);const t=this.container.querySelector(`${this.itemSelector}[data-id="${e}"]`);if(t){const e=t.querySelector(this.checkboxSelector);e&&(e.checked=!0)}})),this.updateSelectionUI(),this.notify("item-selected",{selectedItem:id,selectedItems:this.selectedItems,container:this.container})}deselect(e){(Array.isArray(e)?e:[e]).forEach((e=>{this.selectedItems.delete(e);const t=this.container.querySelector(`${this.itemSelector}[data-id="${e}"]`);if(t){const e=t.querySelector(this.checkboxSelector);e&&(e.checked=!1)}})),this.updateSelectionUI(),this.notify("item-deselected",{selectedItem:id,selectedItems:this.selectedItems,container:this.container})}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.container&&(this.container.removeEventListener("click",this.clickHandler),this.container.removeEventListener("change",this.changeHandler),this.container.removeEventListener("keydown",this.keyHandler)),this.clearSelection(),this.subscribers.clear(),this.container=null,this.ui=null,this.lastSelected=null}};
window.jvbHandleSelection=class{constructor(e){this.container=e.container,this.ui=e.ui||{},this.itemSelector=e.itemSelector||".item",this.checkboxSelector=e.checkboxSelector||'[name*="select-item"]',this.selectedItems=new Set,this.lastSelected=null,this.subscribers=new Set,this.init()}init(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.keyHandler=this.handleKeys.bind(this),this.container.addEventListener("click",this.clickHandler),this.container.addEventListener("change",this.changeHandler),this.container.addEventListener("keydown",this.keyHandler)}handleKeys(e){(e.ctrlKey||e.metaKey)&&"a"===e.key&&(e.preventDefault(),this.ui.selectAll&&(this.ui.selectAll.checked=!0,this.selectAll(!0),window.jvbA11y&&window.jvbA11y.announce("All items selected"))),"Escape"===e.key&&this.selectedItems.size>0&&(this.selectAll(!1),window.jvbA11y&&window.jvbA11y.announce("Selection cleared")),("Delete"===e.key||"Backspace"===e.key)&&!e.target.matches("input, textarea")&&this.selectedItems>0&&(e.preventDefault(),confirm(`Remove ${this.selectedItems.size} selected item${1!==this.selectedItems.size?"s":""}?`)&&this.deselect(this.selectedItems))}handleClick(e){const t=e.target.closest(`${this.checkboxSelector}, label[for]`);if(!t)return;const s="LABEL"===t.tagName?document.getElementById(t.getAttribute("for")):t;if(s)if(e.shiftKey&&this.lastSelected)e.preventDefault(),this.handleRangeSelection(s);else{const e=s.closest(this.itemSelector);e&&(this.lastSelected=e)}}handleChange(e){if(this.ui.selectAll&&e.target===this.ui.selectAll)this.selectAll(e.target.checked);else{const t=e.target.closest(this.checkboxSelector);if(!t)return;this.toggleSelection(this.getItemId(t))}}toggleSelection(e){if(!e)return;let t=!0;this.selectedItems.has(e)?(t=!1,this.selectedItems.delete(e)):this.selectedItems.add(e),t?this.notify("item-selected",{selectedItem:e,selectedItems:this.selectedItems,container:this.container}):this.notify("item-deselected",{selectedItem:e,selectedItems:this.selectedItems,container:this.container}),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(this.itemSelector);e||(this.selectedItems.clear(),this.ui.selectAll&&(this.ui.selectAll.checked=!1)),t.forEach((t=>{const s=this.getItemId(t),i=t.querySelector(this.checkboxSelector);i&&(i.checked=e),e&&s&&this.selectedItems.add(s)})),this.notify("select-all",{container:this.container,selected:e,items:t}),this.updateSelectionUI()}clearSelection(){this.selectAll(!1)}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(this.itemSelector));const t=e.closest(this.itemSelector);if(!t)return;const s=Array.from(this.container.querySelectorAll(this.itemSelector)),i=s.indexOf(this.lastSelected),c=s.indexOf(t);if(-1===i||-1===c)return;const l=Math.min(i,c),n=Math.max(i,c);let h=!e.checked;for(let e=l;e<=n;e++){const t=s[e],i=t.querySelector(this.checkboxSelector),c=this.getItemId(t);i&&c&&(i.checked=h,this.selectedItems.add(c))}this.lastSelected=t,this.updateSelectionUI(),this.notify("range-selected",{selectedItems:this.selectedItems,container:this.container});const r=n-l+1;window.jvbA11y&&window.jvbA11y.announce(`Selected ${r} items in range`)}updateSelectionUI(){const e=this.selectedItems.size,t=this.container.querySelectorAll(this.itemSelector).length;if(this.ui.bulkControls&&(this.ui.bulkControls.hidden=0===e),this.ui.count){const t=1===e?"item":"items";this.ui.count.textContent=0===e?"":`{ ${e} ${t} selected }`,this.ui.count.hidden=0===e}if(this.ui.selectAll){this.ui.selectAll.checked=t>0&&e===t,this.ui.selectAll.indeterminate=e>0&&e<t;const s=this.ui.selectAll.nextElementSibling||this.ui.selectAll.previousElementSibling;s&&"LABEL"===s.tagName&&(s.textContent=t>0&&e===t?"Clear Selection":"Select All")}}getItemId(e){const t=e.closest(this.itemSelector);return t?t.dataset.id||t.dataset.itemId||t.dataset.uploadId||t.id:null}isSelected(e){return this.selectedItems.has(e)}select(e){(Array.isArray(e)?e:[e]).forEach((e=>{this.selectedItems.add(e);const t=this.container.querySelector(`${this.itemSelector}[data-id="${e}"]`);if(t){const e=t.querySelector(this.checkboxSelector);e&&(e.checked=!0)}})),this.updateSelectionUI(),this.notify("item-selected",{selectedItem:id,selectedItems:this.selectedItems,container:this.container})}deselect(e){(Array.isArray(e)?e:[e]).forEach((e=>{this.selectedItems.delete(e);const t=this.container.querySelector(`${this.itemSelector}[data-id="${e}"]`);if(t){const e=t.querySelector(this.checkboxSelector);e&&(e.checked=!1)}})),this.updateSelectionUI(),this.notify("item-deselected",{selectedItem:id,selectedItems:this.selectedItems,container:this.container})}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.container&&(this.container.removeEventListener("click",this.clickHandler),this.container.removeEventListener("change",this.changeHandler),this.container.removeEventListener("keydown",this.keyHandler)),this.clearSelection(),this.subscribers.clear(),this.container=null,this.ui=null,this.lastSelected=null}};
assets/js/min/integrations.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.initElements(),this.initListeners(),this.init()}initElements(){this.selectors={form:"form.integration",action:"data-action"};let e=document.querySelectorAll(this.selectors.form);this.forms=new Map,e.forEach((e=>{this.forms.set(e.dataset.service,e)}))}initListeners(){this.handleClick=this.clickHandler.bind(this),this.handleChange=this.changeHandler.bind(this),this.handleSubmit=this.submitHandler.bind(this),document.addEventListener("click",this.handleClick),document.addEventListener("change",this.handleChange),document.addEventListener("submit",this.handleSubmit)}init(){document.addEventListener("DOMContentLoaded",(()=>{this.checkForOAuthMessages()}))}checkForOAuthMessages(){const e=new URLSearchParams(window.location.search),t=e.get("success"),s=e.get("error");if(t){this.showNotification(t,"success",5e3),this.cleanURL();document.querySelectorAll("form.integration").forEach((e=>{this.updateUI(e,"connected")}))}else s&&(this.showNotification(s,"error",8e3),this.cleanURL())}cleanURL(){const e=new URL(window.location);e.searchParams.delete("success"),e.searchParams.delete("error"),window.history.replaceState({},document.title,e.pathname+e.hash)}showNotification(e,t="info",s=5e3){let o=document.querySelector(".integration-status-message");if(!o){o=document.createElement("div"),o.className="integration-status-message";const e=document.querySelector(".integration-settings")||document.querySelector("main")||document.body;e.insertBefore(o,e.firstChild)}o.textContent=e,o.className=`integration-status-message ${t}`,this.notificationTimeout&&clearTimeout(this.notificationTimeout),s>0&&(this.notificationTimeout=setTimeout((()=>{o.className="integration-status-message",o.textContent=""}),s)),this.popup&&this.addPopup(e,s)}addPopup(e,t=2e3){this.popup||(this.popup=document.querySelector(".integration-popup")||this.createPopupElement()),this.popup.textContent=e,this.popup.classList.add("showing"),setTimeout((()=>{this.popup.classList.remove("showing")}),t)}createPopupElement(){const e=document.createElement("div");return e.className="integration-popup",document.body.appendChild(e),e}clickHandler(e){if(e.target.closest(this.selectors.form)&&("BUTTON"===e.target.tagName||e.target.closest("button"))){e.preventDefault();let t="BUTTON"===e.target.tagName?e.target:e.target.closest("button");this.handleAction(t)}}changeHandler(e){if(e.target.closest(this.selectors.form))if("action"in e.target.dataset)this.handleAction(e.target);else{let t=this.getFormFromTarget(e.target);if(!t)return;t.classList.add("hasChanges"),t.querySelector(".setup .text").textContent="Unsaved Changes"}}submitHandler(e){e.target.closest(this.selectors.form)&&e.preventDefault()}getFormFromTarget(e){let t=e.closest("form")?.dataset.service;return this.forms.get(t)??!1}handleOAuthClick(e){const t=e.dataset.service,s=e.href,o=(screen.width-600)/2,i=(screen.height-700)/2;this.showNotification("Opening authorization window...","info"),e.classList.add("loading"),e.setAttribute("aria-busy","true");const n=window.open(s,"oauth_"+t,`width=600,height=700,left=${o},top=${i},toolbar=no,menubar=no,location=yes,status=yes,resizable=yes`);if(!n)return this.showNotification("Popup was blocked. Please allow popups and try again.","error"),e.classList.remove("loading"),e.removeAttribute("aria-busy"),!0;n.focus(),this.showNotification("Waiting for authorization...","info");const a=setInterval((()=>{try{n.closed&&(clearInterval(a),e.classList.remove("loading"),e.removeAttribute("aria-busy"),this.showNotification("Checking authorization status...","info"),setTimeout((()=>{this.checkForOAuthMessages(),setTimeout((()=>{const e=new URLSearchParams(window.location.search);e.has("success")||e.has("error")||window.location.reload()}),500)}),500))}catch(e){}}),500);return setTimeout((()=>{clearInterval(a),e.classList.remove("loading"),e.removeAttribute("aria-busy")}),3e5),!1}async handleAction(e){const t=e.closest("form"),s=t.dataset.service,o=e.dataset.action,i="BUTTON"===e.tagName,n=i&&"save_credentials"===o;if(!("confirm"in e.dataset)||confirm(e.dataset.confirm)){this.updateUI(t,"syncing");try{this.updateUI(t,"syncing");const a={service:s,action:o,user_id:window.auth.getUser(),data:{}};if(i||(a.data[e.name.replace(s+"_","")]=e.value),n){const e=new FormData(t);for(let[t,o]of e.entries())["service"].includes(t)||t.includes("nonce")||(a.data[t.replace(s+"_","")]=o)}const r=await fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(a)}),c=await r.json();if(r.ok&&c.success){let e="connected";switch(o){case"clear_credentials":e="disconnected";break;case"save_credentials":this.showNotification("Settings saved successfully","success")}this.updateUI(t,e),c.reload&&setTimeout((()=>{window.location.reload()}),50)}else this.updateUI(t,"error",c.message??""),this.showNotification(c.message||"Operation failed","error")}catch(e){this.updateUI(t,"error"),this.showNotification("Network error: "+e.message,"error"),console.error("API Error:",e)}}}updateUI(e,t,s=""){let o=["connected","disconnected","hasChanges","syncing","error"];if(!o.includes(t))return;s=""===s?{connected:"Set Up",disconnected:"Not Set Up",hasChanges:"Unsaved Changes",syncing:"Testing changes",error:"Something went wrong"}[t]:s,"syncing"===t?e.querySelectorAll("button").forEach((e=>{e.disabled=!0})):e.querySelectorAll("button[disabled]").forEach((e=>{e.disabled=!1})),e.classList.remove(...o),e.classList.add(t,"flash"),e.querySelector(".setup .text").textContent=s,"syncing"===t?e.querySelectorAll("button").forEach((e=>e.disabled=!0)):e.querySelectorAll("button:disabled").forEach((e=>e.disabled=!1)),setTimeout((()=>e.classList.remove("flash")),600)}}window.jvbOAuthPopup=function(e,t){const s=(window.screen.width-600)/2,o=(window.screen.height-700)/2;e+=(e.indexOf("?")>-1?"&":"?")+"popup=1";const i=window.open(e,t+"-oauth",`width=600,height=700,left=${s},top=${o},scrollbars=yes,resizable=yes,toolbar=no,menubar=no`);if(!i)return alert("Please allow popups for this site to complete the authorization process."),!1;window.jvbOAuthComplete=function(e,s,o){if(e===t)if(s){const e=document.querySelector(`.integration-card[data-service="${t}"] .setup .text`);e&&(e.textContent="Connection successful! Refreshing..."),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3)}else alert("OAuth authorization failed: "+(o||"Unknown error")),jvbRefreshIntegration(t)};const n=setInterval((()=>{try{i.closed&&(clearInterval(n),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3))}catch(e){}}),1e3);return!1},window.jvbRefreshIntegration=function(e){fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({service:e,action:"check_oauth_status"})}).then((e=>e.json())).then((t=>{if(console.log("OAuth status check result:",t),t.success&&t.authorized){const t=document.querySelector(`.integration-card[data-service="${e}"]`);if(t){t.classList.remove("disconnected"),t.classList.add("connected");const e=t.querySelector(".setup .text");e&&(e.textContent="Connected"),setTimeout((()=>{location.reload()}),1500)}}else location.reload()})).catch((e=>{console.error("Error checking OAuth status:",e),location.reload()}))},document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.integrations=new e)}))}))})();
(()=>{class e{constructor(){this.initElements(),this.initListeners(),this.init()}initElements(){this.selectors={form:"form.integration",action:"data-action"};let e=document.querySelectorAll(this.selectors.form);this.forms=new Map,e.forEach((e=>{this.forms.set(e.dataset.service,e)}))}initListeners(){this.handleClick=this.clickHandler.bind(this),this.handleChange=this.changeHandler.bind(this),this.handleSubmit=this.submitHandler.bind(this),document.addEventListener("click",this.handleClick),document.addEventListener("change",this.handleChange),document.addEventListener("submit",this.handleSubmit)}init(){document.addEventListener("DOMContentLoaded",(()=>{this.checkForOAuthMessages()}))}checkForOAuthMessages(){const e=new URLSearchParams(window.location.search),t=e.get("success"),s=e.get("error");if(t){this.showNotification(t,"success",5e3),this.cleanURL();document.querySelectorAll("form.integration").forEach((e=>{this.updateUI(e,"connected")}))}else s&&(this.showNotification(s,"error",8e3),this.cleanURL())}cleanURL(){const e=new URL(window.location);e.searchParams.delete("success"),e.searchParams.delete("error"),window.history.replaceState({},document.title,e.pathname+e.hash)}showNotification(e,t="info",s=5e3){let n=document.querySelector(".integration-status-message");if(!n){n=document.createElement("div"),n.className="integration-status-message";const e=document.querySelector(".integration-settings")||document.querySelector("main")||document.body;e.insertBefore(n,e.firstChild)}n.textContent=e,n.className=`integration-status-message ${t}`,this.notificationTimeout&&clearTimeout(this.notificationTimeout),s>0&&(this.notificationTimeout=setTimeout((()=>{n.className="integration-status-message",n.textContent=""}),s)),this.popup&&this.addPopup(e,s)}addPopup(e,t=2e3){this.popup||(this.popup=document.querySelector(".integration-popup")||this.createPopupElement()),this.popup.textContent=e,this.popup.classList.add("showing"),setTimeout((()=>{this.popup.classList.remove("showing")}),t)}createPopupElement(){const e=document.createElement("div");return e.className="integration-popup",document.body.appendChild(e),e}clickHandler(e){if(e.target.closest(this.selectors.form)&&("BUTTON"===e.target.tagName||e.target.closest("button"))){e.preventDefault();let t="BUTTON"===e.target.tagName?e.target:e.target.closest("button");this.handleAction(t)}}changeHandler(e){if(e.target.closest(this.selectors.form))if("action"in e.target.dataset)this.handleAction(e.target);else{let t=this.getFormFromTarget(e.target);if(!t)return;t.classList.add("hasChanges"),t.querySelector(".setup .text").textContent="Unsaved Changes"}}submitHandler(e){e.target.closest(this.selectors.form)&&e.preventDefault()}getFormFromTarget(e){let t=e.closest("form")?.dataset.service;return this.forms.get(t)??!1}async handleAction(e){const t=e.closest("form"),s=t.dataset.service,n=e.dataset.action,o="BUTTON"===e.tagName,i=o&&"save_credentials"===n;if(!("confirm"in e.dataset)||confirm(e.dataset.confirm)){this.updateUI(t,"syncing");try{this.updateUI(t,"syncing");const a={service:s,action:n,user_id:window.auth.getUser(),data:{}};if(o||(a.data[e.name.replace(s+"_","")]=e.value),i){const e=new FormData(t);for(let[t,n]of e.entries())["service"].includes(t)||t.includes("nonce")||(a.data[t.replace(s+"_","")]=n)}const r=await fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(a)}),c=await r.json();if(r.ok&&c.success){let e="connected";switch(n){case"clear_credentials":e="disconnected";break;case"save_credentials":this.showNotification("Settings saved successfully","success")}this.updateUI(t,e),c.reload&&setTimeout((()=>{window.location.reload()}),50)}else this.updateUI(t,"error",c.message??""),this.showNotification(c.message||"Operation failed","error")}catch(e){this.updateUI(t,"error"),this.showNotification("Network error: "+e.message,"error"),console.error("API Error:",e)}}}updateUI(e,t,s=""){let n=["connected","disconnected","hasChanges","syncing","error"];if(!n.includes(t))return;s=""===s?{connected:"Set Up",disconnected:"Not Set Up",hasChanges:"Unsaved Changes",syncing:"Testing changes",error:"Something went wrong"}[t]:s,"syncing"===t?e.querySelectorAll("button").forEach((e=>{e.disabled=!0})):e.querySelectorAll("button[disabled]").forEach((e=>{e.disabled=!1})),e.classList.remove(...n),e.classList.add(t,"flash"),e.querySelector(".setup .text").textContent=s,"syncing"===t?e.querySelectorAll("button").forEach((e=>e.disabled=!0)):e.querySelectorAll("button:disabled").forEach((e=>e.disabled=!1)),setTimeout((()=>e.classList.remove("flash")),600)}}window.jvbOAuthPopup=function(e,t){const s=(window.screen.width-600)/2,n=(window.screen.height-700)/2;e+=(e.indexOf("?")>-1?"&":"?")+"popup=1";const o=window.open(e,t+"-oauth",`width=600,height=700,left=${s},top=${n},scrollbars=yes,resizable=yes,toolbar=no,menubar=no`);if(!o)return alert("Please allow popups for this site to complete the authorization process."),!1;window.jvbOAuthComplete=function(e,s,n){if(e===t)if(s){const e=document.querySelector(`.integration-card[data-service="${t}"] .setup .text`);e&&(e.textContent="Connection successful! Refreshing..."),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3)}else alert("OAuth authorization failed: "+(n||"Unknown error")),jvbRefreshIntegration(t)};const i=setInterval((()=>{try{o.closed&&(clearInterval(i),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3))}catch(e){}}),1e3);return!1},window.jvbRefreshIntegration=function(e){fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({service:e,action:"check_oauth_status"})}).then((e=>e.json())).then((t=>{if(console.log("OAuth status check result:",t),t.success&&t.authorized){const t=document.querySelector(`.integration-card[data-service="${e}"]`);if(t){t.classList.remove("disconnected"),t.classList.add("connected");const e=t.querySelector(".setup .text");e&&(e.textContent="Connected"),setTimeout((()=>{location.reload()}),1500)}}else location.reload()})).catch((e=>{console.error("Error checking OAuth status:",e),location.reload()}))},document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.integrations=new e)}))}))})();
assets/js/min/navigation.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,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.hoverOnListener),e.addEventListener("mouseleave",this.hoverOffListener));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.navs.set(t,a),this.counter++}))}navIDs(){return Array.from(this.navs.keys()).map((e=>`#${e}`))}initListeners(){this.clickListener=this.handleClick.bind(this),this.escapeListener=this.handleEscape.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;this.openNav&&null===e.target.closest(`#${this.openNav}`)&&this.toggleNav(!1,this.openNav);let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav");this.toggleNav(!e.classList.contains("open"),e.id)}let s=e.target.closest('[data-action="toggle-submenu"]');if(s){let e=s.closest("li");this.toggleSubmenu(!e.classList.contains("open"),e)}}handleHoverOn(e){let t=e.target.closest("nav");t&&this.toggleNav(!0,t.id);let s=e.target.closest(".has-submenu");s&&this.toggleSubmenu(!0,s)}handleHoverOff(e){let t=e.target.closest("nav");t&&this.toggleNav(!1,t.id)}handleEscape(e){this.openNav&&"Escape"===e.key&&this.toggleNav(!1,this.openNav)}toggleNav(e,t){let s=this.navs.get(t);s&&(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.escapeListener)):(this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.escapeListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}))),s.nav.ariaExpanded=e,s.nav.classList.toggle("open",e),s.ariaHidden=!e,e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus())}toggleSubmenu(e,t){let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];t.classList.toggle("open",e),t.ariaHidden=!e,s.ariaExpanded=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.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.hoverOnListener),e.addEventListener("mouseleave",this.hoverOffListener));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.navs.set(t,a),this.counter++}))}initListeners(){this.clickListener=this.handleClick.bind(this),this.escapeListener=this.handleEscape.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;this.openNav&&null===e.target.closest(`#${this.openNav}`)&&this.toggleNav(!1,this.openNav);let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav");this.toggleNav(!e.classList.contains("open"),e.id)}let s=e.target.closest('[data-action="toggle-submenu"]');if(s){let e=s.closest("li");this.toggleSubmenu(!e.classList.contains("open"),e)}}handleHoverOn(e){let t=e.target.closest("nav");t&&this.toggleNav(!0,t.id);let s=e.target.closest(".has-submenu");s&&this.toggleSubmenu(!0,s)}handleHoverOff(e){let t=e.target.closest("nav");t&&this.toggleNav(!1,t.id)}handleEscape(e){this.openNav&&"Escape"===e.key&&this.toggleNav(!1,this.openNav)}toggleNav(e,t){let s=this.navs.get(t);s&&(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.escapeListener)):(this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.escapeListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}))),s.nav.ariaExpanded=e,s.nav.classList.toggle("open",e),s.ariaHidden=!e,e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus())}toggleSubmenu(e,t){let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];t.classList.toggle("open",e),t.ariaHidden=!e,s.ariaExpanded=e,e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbNav=new e}))})();
assets/js/min/populate.min.js
@@ -1 +1 @@
window.jvbPopulate=class{constructor(e,t={},a={},r={}){for(let[l,o]of Object.entries(t)){let t=e.querySelector(`[data-field="${l}"]`);t&&this.populateField(t,l,o,a,r)}}populateField(e,t,a,r={},l={}){if(e&&null!=a)switch(this.getFieldType(e)){case"upload":case"gallery":case"image":this.populateUploadField(e,t,a,r);break;case"repeater":this.populateRepeaterField(e,t,a,l);break;case"taxonomy":this.populateTaxonomyField(e,t,a);break;case"user":this.populateUserField(e,t,a);break;case"location":this.populateLocationField(e,t,a);break;case"set":case"checkbox":this.populateSetField(e,t,a);break;case"select":case"radio":this.populateSelectField(e,t,a);break;case"true_false":this.populateBooleanField(e,t,a);break;case"date":case"time":case"datetime":this.populateDateField(e,t,a);break;case"number":this.populateNumberField(e,t,a);break;case"textarea":e.querySelector(".editor-container")?this.populateEditorField(e,t,a):this.populateTextareaField(e,t,a);break;default:this.populateTextField(e,t,a)}}getFieldType(e){if(e.dataset.fieldType)return e.dataset.fieldType;if(e.dataset.type)return e.dataset.type;const t=["upload","repeater","taxonomy","user","location","set","checkbox","select","radio","true_false","date","time","datetime","editor","number","text","textarea","email","url","tel","phone"];for(const a of t)if(e.classList.contains(a))return a;const a=e.querySelector("input, select, textarea");if(a){if("TEXTAREA"===a.tagName)return"true"===a.dataset.editor?"editor":"textarea";if(a.type)return"checkbox"!==a.type||e.classList.contains("true_false")?a.type:"set"}return"text"}populateTextField(e,t,a){const r=e.querySelector(`[name="${t}"], input, textarea`);if((!r||"file"!==r.type)&&r&&(r.value=String(a||""),r.dataset.limit)){const t=e.querySelector(".char-count .current");t&&(t.textContent=r.value.length)}}populateTextareaField(e,t,a){const r=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea:not([data-editor="true"])');if(r){if(r.value,r.value=String(a||""),r.dispatchEvent(new Event("change",{bubbles:!0})),r.dataset.limit){const t=e.querySelector(".char-count .current");if(t){t.textContent=r.value.length;const a=parseInt(r.dataset.limit,10);r.value.length>=a?e.classList.add("reached"):e.classList.remove("reached")}}}else console.warn(`No textarea found for field ${t} in wrapper:`,e)}populateNumberField(e,t,a){const r=e.querySelector(`[name="${t}"], input[type="number"]`);r&&(r.value=Number(a)||0)}populateBooleanField(e,t,a){const r=e.querySelector(`[name="${t}"], input[type="checkbox"]`);r&&(r.checked=Boolean(a))}populateSelectField(e,t,a){const r=String(a||""),l=e.querySelector(`select[name="${t}"]`);if(l)return void(l.value=r);const o=e.querySelector(`input[type="radio"][name="${t}"][value="${r}"]`);o&&(o.checked=!0)}populateSetField(e,t,a){let r=a;if("string"==typeof a)try{r=JSON.parse(a)}catch(e){r=a.split(",").map((e=>e.trim()))}Array.isArray(r)||(r=[String(r)]),e.querySelectorAll(`input[type="checkbox"][name*="${t}"]`).forEach((e=>{e.checked=r.includes(e.value)}))}populateDateField(e,t,a){const r=e.querySelector(`[name="${t}"], input`);if(r&&a){let e=a;"object"==typeof a&&a.date&&(e=a.date);try{const t=new Date(e);if(!isNaN(t.getTime()))switch(r.type){case"date":r.value=t.toISOString().split("T")[0];break;case"time":r.value=t.toTimeString().slice(0,5);break;case"datetime-local":r.value=t.toISOString().slice(0,16);break;default:r.value=e}}catch(t){r.value=e}}}populateEditorField(e,t,a){const r=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea[data-editor="true"]')||e.querySelector("textarea");if(!r)return void console.warn(`Editor field ${t}: textarea not found`);const l=String(a||"");r.value=l;const o=e.querySelector(".editor");if(o){let e=null;if(o.__quill)e=o.__quill;else if(o.quill)e=o.quill;else if(window.Quill&&window.Quill.find)e=window.Quill.find(o);else if(window.Quill&&window.Quill.instances)for(let t of window.Quill.instances)if(t.container===o){e=t;break}e?(e.root.innerHTML=l,o.__quill=e):(console.warn(`Quill instance not found for ${t}, setting HTML directly`),o.innerHTML=l)}else console.warn(`Editor container not found for ${t}`);r.dispatchEvent(new Event("change",{bubbles:!0}))}populateLocationField(e,t,a){a&&"object"==typeof a&&["address","lat","lng","street","city","province","postal_code","country"].forEach((r=>{if(void 0!==a[r]){const l=e.querySelector(`[name="${t}_${r}"], [name="${r}"]`);l&&(l.value=String(a[r]||""))}}))}populateTaxonomyField(e,t,a){let r=[];if(Array.isArray(a))r=a.map((e=>String(e)));else if("string"==typeof a)try{const e=JSON.parse(a);r=Array.isArray(e)?e.map((e=>String(e))):[String(e)]}catch(e){r=a.split(",").map((e=>e.trim()))}else a&&(r=[String(a)]);if(0===r.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=r.join(","))}populateUserField(e,t,a){this.populateTaxonomyField(e,t,a)}populateUploadField(e,t,a,r={}){if("timeline"===e.dataset.subtype||"timeline"===t)return void this.populateTimelineGallery(e,t,a,r);if(!a)return;const l=String(a).split(",").filter((e=>parseInt(e.trim())));if(0===l.length)return;const o=e.querySelector(`input[type="hidden"][name="${t}"]`);o&&(o.value=l.join(","));const i=e.querySelector(".item-grid"),n=e.querySelector(".file-upload-container");e.querySelector(".progress")?.remove(),i&&(window.removeChildren(i),l.forEach((e=>{const t=window.getTemplate("uploadItem");if(!t)return void console.warn("uploadItem template not found");let a=t.querySelector('input[name="select-item"]'),l=t.querySelector('label[for="select-item"]');t.dataset.id=e,a.name=a.name+`-${e}`,a.id=a.name,l.htmlFor=a.name;const o=t.querySelector("img");t.querySelector("video").remove();const n=t.querySelector("details");if(r[e]){const a=r[e];o&&(o.src=a.medium||a.small||a.large||"",o.alt=a["image-alt-text"]||a.alt||"");const l=t.querySelector('[name="image-title"]'),i=t.querySelector('[name="image-alt-text"]'),n=t.querySelector('[name="image-caption"]');l&&(l.value=a["image-title"]||a.title||""),i&&(i.value=a["image-alt-text"]||a.alt||""),n&&(n.value=a["image-caption"]||a.caption||"")}else console.warn("No image data found for ID:",e);n?.querySelector(".upload-meta > .hint")?.remove(),i.append(t)})),l.length>0&&n&&(n.hidden=!0))}populateTimelineGallery(e,t,a,r){if(!a||"object"!=typeof a)return;const l=Object.values(a);if(0===l.length)return;const o=e.querySelector(".item-grid"),i=e.querySelector(".file-upload-container");if(e.querySelector(".progress")?.remove(),o){window.removeChildren(o),console.log(l);for(let[e,t]of Object.entries(a)){let e=t.post_thumbnail;const a=window.getTemplate("timelineItem");if(!a)return;const l=a.querySelector("img");a.querySelector("video")?.remove(),a.querySelector(".select-item span")?.remove();let i=a.querySelector('input[name="select-item"]'),n=a.querySelector('label[for="select-item"]');a.dataset.id=e,a.dataset.postId=t.id,i.name=i.name+`-${e}`,i.id=i.name,n.htmlFor=i.name;const c=r[e];l&&c&&(l.src=c.medium||c.small||c.large||"",l.title=c["image-title"]);const s={...t,...c};a.querySelectorAll(".field").forEach((e=>{if(e.classList.contains("group"))return;const a=e.querySelector('input:not([type="file"]), textarea');if(!a)return;const l=e.querySelector("label"),o=a.name.replace("upload_data::",""),i=s[o];this.populateField(e,o,i,r);const n=`[${t.id}]${o}`;a.name=n,a.id=n,l&&(l.htmlFor=n)})),o.append(a)}l.length>0&&i&&(i.hidden=!0)}}populateRepeaterField(e,t,a,r={}){if(!a||!Array.isArray(a))return;const l=e.querySelector(".repeater-items"),o=e.querySelector("template");l&&o?(window.removeChildren(l),a.forEach(((a,r)=>{if(!a||"object"!=typeof a)return;const i=window.getTemplate(o.className);if(!i)return void console.warn(`Repeater field ${t}: template not found`);i.id=`${e.closest("form").id}-${t}-row-${r}`,i.dataset.index=r;const n=i.querySelector(".row-number");n&&(n.textContent=`#${r+1}`),i.querySelectorAll("input, select, textarea").forEach((e=>{const l=e.name,o=`${t}:${r}:${l}`,i=`${t}-${r}-${l}-${e.value}`;e.name=o,e.id=i;const n=e.nextElementSibling;n&&"LABEL"===n.tagName&&(n.htmlFor=i),void 0!==a[l]&&this.populateRepeaterFieldValue(e,l,a[l])})),l.appendChild(i)}))):console.warn(`Repeater field ${t}: missing container or template`)}populateRepeaterFieldValue(e,t,a){switch(e.type){case"checkbox":e.checked=Boolean(a);break;case"radio":e.checked=e.value===String(a);break;default:e.value=String(a||"")}}};
window.jvbPopulate=class{constructor(e,t={},a={},l={}){for(let[r,o]of Object.entries(t)){let t=e.querySelector(`[data-field="${r}"]`);t&&this.populateField(t,r,o,a,l)}}populateField(e,t,a,l={},r={}){if(e&&null!=a)switch(this.getFieldType(e)){case"upload":case"gallery":case"image":this.populateUploadField(e,t,a,l);break;case"repeater":this.populateRepeaterField(e,t,a,r);break;case"taxonomy":this.populateTaxonomyField(e,t,a);break;case"user":this.populateUserField(e,t,a);break;case"location":this.populateLocationField(e,t,a);break;case"set":case"checkbox":this.populateSetField(e,t,a);break;case"select":case"radio":this.populateSelectField(e,t,a);break;case"true_false":this.populateBooleanField(e,t,a);break;case"date":case"time":case"datetime":this.populateDateField(e,t,a);break;case"number":this.populateNumberField(e,t,a);break;case"textarea":e.querySelector(".editor-container")?this.populateEditorField(e,t,a):this.populateTextareaField(e,t,a);break;default:this.populateTextField(e,t,a)}}getFieldType(e){if(e.dataset.fieldType)return e.dataset.fieldType;if(e.dataset.type)return e.dataset.type;const t=["upload","repeater","taxonomy","user","location","set","checkbox","select","radio","true_false","date","time","datetime","editor","number","text","textarea","email","url","tel","phone"];for(const a of t)if(e.classList.contains(a))return a;const a=e.querySelector("input, select, textarea");if(a){if("TEXTAREA"===a.tagName)return"true"===a.dataset.editor?"editor":"textarea";if(a.type)return"checkbox"!==a.type||e.classList.contains("true_false")?a.type:"set"}return"text"}populateTextField(e,t,a){const l=e.querySelector(`[name="${t}"], input, textarea`);if((!l||"file"!==l.type)&&l&&(l.value=String(a||""),l.dataset.limit)){const t=e.querySelector(".char-count .current");t&&(t.textContent=l.value.length)}}populateTextareaField(e,t,a){const l=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea:not([data-editor="true"])');if(l){if(l.value=String(a||""),l.dispatchEvent(new Event("change",{bubbles:!0})),l.dataset.limit){const t=e.querySelector(".char-count .current");if(t){t.textContent=l.value.length;const a=parseInt(l.dataset.limit,10);l.value.length>=a?e.classList.add("reached"):e.classList.remove("reached")}}}else console.warn(`No textarea found for field ${t} in wrapper:`,e)}populateNumberField(e,t,a){const l=e.querySelector(`[name="${t}"], input[type="number"]`);l&&(l.value=Number(a)||0)}populateBooleanField(e,t,a){const l=e.querySelector(`[name="${t}"], input[type="checkbox"]`);l&&(l.checked=Boolean(a))}populateSelectField(e,t,a){const l=String(a||""),r=e.querySelector(`select[name="${t}"]`);if(r)return void(r.value=l);const o=e.querySelector(`input[type="radio"][name="${t}"][value="${l}"]`);o&&(o.checked=!0)}populateSetField(e,t,a){let l=a;if("string"==typeof a)try{l=JSON.parse(a)}catch(e){l=a.split(",").map((e=>e.trim()))}Array.isArray(l)||(l=[String(l)]),e.querySelectorAll(`input[type="checkbox"][name*="${t}"]`).forEach((e=>{e.checked=l.includes(e.value)}))}populateDateField(e,t,a){const l=e.querySelector(`[name="${t}"], input`);if(l&&a){let e=a;"object"==typeof a&&a.date&&(e=a.date);try{const t=new Date(e);if(!isNaN(t.getTime()))switch(l.type){case"date":l.value=t.toISOString().split("T")[0];break;case"time":l.value=t.toTimeString().slice(0,5);break;case"datetime-local":l.value=t.toISOString().slice(0,16);break;default:l.value=e}}catch(t){l.value=e}}}populateEditorField(e,t,a){const l=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea[data-editor="true"]')||e.querySelector("textarea");if(!l)return void console.warn(`Editor field ${t}: textarea not found`);const r=String(a||"");l.value=r;const o=e.querySelector(".editor");if(o){let e=null;if(o.__quill)e=o.__quill;else if(o.quill)e=o.quill;else if(window.Quill&&window.Quill.find)e=window.Quill.find(o);else if(window.Quill&&window.Quill.instances)for(let t of window.Quill.instances)if(t.container===o){e=t;break}e?(e.root.innerHTML=r,o.__quill=e):(console.warn(`Quill instance not found for ${t}, setting HTML directly`),o.innerHTML=r)}else console.warn(`Editor container not found for ${t}`);l.dispatchEvent(new Event("change",{bubbles:!0}))}populateLocationField(e,t,a){a&&"object"==typeof a&&["address","lat","lng","street","city","province","postal_code","country"].forEach((l=>{if(void 0!==a[l]){const r=e.querySelector(`[name="${t}_${l}"], [name="${l}"]`);r&&(r.value=String(a[l]||""))}}))}populateTaxonomyField(e,t,a){let l=[];if(Array.isArray(a))l=a.map((e=>String(e)));else if("string"==typeof a)try{const e=JSON.parse(a);l=Array.isArray(e)?e.map((e=>String(e))):[String(e)]}catch(e){l=a.split(",").map((e=>e.trim()))}else a&&(l=[String(a)]);if(0===l.length)return;const r=e.querySelector(`input[type="hidden"][name="${t}"]`);r&&(r.value=l.join(","))}populateUserField(e,t,a){this.populateTaxonomyField(e,t,a)}populateUploadField(e,t,a,l={}){if("timeline"===e.dataset.subtype||"timeline"===t)return void this.populateTimelineGallery(e,t,a,l);if(!a)return;const r=String(a).split(",").filter((e=>parseInt(e.trim())));if(0===r.length)return;const o=e.querySelector(`input[type="hidden"][name="${t}"]`);o&&(o.value=r.join(","));const i=e.querySelector(".item-grid"),n=e.querySelector(".file-upload-container");e.querySelector(".progress")?.remove(),i&&(window.removeChildren(i),r.forEach((e=>{const t=window.getTemplate("uploadItem");if(!t)return void console.warn("uploadItem template not found");let a=t.querySelector('input[name="select-item"]'),r=t.querySelector('label[for="select-item"]');t.dataset.id=e,a.name=a.name+`-${e}`,a.id=a.name,r.htmlFor=a.name;const o=t.querySelector("img");t.querySelector("video").remove();const n=t.querySelector("details");if(l[e]){const a=l[e];o&&(o.src=a.medium||a.small||a.large||"",o.alt=a["image-alt-text"]||a.alt||"");const r=t.querySelector('[name="image-title"]'),i=t.querySelector('[name="image-alt-text"]'),n=t.querySelector('[name="image-caption"]');r&&(r.value=a["image-title"]||a.title||""),i&&(i.value=a["image-alt-text"]||a.alt||""),n&&(n.value=a["image-caption"]||a.caption||"")}else console.warn("No image data found for ID:",e);n?.querySelector(".upload-meta > .hint")?.remove(),i.append(t)})),r.length>0&&n&&(n.hidden=!0))}populateTimelineGallery(e,t,a,l){if(console.log("populating Timeline Gallery"),!a||"object"!=typeof a)return;const r=Object.values(a);if(0===r.length)return;const o=e.querySelector(".item-grid"),i=e.querySelector(".file-upload-container");if(e.querySelector(".progress")?.remove(),o){window.removeChildren(o),console.log(r);for(let e of Object.entries(a)){let t=e.post_thumbnail;const a=window.getTemplate("timelineItem");if(!a)return;const r=a.querySelector("img");a.querySelector("video")?.remove(),a.querySelector(".select-item span")?.remove();let i=a.querySelector('input[name="select-item"]'),n=a.querySelector('label[for="select-item"]');a.dataset.id=t,a.dataset.postId=e.id,i.name=i.name+`-${t}`,i.id=i.name,n.htmlFor=i.name;const c=l[t];r&&c&&(r.src=c.medium||c.small||c.large||"",r.title=c["image-title"]);const s={...e,...c};a.querySelectorAll(".field").forEach((t=>{if(t.classList.contains("group"))return;const a=t.querySelector('input:not([type="file"]), textarea');if(!a)return;const r=t.querySelector("label"),o=a.name.replace("upload_data::",""),i=s[o];this.populateField(t,o,i,l);const n=`[${e.id}]${o}`;a.name=n,a.id=n,r&&(r.htmlFor=n)})),o.append(a)}r.length>0&&i&&(i.hidden=!0)}}populateRepeaterField(e,t,a){if(!a||!Array.isArray(a))return;const l=e.querySelector(".repeater-items"),r=e.querySelector("template");l&&r?(window.removeChildren(l),a.forEach(((a,o)=>{if(!a||"object"!=typeof a)return;const i=window.getTemplate(r.className);if(!i)return void console.warn(`Repeater field ${t}: template not found`);i.id=`${e.closest("form").id}-${t}-row-${o}`,i.dataset.index=o;const n=i.querySelector(".row-number");n&&(n.textContent=`#${o+1}`),i.querySelectorAll("input, select, textarea").forEach((e=>{const l=e.name,r=`${t}:${o}:${l}`,i=`${t}-${o}-${l}-${e.value}`;e.name=r,e.id=i;const n=e.nextElementSibling;n&&"LABEL"===n.tagName&&(n.htmlFor=i),void 0!==a[l]&&this.populateRepeaterFieldValue(e,l,a[l])})),l.appendChild(i)}))):console.warn(`Repeater field ${t}: missing container or template`)}populateRepeaterFieldValue(e,t,a){switch(e.type){case"checkbox":e.checked=Boolean(a);break;case"radio":e.checked=e.value===String(a);break;default:e.value=String(a||"")}}};
assets/js/min/queue.min.js
@@ -1 +1 @@
(()=>{class e{constructor(e={}){if(this.canUpdateUI=!0,this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...e},this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.user=window.auth.getUser(),!this.user)return console.log("Queue: User not logged in, queue disabled"),this.store=null,void(this.canUpdateUI=!1);this.headers={"X-WP-Nonce":window.auth.getNonce(),...e.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError;const t=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,delayFetch:!1});this.store=t.queue,this.classes=["offline","synced","pending"],this.initUI(),this.initListeners(),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.updateUI=()=>window.debouncer.schedule("queue-ui-update",this._updateUI.bind(this),100),this.initQueue()}async initQueue(){const e=this.getOperationsByStatus(["completed","failed_permanent"],!1);e.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-saved":this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&this.startPolling(),this.updateUI();break;case"item-saved":if(t.item){const e=this.store.data.get(t.item.id);e&&e.status!==t.item.status&&this.handleOperationStatusChange(t.item,e.status)}this.hasQueuedOperations()&&this.startPolling();break;default:this.updateUI()}})),this.notify("queue-initialized",{operations:e})}handleOperationStatusChange(e,t){if(e&&t!==e.status)switch(e.status){case"completed":this.notify("operation-completed",e);break;case"failed":this.notify("operation-failed",e);break;case"failed_permanent":this.notify("operation-failed-permanent",e)}}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}return console.log("Added to Queue: ",t),this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}setQueue(e){this.store.save(e)}updateOperationStatus(e,t){let s=this.store.get(e);s&&(s.status=t,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(e){return this.store.get(e)}clearQueue(e){this.store.get(e);this.store.delete(e)}startActivityTracking(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.lastActivity=Date.now(),this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}hideQueue(){this.ui.panel.hidden=!0,this.ui.toggle.hidden=!0}showQueue(){this.ui.panel.hidden=!1,this.ui.toggle.hidden=!1}setProcessing(e){this.isProcessing=e,this.ui.toggle.classList.toggle("saving",e)}async processQueue(){if(this.isProcessing)return;const e=this.getOperationsByStatus("queued");if(0===e.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0?(this.startPolling(),this.showQueue()):this.hideQueue()}async processOperation(e){try{this.updateOperationStatus(e.id,"uploading"),e.data?._isFormData&&(e.data=await this.store.objectToFormData(e.data));const t=`${this.config.apiBase}${e.endpoint}`;let s;e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),s=e.data):(s=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json");const i=await fetch(t,{method:e.method,headers:e.headers,body:s}),a=await i.json();if(!i.ok||!1===a.success)throw new Error(a.message||`HTTP ${i.status}`);if(a.id&&e.id!==a.id){const t=this.getQueue(a.id);t?(t.data=window.deepMerge(t.data,e.data),t.status=a.status||"pending",t.serverData=a,this.updateOperationStatus(t.id,t.status),this.setQueue(t),this.removeOperationFromUI(e.id),e=t):(this.clearQueue(e.id),e.id=a.id,e.status=a.status||"pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e))}else e.status=a.status||"pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e);this.a11y.announce(`${e.title} sent to server for processing.`)}catch(t){console.error("Operation failed:",t),e.retries++,e.lastError=t.message,e.retries>=this.config.maxRetries?e.status="failed_permanent":(e.status="failed",e.nextRetry=Date.now()+1e3*Math.pow(2,e.retries)),this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{this.store.clearCache(),await this.store.fetch();0===this.getOperationsByStatus(["completed","failed_permanent"],!1).length&&(this.stopPolling(),this.updateStatusPanel("synced"))}catch(e){console.error("Polling error:",e)}}),this.config.pollInterval))}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}async updateServerOperations(e,t){if(0===(e=(e=Array.isArray(e)?e:e.includes(",")?e.split(","):[e]).filter((e=>{let s=this.getQueue(e);return this.getAllowedActions(s.status).includes(t)}))).length)return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>this.removeOperationFromUI(e)));try{const i=await fetch(`${this.config.apiBase}${this.config.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:e,action:t,user:window.auth.getUser()})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const a=await i.json();if(!a.success)throw new Error(a.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);this.notify(`${t}-operation`,i),s?this.clearQueue(e):(i.status="queued",i.retries=0,this.setQueue(i),this.updateOperationStatus(i.id,i.status))})),"retry"===t&&this.startActivityTracking(),this.updateUI(),a}catch(s){return await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.updateServerOperations(e,t))),{success:!1,error:s.message}}}getAllowedActions(e){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[e]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=e=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return e.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(e){if(e.target.closest(this.selectors.panel,this.selectors.toggle))if(e.target.closest(this.selectors.refreshButton))this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch();else if(e.target.closest(this.selectors.clearButton)){const e=this.getOperationsByStatus("completed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"dismiss")}}else if(e.target.closest(this.selectors.retryButton)){const e=this.getOperationsByStatus("failed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"retry")}}else if(e.target.closest("[data-action]")){const t=e.target.closest("[data-action]"),s=t.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,t.dataset.action)}else if(e.target.closest(".filters [data-filter]")){const t=e.target.closest("[data-filter]").dataset.filter;this.setFilter(t)}}initUI(){this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}_updateUI(){if(!this.canUpdateUI)return;const e=Array.from(this.store.data.values()),t=this.store.lastResponse?.queue_stats||{queued:0,localProcessing:0,uploading:0,pending:0,processing:0,completed:0,failed:0,failed_permanent:0};if(this.ui.count){const s=e.length-t.completed;this.ui.count.textContent=s>0?s:"",this.ui.count.style.display=s>0?"":"none"}if(this.ui.indicator){const e=t.queued>0||t.uploading>0||t.pending>0||t.processing>0;this.ui.indicator.classList.toggle("active",e)}this.ui.clearButton.disabled=0===this.getOperationsByStatus("completed").length,this.ui.retryButton.disabled=0===this.getOperationsByStatus("failed").length&&0===this.getOperationsByStatus("failed_permanent").length,Object.entries(this.ui.filters).forEach((([s,i])=>{const a="all"===s?e.length:t[s]||0,n=i.querySelector(".count");n&&(n.textContent=a>0?a:""),i.setAttribute("data-count",a)})),this.renderOperations()}getStatusLabel(e){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[e]||e}getItemMessage(e){if(e.message)return e.message;if(e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}calculateProgress(e){if(e.progress)return e.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]||0}renderOperations(){if(!this.ui.itemsContainer)return;const e=this.store.getFiltered();if(window.removeChildren(this.ui.itemsContainer),0===e.length){let e=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(e),this.a11y.announce("Nothing queued.")}else e.forEach((e=>{const t=this.createOperationUI(e);this.ui.itemsContainer.append(t)}))}createOperationUI(e){const t=window.getTemplate("queueItem");return t.dataset.id=e.id,this.updateOperationUI(e,t),t}updateOperationUI(e,t=null){t||(t=this.ui.itemsContainer?.querySelector(`[data-id="${e.id}"]`)),t||(t=this.createOperationUI(e)),this.statuses.forEach((e=>t.classList.remove(e))),t.classList.add(e.status);let s="";e.updated_at?s=window.formatTimeAgo(new Date(e.updated_at)):e.created_at&&(s=window.formatTimeAgo(new Date(e.created_at)));const i=this.calculateProgress(e),a=t.querySelector(".type"),n=t.querySelector(".status"),r=t.querySelector(".info .details"),o=t.querySelector(".info .time"),d=t.querySelector(".progress .fill");if(a&&(a.textContent=e.title),n){n.querySelector(".icon")?.remove();let t=this.getStatusLabel(e.status);n.title=t,n.prepend(window.getIcon(this.icons[e.status])),n.querySelector("span").textContent=t}r&&(r.textContent=this.getItemMessage(e)),o&&(o.textContent=s),d&&(d.style.width=`${i}%`);const l=t.querySelector(".actions");l&&this.updateActionButtons(e,l)}updateActionButtons(e,t){switch(window.removeChildren(t),e.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",t.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=e.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",t.appendChild(i),t.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",t.appendChild(n)}}removeOperationFromUI(e){const t=this.ui.itemsContainer?.querySelector(`[data-id="${e}"]`);t&&(t.style.opacity="0",t.style.transform="scale(0.9)",setTimeout((()=>t.remove()),300))}updateStatusPanel(e){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(e)&&this.ui.panel?.classList.add(e)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t&&t.classList.toggle("active",t.dataset.filter===e)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}getOperationsByStatus(e,t=!0){return Array.isArray(e)||"string"!=typeof e||(e=[e]),t?Array.from(this.store.data.values()).filter((t=>e.includes(t.status))):Array.from(this.store.data.values()).filter((t=>!e.includes(t.status)))}hasQueuedOperations(){return this.getOperationsByStatus("queued").length>0}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
(()=>{class e{constructor(e={}){if(this.canUpdateUI=!0,this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...e},this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.user=window.auth.getUser(),!this.user)return console.log("Queue: User not logged in, queue disabled"),this.store=null,void(this.canUpdateUI=!1);this.headers={"X-WP-Nonce":window.auth.getNonce(),...e.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError;const t=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,delayFetch:!1});this.store=t.queue,this.classes=["offline","synced","pending"],this.initUI(),this.initListeners(),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.updateUI=()=>window.debouncer.schedule("queue-ui-update",this._updateUI.bind(this),100),this.initQueue()}async initQueue(){this.maybeStartPolling()||this.updateStatusPanel("synced"),this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-saved":this.maybeStartPolling(),this.updateUI();break;case"item-saved":console.log(t,"Item saved data"),t.previousItem&&t.previousItem.status!==t.item.status&&this.handleOperationStatusChange(t.item,t.previousItem.status),this.maybeStartPolling();break;default:this.updateUI()}}))}maybeStartPolling(){return this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&(this.startPolling(),!0)}handleOperationStatusChange(e){switch(e.status){case"completed":console.log(e),this.notify("operation-completed",e);break;case"failed":this.notify("operation-failed",e);break;case"failed_permanent":this.notify("operation-failed-permanent",e)}}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}return console.log("Added to Queue: ",t),this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}setQueue(e){this.store.save(e)}updateOperationStatus(e,t){let s=this.store.get(e);s&&(s.status=t,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(e){return this.store.get(e)}clearQueue(e){this.store.delete(e)}startActivityTracking(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}hideQueue(){this.ui.panel.hidden=!0,this.ui.toggle.hidden=!0}showQueue(){this.ui.panel.hidden=!1,this.ui.toggle.hidden=!1}setProcessing(e){this.isProcessing=e,this.ui.toggle.classList.toggle("saving",e)}async processQueue(){if(this.isProcessing)return;const e=this.getOperationsByStatus("queued");if(0!==e.length){this.setProcessing(!0);for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.maybeStartPolling()?this.showQueue():this.hideQueue()}else this.stopActivityTracking()}async processOperation(e){try{this.updateOperationStatus(e.id,"uploading"),e.data?._isFormData&&(e.data=await this.store.objectToFormData(e.data));const t=`${this.config.apiBase}${e.endpoint}`;let s;e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),s=e.data):(s=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json");const i=await fetch(t,{method:e.method,headers:e.headers,body:s}),a=await i.json();if(!i.ok||!1===a.success)throw new Error(a.message||`HTTP ${i.status}`);a.id&&e.id!==a.id?e=await this.handleServerMerge(e,a):(e.status=a.status||"pending",e.serverData=a,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing.`)}catch(t){console.error("Operation failed:",t),e.retries++,e.lastError=t.message,e.retries>=this.config.maxRetries?e.status="failed_permanent":(e.status="failed",e.nextRetry=Date.now()+1e3*Math.pow(2,e.retries)),this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(s.data=window.deepMerge(s.data,e.data),s.status=t.status||"pending",s.serverData=t,this.updateOperationStatus(s.id,s.status),this.removeOperationFromUI(e.id),this.clearQueue(e.id),s):(this.clearQueue(e.id),e.id=t.id,e.status=t.status||"pending",e.serverData=t,this.updateOperationStatus(e.id,e.status),e)}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{this.store.clearCache(),await this.store.fetch(),this.maybeStartPolling()||(this.stopPolling(),this.updateStatusPanel("synced"))}catch(e){console.error("Polling error:",e)}}),this.config.pollInterval))}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}getOperationIds(e){return e.map((e=>e.id))}async updateServerOperations(e,t){if(0===(e=(e=Array.isArray(e)?e:e.includes(",")?e.split(","):[e]).filter((e=>{let s=this.getQueue(e);return this.getAllowedActions(s.status).includes(t)}))).length)return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>this.removeOperationFromUI(e)));try{const i=await fetch(`${this.config.apiBase}${this.config.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:e,action:t,user:window.auth.getUser()})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const a=await i.json();if(!a.success)throw new Error(a.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);this.notify(`${t}-operation`,i),s?this.clearQueue(e):(i.status="queued",i.retries=0,this.setQueue(i),this.updateOperationStatus(i.id,i.status))})),"retry"===t&&this.startActivityTracking(),this.updateUI(),a}catch(s){return await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.updateServerOperations(e,t))),{success:!1,error:s.message}}}getAllowedActions(e){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[e]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=e=>{if(this.isPolling||this.isProcessing)return e.preventDefault(),"You have unsaved changes in the queue. Proceed?"},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(e){if(e.target.closest(this.selectors.panel,this.selectors.toggle))if(e.target.closest(this.selectors.refreshButton))this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch();else if(e.target.closest(this.selectors.clearButton)){const e=this.getOperationIds(this.getOperationsByStatus("completed"));e.length>0&&this.updateServerOperations(e,"dismiss")}else if(e.target.closest(this.selectors.retryButton)){const e=this.getOperationIds(this.getOperationsByStatus("failed"));e.length>0&&this.updateServerOperations(e,"retry")}else if(e.target.closest("[data-action]")){const t=e.target.closest("[data-action]"),s=t.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,t.dataset.action)}else if(e.target.closest(".filters [data-filter]")){const t=e.target.closest("[data-filter]").dataset.filter;this.setFilter(t)}}initUI(){this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}_updateUI(){if(!this.canUpdateUI)return;const e=Array.from(this.store.data.values()),t=this.store.lastResponse?.queue_stats||{queued:0,localProcessing:0,uploading:0,pending:0,processing:0,completed:0,failed:0,failed_permanent:0};if(this.ui.count){const s=e.length-t.completed;this.ui.count.textContent=s>0?s:"",this.ui.count.style.display=s>0?"":"none"}if(this.ui.indicator){const e=t.queued>0||t.uploading>0||t.pending>0||t.processing>0;this.ui.indicator.classList.toggle("active",e)}this.ui.clearButton.disabled=0===this.getOperationsByStatus("completed").length,this.ui.retryButton.disabled=0===this.getOperationsByStatus("failed").length&&0===this.getOperationsByStatus("failed_permanent").length,Object.entries(this.ui.filters).forEach((([s,i])=>{const a="all"===s?e.length:t[s]||0,n=i.querySelector(".count");n&&(n.textContent=a>0?a:""),i.setAttribute("data-count",a)})),this.renderOperations()}getStatusLabel(e){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[e]||e}getItemMessage(e){if(e.message)return e.message;if(e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}calculateProgress(e){if(e.progress)return e.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]||0}renderOperations(){if(!this.ui.itemsContainer)return;const e=this.store.getFiltered();if(window.removeChildren(this.ui.itemsContainer),0===e.length){let e=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(e),this.a11y.announce("Nothing queued.")}else e.forEach((e=>{const t=this.createOperationUI(e);this.ui.itemsContainer.append(t)}))}createOperationUI(e){const t=window.getTemplate("queueItem");return t.dataset.id=e.id,this.updateOperationUI(e,t),t}updateOperationUI(e,t=null){t||(t=this.ui.itemsContainer?.querySelector(`[data-id="${e.id}"]`)),t||(t=this.createOperationUI(e)),this.statuses.forEach((e=>t.classList.remove(e))),t.classList.add(e.status);let s="";e.updated_at?s=window.formatTimeAgo(new Date(e.updated_at)):e.created_at&&(s=window.formatTimeAgo(new Date(e.created_at)));const i=this.calculateProgress(e),a=t.querySelector(".type"),n=t.querySelector(".status"),r=t.querySelector(".info .details"),o=t.querySelector(".info .time"),l=t.querySelector(".progress .fill");if(a&&(a.textContent=e.title),n){n.querySelector(".icon")?.remove();let t=this.getStatusLabel(e.status);n.title=t,n.prepend(window.getIcon(this.icons[e.status])),n.querySelector("span").textContent=t}r&&(r.textContent=this.getItemMessage(e)),o&&(o.textContent=s),l&&(l.style.width=`${i}%`);const d=t.querySelector(".actions");d&&this.updateActionButtons(e,d)}updateActionButtons(e,t){switch(window.removeChildren(t),e.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",t.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=e.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",t.appendChild(i),t.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",t.appendChild(n)}}removeOperationFromUI(e){const t=this.ui.itemsContainer?.querySelector(`[data-id="${e}"]`);t&&(t.style.opacity="0",t.style.transform="scale(0.9)",setTimeout((()=>t.remove()),300))}updateStatusPanel(e){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(e)&&this.ui.panel?.classList.add(e)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t&&t.classList.toggle("active",t.dataset.filter===e)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}getOperationsByStatus(e,t=!0){return Array.isArray(e)||"string"!=typeof e||(e=[e]),t?Array.from(this.store.data.values()).filter((t=>e.includes(t.status))):Array.from(this.store.data.values()).filter((t=>!e.includes(t.status)))}hasQueuedOperations(){return this.getOperationsByStatus("queued").length>0}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
assets/js/min/quill.min.js
@@ -1 +1 @@
window.jvbQuill=function(t){t.querySelectorAll("textarea[data-editor=true]").forEach((t=>{let n,e,i;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),e=n.querySelector(".editor"),i=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",e=document.createElement("div"),e.className="editor",i=document.createElement("div"),i.className="toolbar";const o=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n                    ${dashboardSettings.icons.image}\n                </button>`:"";i.id=`toolbar-${t.id}`,i.innerHTML=`\n                <span class="ql-formats">\n                    <button type="button" class="ql-p">\n                        <i class="icon icon-paragraph"></i>\n                    </button>\n                    <button type="button" class="ql-h1">\n                        <i class="icon icon-text-h-one"></i>\n                    </button>\n                    <button type="button" class="ql-h2">\n                        <i class="icon icon-text-h-two"></i>\n                    </button>\n                    <button type="button" class="ql-h3">\n                        <i class="icon icon-text-h-three"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                    <button type="button" class="ql-jvb_bold">\n                        <i class="icon icon-text-b-fi"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_italic">\n                        <i class="icon icon-text-italic"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_underline">\n                        <i class="icon icon-text-underline"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_strike">\n                        <i class="icon icon-text-strikethrough"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_list" value="bullet">\n                        <i class="icon icon-list-dashes"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_list" value="ordered">\n                        <i class="icon icon-list-numbers"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_align" value="left">\n                        <i class="icon icon-text-align-left"></i>\n                    </button>\n                     <button type="button" class="ql-jvb_align" value="center">\n                        <i class="icon icon-text-align-center"></i>\n                    </button>\n                     <button type="button" class="ql-jvb_align" value="right">\n                        <i class="icon icon-text-align-right"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_link">\n                        <i class="icon icon-link"></i>\n                    </button>\n                    ${o}\n                </span>\n            `,n.appendChild(i),n.appendChild(e),t.parentNode.insertBefore(n,t),t.style.display="none",e.innerHTML=t.value}const o=new Quill(e,{theme:"snow",modules:{toolbar:{container:i,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){this.quill.format("bold",!0)},jvb_italic:function(){this.quill.format("italic",!0)},jvb_strike:function(){this.quill.format("strike",!0)},jvb_underline:function(){this.quill.format("underline",!0)},jvb_align:function(t){this.quill.format("align",t!==this.quill.getFormat().list&&t)},jvb_list:function(t){this.quill.format("list",t!==this.quill.getFormat().list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;this.quill.getText(t.index,t.length);const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n                                    <div class="quill-link-modal-content ">\n                                        <label for="link">Enter URL</label>\n                                        <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n                                        <div class="buttons">\n                                            <button type="button" class="save">Save</button>\n                                            ${n?'<button type="button" class="remove">Remove</button>':""}\n                                            <button type="button" class="cancel">Cancel</button>\n                                        </div>\n                                    </div>\n                                `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const o=e.querySelector(".remove");o&&o.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const t=document.createElement("input");t.setAttribute("type","file"),t.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),t.style.display="none",document.body.appendChild(t),t.onchange=async n=>{const e=n.target.files?.[0];if(!e)return;if(e.size>5242880)return this.quill.insertText(i.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void t.remove();const i=this.quill.getSelection(!0),o=new FormData;o.append("image",e),objectID&&o.append("post_id",objectID),window.jvbLoading&&window.jvbLoading.showLoading("Uploading image...","Processing Upload");try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:o});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(i.index,"image",n.url)}catch(t){this.handleError("Upload error:",t),this.quill.insertText(i.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{window.jvbLoading&&window.jvbLoading.hide(),t.remove()}},t.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});o.on("selection-change",(function(t){const n=i.querySelector(".ql-align");if(n){if(t&&0===t.length){const[e]=this.quill.getLeaf(t.index);if(e&&e.domNode&&"IMG"===e.domNode.tagName)return void(n.style.display="inline-block")}n.style.display="none"}})),o.on("text-change",(()=>{t.value=o.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))}))};
window.jvbQuill=function(t){t.querySelectorAll("textarea[data-editor=true]").forEach((t=>{let n,e,i;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),e=n.querySelector(".editor"),i=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",e=document.createElement("div"),e.className="editor",i=document.createElement("div"),i.className="toolbar";const l=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n                    ${window.getIcon("image")}\n                </button>`:"";i.id=`toolbar-${t.id}`,i.innerHTML=`\n                <span class="ql-formats">\n                    <button type="button" class="ql-p">\n                        <i class="icon icon-paragraph"></i>\n                    </button>\n                    <button type="button" class="ql-h1">\n                        <i class="icon icon-text-h-one"></i>\n                    </button>\n                    <button type="button" class="ql-h2">\n                        <i class="icon icon-text-h-two"></i>\n                    </button>\n                    <button type="button" class="ql-h3">\n                        <i class="icon icon-text-h-three"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                    <button type="button" class="ql-jvb_bold">\n                        <i class="icon icon-text-b-fi"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_italic">\n                        <i class="icon icon-text-italic"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_underline">\n                        <i class="icon icon-text-underline"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_strike">\n                        <i class="icon icon-text-strikethrough"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_list" value="bullet">\n                        <i class="icon icon-list-dashes"></i>\n                    </button>\n                    <button type="button" class="ql-jvb_list" value="ordered">\n                        <i class="icon icon-list-numbers"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_align" value="left">\n                        <i class="icon icon-text-align-left"></i>\n                    </button>\n                     <button type="button" class="ql-jvb_align" value="center">\n                        <i class="icon icon-text-align-center"></i>\n                    </button>\n                     <button type="button" class="ql-jvb_align" value="right">\n                        <i class="icon icon-text-align-right"></i>\n                    </button>\n                </span>\n                <span class="ql-formats">\n                     <button type="button" class="ql-jvb_link">\n                        <i class="icon icon-link"></i>\n                    </button>\n                    ${l}\n                </span>\n            `,n.appendChild(i),n.appendChild(e),t.parentNode.insertBefore(n,t),t.style.display="none",e.innerHTML=t.value}const l=new Quill(e,{theme:"snow",modules:{toolbar:{container:i,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){this.quill.format("bold",!0)},jvb_italic:function(){this.quill.format("italic",!0)},jvb_strike:function(){this.quill.format("strike",!0)},jvb_underline:function(){this.quill.format("underline",!0)},jvb_align:function(t){this.quill.format("align",t!==this.quill.getFormat().list&&t)},jvb_list:function(t){this.quill.format("list",t!==this.quill.getFormat().list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n                                    <div class="quill-link-modal-content ">\n                                        <label for="link">Enter URL</label>\n                                        <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n                                        <div class="buttons">\n                                            <button type="button" class="save">Save</button>\n                                            ${n?'<button type="button" class="remove">Remove</button>':""}\n                                            <button type="button" class="cancel">Cancel</button>\n                                        </div>\n                                    </div>\n                                `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const l=e.querySelector(".remove");l&&l.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const t=document.createElement("input");t.setAttribute("type","file"),t.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),t.style.display="none",document.body.appendChild(t),t.onchange=async n=>{const e=n.target.files?.[0];if(!e)return;if(e.size>5242880)return this.quill.insertText(i.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void t.remove();const i=this.quill.getSelection(!0),l=new FormData;l.append("image",e),objectID&&l.append("post_id",objectID);try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:l});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(i.index,"image",n.url)}catch(t){this.handleError("Upload error:",t),this.quill.insertText(i.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{t.remove()}},t.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});l.on("selection-change",(function(t){const n=i.querySelector(".ql-align");if(n){if(t&&0===t.length){const[e]=this.quill.getLeaf(t.index);if(e&&e.domNode&&"IMG"===e.domNode.tagName)return void(n.style.display="inline-block")}n.style.display="none"}})),l.on("text-change",(()=>{t.value=l.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))}))};
assets/js/min/referral.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.storesInited=!1,this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",invite:"form.invite",adminList:".items-list.referral",dash:".replace .referral-dashboard",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'},list:".referrals-list"},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.dashTabs=null,this.ui.dash&&(this.dashTabs=new window.jvbTabs(this.ui.dash)),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()})),this.formController=null,this.ui.invite&&(this.formController=new window.jvbForm,this.formController.registerForm(this.ui.invite,{autosave:!0,endpoint:"referrals",formStatus:!1}),this.formController.subscribe(((e,t)=>{"form-submit"===e&&((t=t.fullData).action="invite",window.jvbQueue.addToQueue({endpoint:"referrals",data:t,title:"Submitting invitations"}))})))}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this)),this.ui.dash&&this.initViewController()}initViewController(){this.listStore&&this.ui.adminList&&(this.view=new window.jvbViews(this.ui.adminList,this.listStore),this.view.subscribe(((e,t)=>{switch(e){case"item-action":this.handleItemAction(t);break;case"bulk-action":this.handleBulkAction(t)}})))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const s=this.container.querySelector(`[data-stat="${e}"]`);s&&(s.textContent=t)}));const s=this.container.querySelectorAll(".stats .card");s.length>=4&&(s[0].querySelector(".stat-number").textContent=t.code_used,s[1].querySelector(".stat-number").textContent=t.consultations,s[2].querySelector(".stat-number").textContent=t.treatments,s[3].querySelector(".stat-number").textContent=t.total_rewards)}handleItemAction(e){const{action:t,itemId:s}=e;switch(t){case"remove":this.removeReferral(s);break;case"resend":this.resendInvite(s)}}async removeReferral(e){if(confirm("Remove this referral from your list?"))try{const t=await fetch(`${jvbSettings.api}referrals`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({action:"remove",referral_id:e})});(await t.json()).success&&(this.listStore&&this.listStore.fetch(),this.statsStore&&this.statsStore.fetch(),this.a11y?.announce("Referral removed"))}catch(e){console.error("Error removing referral:",e)}}async resendInvite(e){try{const t=await fetch(`${jvbSettings.api}referrals`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({action:"resend",referral_id:e})}),s=await t.json();s.success?this.a11y?.announce("Invitation resent"):alert(s.message||"Cannot resend yet. Wait 7 days between invites.")}catch(e){console.error("Error resending invite:",e)}}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,s=this.container.querySelector(`#${t}`);if(!s)return;const r=s.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(r).then((()=>{this.showCopySuccess(e)})).catch((()=>{this.selectText(s),this.showCopyFallback(e)}))}selectText(e){if(window.getSelection&&document.createRange){const t=window.getSelection(),s=document.createRange();s.selectNodeContents(e),t.removeAllRanges(),t.addRange(s)}else if(document.body.createTextRange){const t=document.body.createTextRange();t.moveToElementText(e),t.select()}}showCopySuccess(e){const t=e.innerHTML;e.innerHTML=window.jvbIcon("check",{size:16})+" Copied!",e.classList.add("success"),setTimeout((()=>{e.innerHTML=t,e.classList.remove("success")}),2e3)}showCopyFallback(e){const t=e.innerHTML;e.innerHTML="✓ Selected - Press Ctrl+C",e.classList.add("selected"),setTimeout((()=>{e.innerHTML=t,e.classList.remove("selected")}),3e3)}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase())}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),s=t.querySelector('[name="referral_code"]'),r=t.querySelector(".code-status");if(!s||!r)return;const a=s.value.trim();if(a){r.hidden=!1,r.className="code-status loading",r.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(r,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(r,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(r,"Error checking code","error")}}else this.showCodeStatus(r,"Please enter a code","error")}showCodeStatus(e,t,s){e.hidden=!1,e.className=`code-status ${s}`,e.textContent=t,"error"===s&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),s=this.getUrlParameter("remail"),r=this.getUrlParameter("seeReferral");if(!e&&!r)return;if(r&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const n=e.toUpperCase();if(a.value=n,a.readOnly=!0,t||s){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const r=this.container.querySelector('[name="referral_email"]');r&&(r.value=s)}this.popup.openPopup();try{const e=await this.validateCodeOnly(n);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const s=this.container.querySelector('[name="referral_name"]');s&&!s.value&&s.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}async loadStats(){if(this.container.querySelector(".stats-summary"))try{const e=await fetch(`${jvbSettings.api}referrals/my-stats?user=${window.auth.getUser()}`,{headers:{"X-WP-Nonce":window.auth.getNonce()}}),t=await e.json();t.success&&t.stats&&this.updateStats(t.stats)}catch(e){console.error("Error loading stats:",e)}}async loadSidebarStats(){try{const e=await fetch(`${jvbSettings.api}referrals/stats?user=${window.auth.getUser()}&type=quick`,{headers:{"X-WP-Nonce":window.auth.getNonce()}}),t=await e.json();t.success&&t.stats&&this.updateSidebarStats(t.stats)}catch(e){console.error("Error loading sidebar stats:",e)}}updateStats(e){const t={total:this.container.querySelector('[data-stat="total"]'),treated:this.container.querySelector('[data-stat="treated"]'),pending:this.container.querySelector('[data-stat="pending"]'),rewards:this.container.querySelector('[data-stat="rewards"]')};t.total&&(t.total.textContent=e.total_referrals||0),t.treated&&(t.treated.textContent=e.treated_count||0),t.pending&&(t.pending.textContent=e.pending_count||0),t.rewards&&(t.rewards.textContent="$"+parseFloat(e.available_rewards||0).toFixed(2))}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}formatDate(e){const t=new Date(e),s=new Date,r=Math.abs(s-t),a=Math.floor(r/864e5);return 0===a?"Today":1===a?"Yesterday":a<7?`${a} days ago`:t.toLocaleDateString("en-US",{month:"short",day:"numeric"})}async handleFormSubmit(e){e.preventDefault();const t=e.target,s=new FormData(t);this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){const t={name:s.get("referral_name"),email:s.get("referral_email"),referral_code:s.get("referral_code")};t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){const t={type:"login",email:s.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};e=await this.makeRequest("magic",t)}e.success?this.handleSuccess(t,e):this.showFormMessage(t,e.message||"Something went wrong. Please try again.","error")}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const s=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!s.ok){const e=await s.text();console.error("Error response:",s.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await s.json()}handleSuccess(e,t){e.style.display="none";const s=e.nextElementSibling;s&&s.classList.contains("success-content")&&(s.hidden=!1,s.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,s="error"){const r=e.querySelector(".status");if(!r)return;const a=r.querySelector(".message");a&&(a.textContent=t),r.hidden=!1,r.className=`status ${s}`,"error"===s&&setTimeout((()=>{r.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button").forEach((t=>t.disabled=e));const s=t.querySelector(".status");if(s&&(s.classList.toggle("loading",e),e)){s.hidden=!1;const e=s.querySelector(".message");e&&(e.textContent="Sending...")}}dispatchEvent(e,t){const s=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(s)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()})),this.formController=null}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)}));const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),n=s.querySelector(".validation-message"),i=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),i&&(i.hidden=!1),o&&(o.hidden=!0),n&&(n.textContent=r,n.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const n={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},i={submitted:"check-circle",error:"close-circle",checking:"loading"};if(i[t]&&window.getIcon){const e=window.getIcon(i[t]);e&&s.prepend(e)}a&&(a.textContent=r||n[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3),this.a11y&&this.a11y.announce(r||n[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach((e=>{this.clearFieldValidation(e)}));const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach((e=>e.hidden=!0)),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const n=e.toUpperCase();if(a.value=n,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(n);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let t={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){let t={type:"login",email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.email?e=await this.makeRequest("magic",t):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout((()=>{s.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach((t=>t.disabled=e)),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
assets/js/min/referralAdmin.min.js
New file
@@ -0,0 +1 @@
(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.referral=window.jvbReferral,this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initListeners()}initElements(){this.selectors={copyBtn:".copy-btn",invite:"form.invite",adminList:".items-list.referral",dash:".replace .referral-dashboard",list:".referrals-list"},this.ui=window.uiFromSelectors(this.selectors),this.tabs=null,this.ui.dash&&(this.tabs=new window.jvbTabs(this.ui.dash),this.initViewController()),this.ui.invite&&(this.formController=new window.jvbForm,this.formController.registerForm(this.ui.invite,{autosave:!0,endpoint:"referrals",formStatus:!1}),this.formController.subscribe(((e,t)=>{"form-submit"===e&&((t=t.fullData).action="invite",window.jvbQueue.addToQueue({endpoint:"referrals",data:t,title:"Submitting invitations"}))})))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),window.jvbQueue&&window.jvbQueue.subscribe(this.handleQueueEvent.bind(this))}handleClick(e){const t=e.target.closest(".copy-btn");t&&this.handleCopyClick(t)}handleCopyClick(e){const t=e.dataset.target,i=e.closest(".row").querySelector(`#${t}`);if(!i)return;const s=i.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}initViewController(){this.referral.listStore&&this.ui.adminList&&(this.view=new window.jvbViews(this.ui.adminList,this.referral.listStore),this.view.subscribe(((e,t)=>{switch(e){case"item-action":this.handleItemAction(t);break;case"bulk-action":this.handleBulkAction(t)}})))}handleItemAction(e){const{action:t,itemId:i}=e;switch(t){case"remove":this.removeReferral(i);break;case"resend":this.resendInvite(i)}}async removeReferral(e){if(confirm("Remove this referral from your list?"))try{const t=await fetch(`${jvbSettings.api}referrals`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({action:"remove",referral_id:e})});(await t.json()).success&&(this.referral.listStore&&this.referral.listStore.fetch(),this.referral.statsStore&&this.referral.statsStore.fetch(),this.a11y?.announce("Referral removed"))}catch(e){console.error("Error removing referral:",e)}}async resendInvite(e){try{const t=await fetch(`${jvbSettings.api}referrals`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({action:"resend",referral_id:e})}),i=await t.json();i.success?this.a11y?.announce("Invitation resent"):alert(i.message||"Cannot resend yet. Wait 7 days between invites.")}catch(e){console.error("Error resending invite:",e)}}handleQueueEvent(e,t){"operation-complete"===e&&t.details&&(t.details.successful||t.details.failed)&&this.showInviteResults(t.details)}showInviteResults(e){if(!this.ui.invite)return;const t=this.ui.invite.querySelector('[data-field="invite"]');if(!t)return;const i=t.querySelector(".tag-list");if(!i)return;const s=new Map;e.successful?.forEach((e=>{s.set(e.email,{status:"success",name:e.name})})),e.failed?.forEach((e=>{s.set(e.email,{status:"error",name:e.name,reason:e.reason})}));i.querySelectorAll(".tag").forEach((e=>{const t=JSON.parse(e.dataset.value||"{}"),i=s.get(t.email);i&&this.updateTagStatus(e,i)})),this.showInviteSummary(e)}updateTagStatus(e,t){e.classList.remove("success","error");const i=e.querySelector(".status-icon");i&&i.remove(),e.classList.add(t.status);const s=document.createElement("span");s.className="status-icon",s.innerHTML="success"===t.status?window.jvbIcon("check-circle",{size:14}):window.jvbIcon("warning-circle",{size:14}),t.reason&&(s.title=t.reason);const n=e.querySelector(".remove-tag");n?e.insertBefore(s,n):e.appendChild(s)}showInviteSummary(e){const t=e.successful?.length||0,i=e.failed?.length||0;let s=`Invites sent! ${t} successful`;i>0&&(s+=`, ${i} failed`),this.formController&&this.formController.showStatus(this.ui.invite,"submitted",s),i>0&&this.showFailureDetails(e.failed)}showFailureDetails(e){const t=`\n        <details class="invite-failures" open>\n            <summary>${e.length} invitation(s) failed - click for details</summary>\n            <ul>\n                ${e.map((e=>`\n                    <li>\n                        <strong>${window.escapeHtml(e.name)}</strong>\n                        (${window.escapeHtml(e.email)}):\n                        <em>${window.escapeHtml(e.reason)}</em>\n                    </li>\n                `)).join("")}\n            </ul>\n        </details>\n    `,i=this.ui.invite.querySelector(".fstatus");if(i){const e=i.querySelector(".invite-failures");e&&e.remove(),i.insertAdjacentHTML("beforeend",t)}}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbAdminReferral=new e)}))}))})();
assets/js/min/uploader.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStoreReady=!1,this.uploadStoreReady=!1,this.hasCheckedForUploads=!1;const{fields:e,uploads:t}=window.jvbStore.register("uploads",[{storeName:"fields",keyPath:"id",indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],TTL:6048e5,delayFetch:!0},{storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}],delayFetch:!0}]);this.fieldStore=e,this.uploadStore=t,window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.uploadElements=new Map,this.fieldElements=new Map,this.groupElements=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.sortableInstances=new Map,this.initWorker(),this.subscribers=new Set,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.init()}async init(){this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if(!["uploads","uploads/meta","uploads/groups"].includes(t.endpoint))return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data?.fieldId;switch(e){case"cancel-operation":s&&this.handleOperationCancelled(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":this.handleOperationComplete(t,s);break;case"operation-failed":case"operation-failed-permanent":this.handleOperationFailed(t,s)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}initializeFields(){document.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}scanFields(e){e.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}registerUploader(e){const t=this.determineFieldId(e),s=this.extractFieldConfig(e),o=this.buildFieldUI(e),a={id:t,config:s,uploads:new Set,groups:[],state:"ready",timestamp:Date.now()};return this.fieldStore.save(a),this.fieldElements.set(t,{element:e,ui:o,config:s}),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"single"!==s.type&&this.initSortable(t),t}extractFieldConfig(e){return{destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},s=e.querySelector(".group-display");return s&&(t.groups={display:s,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initSortable(e){if(!window.Sortable)return;!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0);const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((t=>{const s=t.classList.contains("group")?t.closest(".upload-group")?.dataset.groupId:null;this.createSortableForGrid(t,e,s)}));const s=t.element.querySelector(".empty-group");s&&!s.sortableInstance&&(s.sortableInstance=new Sortable(s,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:e,pull:!1,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:t=>this.handleDrop(t,e)}))}syncSortableSelection(e,t){this.sortableInstances.forEach(((s,o)=>{if(o.startsWith(e)){s.el.querySelectorAll(".item").forEach((e=>{const s=e.dataset.uploadId;t.has(s)?Sortable.utils.select(e):Sortable.utils.deselect(e)}))}}))}handleDrop(e,t){const s=e.to,o=e.from,a=e.items?.length>0?e.items:[e.item],r=a.map((e=>e.dataset.uploadId));switch(this.getDropTargetType(s)){case"empty-group":this.handleDropToEmptyGroup(a,r,t);break;case"preview":default:this.handleDropToPreview(a,r,t);break;case"group":this.handleDropToGroup(a,r,s,o,t)}this.updateSortableState(s),o!==s&&this.updateSortableState(o)}getDropTargetType(e){return e.classList.contains("empty-group")?"empty-group":e.classList.contains("preview")?"preview":e.classList.contains("group")?"group":"unknown"}handleDropToGroup(e,t,s,o,a){try{if(s===o)return void this.handleReorder({to:s,items:e});t.forEach((e=>{this.addToGroup(e,s,!1)})),this.schedulePersistance(a);const r=e.length>1?`Moved ${e.length} items to group`:"Moved item to group";this.a11y.announce(r);const i=this.selectionHandlers.get(a);i?.clearSelection()}catch(t){this.handleDropError(e,a,t)}}handleDropToPreview(e,t,s){try{t.forEach((e=>{this.removeFromGroup(e)})),this.schedulePersistance(s);const o=e.length>1?`Moved ${e.length} items to preview`:"Moved item to preview";this.a11y.announce(o);const a=this.selectionHandlers.get(s);a?.clearSelection()}catch(t){this.handleDropError(e,s,t)}}handleDropError(e,t,s,o="An error occurred"){console.error("Drop error:",s);const a=this.fieldElements.get(t);a?.ui?.preview&&e.forEach((e=>a.ui.preview.appendChild(e))),this.a11y.announce(`${o}. Items returned to preview.`)}handleDropToEmptyGroup(e,t,s){try{const o=this.createGroup(s);if(!o)return void this.handleDropError(e,s,new Error("Group creation failed"),"Failed to create group");e.forEach(((e,s)=>{o.grid.appendChild(e),this.addToGroup(t[s],o.grid,!1)})),this.schedulePersistance(s);const a=e.length>1?`Created group with ${e.length} items`:"Created group with item";this.a11y.announce(a);const r=this.selectionHandlers.get(s);r?.clearSelection()}catch(t){this.handleDropError(e,s,t)}}updateSortableState(e){const t=e?.sortableInstance;t&&t.option("disabled",!1)}refreshSortable(e){const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((e=>this.updateSortableState(e)))}handleReorder(e){const t=e.to,s=t.closest(".field, .upload");if(!s)return;e.items&&e.items.length>0?e.items:e.item;let o=Array.from(t.querySelectorAll(".item:not(.sortable-ghost):not(.sortable-clone)")).map((e=>e.dataset.uploadId)).filter((e=>e)),a=s.querySelector('input[type="hidden"]');a&&o.length>0&&(a.value=o.join(","));const r=this.getFieldIdFromElement(t);if(r){const e=this.getFieldData(r);if(t.classList.contains("group")){const s=t.dataset.groupId,a=e?.groups?.find((e=>e.id===s));a&&(a.uploads=o)}this.schedulePersistance(r)}this.a11y.announce("Item reordered"),s.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex,items:o},bubbles:!0}))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const o=this.getFieldIdFromElement(t);o&&(this.processFiles(o,s),this.a11y.announce(`${s.length} file(s) dropped for upload`))}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}if(t){const s=this.getFieldData(t);"post_group"===s?.config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e)}}async processFiles(e,t){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return;o.ui.dropZone&&(o.ui.dropZone.hidden=!0),o.ui.groups?.display&&(o.ui.groups.display.hidden=!1);const a=t.length;let r=0;this.updateUploadProgress(e,0,a,"Processing files...");const i=Array.from(t).map((async t=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,l={id:i,attachmentId:null,fieldId:e,status:"local_processing",groupId:null,meta:{originalName:t.name,size:t.size,type:t.type}};await this.uploadStore.save(l);const n=this.createPreviewUrl(t),d=t.type.startsWith("image/")?await this.processImage(t,s.config.subtype):t;this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),await this.saveBlobData(i,d||t);const c=this.getSubtypeFromMime(t.type),u=this.createUploadElement({id:i,preview:n,meta:l.meta,subtype:c},"post_group"===s.config.destination);o.ui.preview&&(o.ui.preview.appendChild(u),this.uploadElements.set(i,{element:u,preview:n,location:o.ui.preview}));const p=this.uploadStore.get(i);return p&&(p.status="processed",await this.uploadStore.save(p)),s.uploads.add(i),await this.saveFieldData(s),r++,this.updateUploadProgress(e,r,a,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),setTimeout((()=>this.showUploadProgress(i,!1)),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),r++,this.updateUploadProgress(e,r,a,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),this.refreshSortable(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((o,a)=>{let r,i=!1;r=setTimeout((()=>{i||(i=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),a(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:r}),this.handleProcess(e,t).then((e=>{i||(i=!0,clearTimeout(r),this.worker.tasks.delete(t),o(e))})).catch((e=>{i||(i=!0,clearTimeout(r),this.worker.tasks.delete(t),a(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const s=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,s,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,s,.85)}async processOnMainThread(e,t,s){return new Promise(((o,a)=>{const r=new Image,i=document.createElement("canvas"),l=i.getContext("2d");let n=null;const d=()=>{r.onload=null,r.onerror=null,n&&(URL.revokeObjectURL(n),n=null),i.width=1,i.height=1,l.clearRect(0,0,1,1)};r.onload=()=>{try{const{width:n,height:c}=this.calculateOptimalDimensions(r,t);i.width=n,i.height=c,l.imageSmoothingEnabled=!0,l.imageSmoothingQuality="high",l.drawImage(r,0,0,n,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);i.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});o(s)}else a(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),a(new Error(`Canvas processing failed: ${e.message}`))}},r.onerror=()=>{d(),a(new Error(`Failed to load image: ${e.name}`))};try{n=this.createPreviewUrl(e),r.src=n}catch(e){d(),a(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,s,o){return new Promise(((a,r)=>{if(!this.worker.worker)return void r(new Error("Worker not available"));const i=`${t}_${Date.now()}`,l=t=>{if(t.data.messageId===i)if(this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),t.data.success){const s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});a(s)}else r(new Error(t.data.error||"Worker processing failed"))},n=e=>{this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),r(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",l),this.worker.worker.addEventListener("error",n),this.worker.worker.postMessage({messageId:i,file:e,maxDimension:s,quality:o,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n\t\t\t\tself.onmessage = async function(e) {\n\t\t\t\t\tconst { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst bitmap = await createImageBitmap(file);\n\t\t\t\t\t\tconst scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n\t\t\t\t\t\tconst width = Math.round(bitmap.width * scale);\n\t\t\t\t\t\tconst height = Math.round(bitmap.height * scale);\n\t\t\t\t\t\tconst canvas = new OffscreenCanvas(width, height);\n\t\t\t\t\t\tconst ctx = canvas.getContext('2d');\n\t\t\t\t\t\tctx.imageSmoothingEnabled = true;\n\t\t\t\t\t\tctx.imageSmoothingQuality = 'high';\n\t\t\t\t\t\tctx.drawImage(bitmap, 0, 0, width, height);\n\t\t\t\t\t\tbitmap.close();\n\t\t\t\t\t\tconst blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });\n\t\t\t\t\t\tself.postMessage({ messageId, success: true, blob: blob, format: outputFormat });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tself.postMessage({ messageId, success: false, error: error.message });\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t"],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:s,height:o}=e;if(s<=t&&o<=t)return{width:s,height:o};const a=Math.min(t/s,t/o);return{width:Math.round(s*a),height:Math.round(o*a)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}async submitUploads(e){const t=this.getFieldData(e);this.fieldElements.get(e);if(!t?.uploads||0===t.uploads.size)return;let s=Array.from(t.uploads);if(0===s.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const o=this.getFieldGroups(e);if(0===o.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const a=[],r=new FormData;let i=[];for(const e of o){const t={images:[],fields:{}};for(let[s,o]of Object.entries(e.changes))t.fields[s]=o;const o=s.filter((t=>{const s=this.uploadStore.get(t);return s?.groupId===e.id}));for(const e of o){const s=await this.getBlobData(e);if(s){r.append("files[]",s);const o={upload_id:e,index:i.length},a=this.uploadElements.get(e),l=a?.element?.querySelector('[name="featured"]');l?.checked&&(t.fields.featured=e),t.images.push(o),i.push(e)}}a.push(t)}const l=s.filter((e=>{const t=this.uploadStore.get(e);return!t?.groupId}));for(const e of l){const t={images:[],fields:{}},s=await this.getBlobData(e);if(s){r.append("files[]",s);const o={upload_id:e,index:i.length};t.images.push(o),i.push(e)}a.push(t)}r.append("content",t.config.content),r.append("user",t.config.itemID),r.append("posts",JSON.stringify(a)),r.append("upload_ids",JSON.stringify(i));const n={endpoint:"uploads/groups",method:"POST",data:r,title:`Creating ${a.length} ${t.config.content}${a.length>1?"s":""} from uploads...`,popup:`Creating ${a.length} post${a.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{const e=await this.queue.addToQueue(n);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),this.a11y.announce(`Creating ${a.length} post${a.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}}async queueUpload(e){const t=this.getFieldData(e);if(!t?.uploads||0===t.uploads.size)return;const s=Array.from(t.uploads),o=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");const a={endpoint:"uploads",method:"POST",data:o,title:`Uploading ${s.length} file${s.length>1?"s":""} to server...`,popup:`Uploading ${s.length} file${s.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{const e=await this.queue.addToQueue(a);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),e}catch(e){throw e}}async prepareUploadData(e,t){const s=new FormData;s.append("content",e.config.content),s.append("mode",e.config.mode),s.append("field_name",e.config.name),s.append("fieldId",e.id),s.append("field_type",e.config.type),s.append("subtype",e.config.subtype),s.append("item_id",e.config.itemID),s.append("destination",e.config.destination||"meta");let o=[];const a=t.map((async e=>{const t=this.uploadStore.get(e);if(!t)return;const a=await this.getBlobData(e);a&&(s.append("files[]",a),o.push(t.id))}));return await Promise.all(a),s.append("upload_ids",JSON.stringify(o)),s}async queueUploadMeta(e){const t=this.getUploadIdFromElement(e.target),s=this.uploadStore.get(t);if(!s)return;if(!this.getFieldData(s.fieldId))return;let o={};o[e.target.name]=e.target.value,s.meta={...s.meta,...o},await this.uploadStore.save(s);let a={};a[s.attachmentId??s.id]=s.meta;const r={endpoint:"uploads/meta",method:"POST",data:a,title:"Updating meta",canMerge:!0,headers:{action_nonce:window.auth.getNonce("dash")}};try{await this.queue.addToQueue(r)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:s.id})}}async handleOperationComplete(e,t){if((e.result?.data||e.serverData?.data||[]).forEach((e=>{const t=this.uploadStore.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploadStore.save(t),this.updateUploadStatus(e.upload_id,"completed"))})),!t)return;const s=this.getFieldData(t);if(!s)return;const o=Array.from(s.uploads).filter((e=>{const t=this.uploadStore.get(e);return"completed"===t?.status}));for(const e of o)await this.clearUpload(e,!1),s.uploads.delete(e);0===s.uploads.size?(await this.clearFieldFromStores(t),this.a11y.announce("All uploads completed successfully")):await this.saveFieldData(s),this.updateFieldState(t)}handleOperationFailed(e,t){(e.data instanceof FormData?JSON.parse(e.data.get("upload_ids")||"[]"):e.data.upload_ids||[]).forEach((t=>{const s=this.uploadStore.get(t);s&&(s.status="operation-failed-permanent"===e.status?"failed_permanent":"failed",this.uploadStore.save(s),this.updateUploadStatus(t,s.status))})),t&&this.updateFieldState(t)}async handleOperationCancelled(e){const t=this.getFieldData(e);if(!t)return;const s=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const e of s)await this.clearUpload(e,!1);await this.clearFieldFromStores(e),this.updateFieldState(e),this.a11y.announce("Upload cancelled")}getFieldGroups(e){const t=this.getFieldData(e);return t?.groups?t.groups.map((e=>({id:e.id,uploads:e.uploads||[],changes:e.changes||{}}))):[]}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const s=e.closest(".item");s&&t.push({uploadId:s.dataset.uploadId,fieldId:s.dataset.fieldId})})),t}async restoreSelectedUploads(e){const t=new Map;e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)}));for(const[e,s]of t.entries()){const t=this.fieldStore.get(e);t&&(t.uploads=s,await this.restoreField(t))}}async restoreField(e){const{config:t,context:s,uploads:o,groups:a,id:r}=e;s?.modalType&&await this.openModalForRestore(s);let i=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!i){const e=`${t.content}_${t.itemID}_${t.name}`;i=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!i)return void console.warn(`Field ${t.name} not found for restoration`,t);let l=i.dataset.uploader;l&&this.fieldElements.has(l)||(l=this.registerUploader(i));const n=this.fieldElements.get(l),d=this.getFieldData(l);if(!n||!d)return void console.error("Failed to register field for restoration");d.state=e.state||"ready",n.ui||(n.ui=this.buildFieldUI(i)),n.ui.groups?.display&&(n.ui.groups.display.hidden=!1),n.ui.dropZone&&(n.ui.dropZone.hidden=!0),a&&a.length>0&&await this.restoreGroups(l,a);const c=o instanceof Set?Array.from(o):Array.isArray(o)?o:[];for(const e of c){const t=this.uploadStore.get(e);t&&await this.restoreUpload(l,t)}await this.saveFieldData(d),this.updateFieldState(l),this.maybeLockUploads(l),this.refreshSortable(l),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(l)}async restoreUpload(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(!s||!o)return void console.error("Field not found for upload restoration:",e);const a=await this.getBlobData(t.id);if(!a)return void console.warn("Blob data not found for upload:",t.id);const r=this.createPreviewUrl(a),i=this.getSubtypeFromMime(a.type),l=this.createUploadElement({id:t.id,preview:r,meta:t.meta||{originalName:a.name,size:a.size,type:a.type},subtype:i},"post_group"===o.config.destination);let n;if(t.groupId){const e=this.groupElements.get(t.groupId);if(e?.grid){n=e.grid;const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads||(s.uploads=[]),s.uploads.includes(t.id)||s.uploads.push(t.id))}else n=s.ui.preview,t.groupId=null}else n=s.ui.preview;n?n.appendChild(l):s.ui.preview&&(s.ui.preview.appendChild(l),n=s.ui.preview),this.uploadElements.set(t.id,{element:l,preview:r,location:n}),o.uploads||(o.uploads=new Set),o.uploads.add(t.id),t.status="processed",await this.uploadStore.save(t),n&&this.updateSortableState(n)}async restoreGroups(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(s&&o){for(const s of t){const t=this.createGroup(e,s.id);if(!t){console.warn("Failed to create group:",s.id);continue}const a=o.groups?.find((e=>e.id===s.id));if(a&&(s.changes&&(a.changes={...s.changes}),s.uploads&&(a.uploads=[...s.uploads]),s.changes)){const e=t.element.querySelector('[name*="post_title"]'),o=t.element.querySelector('[name*="post_excerpt"]');e&&s.changes.post_title&&(e.value=s.changes.post_title),o&&s.changes.post_excerpt&&(o.value=s.changes.post_excerpt)}}await this.saveFieldData(o)}else console.error("Field not found for group restoration:",e)}async openModalForRestore(e){if(!e)return;const{modalType:t,itemId:s}=e;let o=null;switch(t){case"create":o=document.querySelector('[data-action="create"]');break;case"edit":s&&(o=document.querySelector(`[data-action="edit"][data-id="${s}"]`));break;case"bulkEdit":o=document.querySelector('[data-action="bulk-edit"]')}o?(o.click(),await new Promise((e=>setTimeout(e,300)))):console.warn("Modal trigger not found for restoration:",e)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const s=t<0?0:t,o=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,o)).toFixed(s))+" "+["Bytes","KB","MB","GB"][o]}async clearUpload(e,t=!0){const s=this.uploadElements.get(e);if(s&&(this.revokePreviewUrl(s.preview),s.element)){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}if(this.uploadElements.delete(e),await this.uploadStore.delete(e),t){const t=this.uploadStore.get(e);t?.fieldId&&await this.schedulePersistance(t.fieldId)}}async clearFieldFromStores(e){const t=this.getFieldData(e);if(t?.uploads){const e=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const t of e)await this.uploadStore.delete(t)}await this.fieldStore.delete(e)}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}updateFieldState(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t||!s)return;const o=t.element,a=s.uploads?.size||0,r=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;o.dataset.hasUploads=a>0?"true":"false",o.dataset.uploadCount=a.toString(),o.dataset.hasGroups=r?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${a} item${1!==a?"s":""}`)}updateUploadProgress(e,t,s,o){const a=this.fieldElements.get(e);if(!a?.ui?.progress?.progress)return;const r=a.ui.progress,i=s>0?t/s*100:0;r.fill&&(r.fill.style.width=`${i}%`),r.text&&(r.text.textContent=o),r.count&&(r.count.textContent=`${t}/${s}`),r.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.getFieldData(e);s&&(s.state=t,this.saveFieldData(s))}updateUploadStatus(e,t){const s=this.uploadStore.get(e);s&&(s.status=t,this.uploadStore.save(s),this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploadElements.get(e),s=this.uploadStore.get(e);if(!s||!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${s.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(s.status),s.status)}showUploadProgress(e,t=!0){const s=this.uploadElements.get(e);if(!s?.element)return;const o=s.element.querySelector(".progress");o&&(t?(o.style.removeProperty("animation"),o.hidden=!1):(o.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{o.hidden=!0}),300)))}updateUploadItemProgress(e,t,s=null){const o=this.uploadElements.get(e);if(!o?.element)return;const a=o.element.querySelector(".progress");if(!a)return;const r=a.querySelector(".fill"),i=a.querySelector(".details"),l=a.querySelector(".icon");r&&(r.style.width=`${t}%`),s&&i&&(i.textContent=this.getStatusText(s)),s&&l&&(l.innerHTML=this.getStatusIcon(s).outerHTML)}maybeLockUploads(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t?.ui?.dropZone||!s)return;const o=s.uploads?.size||0,a="post_group"===s.config.destination?20:s.config?.maxFiles||999;t.ui.dropZone.hidden=o>=a,t.element.classList.toggle("at-max-uploads",o>=a),"post_group"===s.config.destination&&o>=a&&this.a11y.announce("Maximum of 20 uploads reached. Please submit current uploads before adding more.")}createSortableForGrid(e,t,s=null){if(!e||e.sortableInstance)return;const o=new Sortable(e,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:t,pull:!0,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>this.handleDrop(e,t),onSelect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&!t.checked&&(t.checked=!0,t.dispatchEvent(new Event("change",{bubbles:!0})))},onDeselect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&t.checked&&(t.checked=!1,t.dispatchEvent(new Event("change",{bubbles:!0})))},onAdd:e=>this.updateSortableState(e.to),onRemove:e=>this.updateSortableState(e.from)});e.sortableInstance=o;const a=s?`${t}-group-${s}`:`${t}-preview`;return this.sortableInstances.set(a,o),o}createGroup(e,t=null){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const a=this.createGroupElement(t,e);if(!a)return null;o.ui.groups||(o.ui.groups={groups:new Map,container:null,empty:null,display:null}),o.ui.groups.groups.set(t,a),o.ui.groups.container&&o.ui.groups.empty?o.ui.groups.container.insertBefore(a,o.ui.groups.empty):o.ui.groups.container&&o.ui.groups.container.appendChild(a);const r=a.querySelector(".item-grid.group");this.groupElements.set(t,{element:a,grid:r,fieldId:e}),s.groups||(s.groups=[]);return s.groups.find((e=>e.id===t))||(s.groups.push({id:t,uploads:[],changes:{}}),this.saveFieldData(s)),this.addGroupSelectionHandler(e,t),r&&this.createSortableForGrid(r,e,t),{id:t,element:a,grid:r}}createGroupElement(e,t){let s=window.getTemplate("imageGroup");if(!s)return;s.dataset.groupId=e,s.dataset.fieldId=t;let o=window.getTemplate("groupMetadata");const a=s.querySelector(".fields");if(a&&o){a.append(o);const r=a.querySelector('[name="post_title"]'),i=a.querySelector('[name="post_excerpt"]');r&&(r.id=`${e}_title`,r.name=`${e}[post_title]`),i&&(i.id=`${e}_excerpt`,i.name=`${e}[post_excerpt]`);const l=this.getFieldData(t);if(l&&""!==l.config.content){let e=s.querySelector("summary");e&&(e.textContent=l.config.content+" Fields")}}else s.querySelector("details")?.remove();const r=s.querySelector(".item-grid.group");return r&&(r.dataset.groupId=e),s}deleteGroup(e,t=!0){const s=this.groupElements.get(e);if(!s)return;const o=this.getFieldData(s.fieldId);if(!o)return;const a=o.groups?.find((t=>t.id===e));let r=!0;t&&a?.uploads?.length>0&&(r=!window.confirm("Delete uploads in group?")),t&&r&&a?.uploads&&a.uploads.forEach((e=>{this.removeFromGroup(e)})),o.groups&&(o.groups=o.groups.filter((t=>t.id!==e)),this.saveFieldData(o)),s.element&&(s.element.remove(),this.a11y.announce("Group removed")),this.groupElements.delete(e);const i=`${s.fieldId}-group-${e}`,l=this.sortableInstances.get(i);l?.destroy&&l.destroy(),this.sortableInstances.delete(i),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){const o=this.uploadStore.get(e),a=this.uploadElements.get(e);if(!o||!a)return;const r=this.getFieldData(o.fieldId),i=this.fieldElements.get(o.fieldId);if(!r||!i)return;if(!t&&a.location===i.ui.preview||t===a.location)return;if(o.groupId){const t=r.groups?.find((e=>e.id===o.groupId));t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length&&this.deleteGroup(o.groupId))}const l=a.element.querySelector('[name*="select-item"]');l&&(l.checked=!1);let n=a.element.querySelector('[name="featured"]');if(n&&(n.hidden=!t),!t||t.classList.contains("preview"))t=i.ui.preview,o.groupId=null;else{const s=t.dataset.groupId;n&&(n.name=s+"_"+n.name);const a=r.groups?.find((e=>e.id===s));a&&(a.uploads||(a.uploads=[]),a.uploads.push(e),o.groupId=s)}a.location=t,t.append(a.element),this.uploadStore.save(o),s&&this.saveFieldData(r),this.updateSortableState(t),a.location&&a.location!==t&&this.updateSortableState(a.location)}removeFromGroup(e){const t=this.uploadStore.get(e),s=this.uploadElements.get(e);if(!t||!s)return;const o=this.getFieldData(t.fieldId),a=this.fieldElements.get(t.fieldId);if(!o||!a)return;if(t.groupId){const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length&&this.deleteGroup(t.groupId,!1)),t.groupId=null}a.ui?.preview&&(a.ui.preview.appendChild(s.element),s.location=a.ui.preview);const r=s.element.querySelector('[name="featured"]');r&&(r.hidden=!0,r.checked=!1),this.uploadStore.save(t),this.updateSortableState(a.ui.preview)}removeUpload(e,t){const s=this.getFieldData(e),o=this.uploadStore.get(t),a=this.uploadElements.get(t);if(!s||!o)return;if(s.uploads?.delete(t),o.groupId){const e=s.groups?.find((e=>e.id===o.groupId));e&&(e.uploads=e.uploads.filter((e=>e!==t)),0===e.uploads.length&&this.deleteGroup(o.groupId))}a?.element?.remove(),this.clearUpload(t),this.saveFieldData(s),this.updateFieldState(e),this.maybeLockUploads(e);const r=this.selectionHandlers.get(e);r&&r.deselect(t),this.a11y.announce("Upload removed")}handleGroupMetaChange(e){const t=this.getGroupFromElement(e);if(!t)return;const s=this.getFieldData(t.fieldId),o=s?.groups?.find((e=>e.id===t.element.dataset.groupId));if(!o)return;o.changes||(o.changes={});let a=e.name;a.includes("group")&&(a=a.replace(`${o.id}_`,"").replace(`${o.id}[`,"").replace("]","")),o.changes[a]=e.value,this.saveFieldData(s),this.schedulePersistance(t.fieldId)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":const t=this.fieldElements.get(s);t&&(t.element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s));break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),s=t?.dataset.uploader;if(!s)return;const o=this.selected.get(s);if(o&&0!==o.size){const e=this.createGroup(s);if(!e)return;o.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(s);t?.clearSelection(),this.a11y.announce(`Created group with ${o.size} items`)}else this.createGroup(s);this.schedulePersistance(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const s=t.dataset.groupId,o=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(s),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(o)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId,o=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(o,s),this.a11y.announce("Item removed"),this.schedulePersistance(o))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fieldElements.get(e);if(!t?.element)return;const s=new window.jvbHandleSelection({container:t.element,ui:{selectAll:t.element.querySelector('[name="select-all-uploads"]'),bulkControls:t.element.querySelector(".selection-actions"),count:t.element.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return s.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.syncSortableSelection(e,s.selectedItems),this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(e,s),s}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const o=this.groupElements.get(t);if(!o?.element)return;const a=new window.jvbHandleSelection({container:o.element,ui:{selectAll:o.element.querySelector(this.selectors.groups.selectAll),bulkControls:o.element.querySelector(this.selectors.groups.actions),count:o.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return a.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(s,a),a}handleSelectAll(e,t){}getCurrentSelection(e){let t=[];for(let[s,o]of this.selectionHandlers)(e===s||s.includes(e))&&o.selectedItems.size>0&&(t=t.concat([...o.selectedItems]));return t}getFieldData(e){const t=this.fieldStore.get(e);return t?(Array.isArray(t.uploads)?t.uploads=new Set(t.uploads):t.uploads||(t.uploads=new Set),Array.isArray(t.groups)||(t.groups=[]),t):null}async saveFieldData(e){await this.fieldStore.save({...e,timestamp:Date.now()})}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const s={field:{selector:this.selectors.field.field,key:"uploader",getRuntimeData:e=>this.fieldElements.get(e),getStoreData:e=>this.getFieldData(e)},upload:{selector:this.selectors.items.item,key:"uploadId",getRuntimeData:e=>this.uploadElements.get(e),getStoreData:e=>this.uploadStore.get(e)},group:{selector:this.selectors.groups.container,key:"groupId",getRuntimeData:e=>this.groupElements.get(e),getStoreData:e=>{const t=this.groupElements.get(e);if(!t)return null;const s=this.getFieldData(t.fieldId);return s?.groups?.find((t=>t.id===e))}}},o=s[t];if(!o)return null;const a=e.closest(o.selector);if(!a)return null;const r=a.dataset[o.key];return{...o.getRuntimeData(r),...o.getStoreData(r)}}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){const t=this.getFromElement(e,"field");return t?.id??null}getUploadIdFromElement(e){const t=this.getFromElement(e,"upload");return t?.id??null}getGroupIdFromElement(e){const t=this.getFromElement(e,"group");return t?.id??null}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]||0}getModalType(e){if(!e?.element)return null;if(void 0!==e._cachedModalType)return e._cachedModalType;const t=e.element.closest("dialog");if(!t)return e._cachedModalType=null,null;let s=null;return s=t.classList.contains("edit")?"edit":t.classList.contains("create")?"create":t.classList.contains("bulkEdit")?"bulkEdit":t.className,e._cachedModalType=s,s}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return;s.dataset.uploadId=e.id,s.dataset.subtype=e.subtype||"image";let[o,a,r,i,l]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch(o&&(o.value=e.id),e.subtype){case"image":a&&(a.src=e.preview,a.alt=e.meta?.originalName||""),r?.remove(),i?.remove();break;case"video":r&&(r.src=e.preview),a?.remove(),i?.remove();break;case"document":const t=e.meta?.originalName||"",s=t.split(".").pop()?.toLowerCase()||"",o={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},l=window.getIcon(o[s]||"file");i&&(i.innerText=t,i.prepend(l)),a?.remove(),r?.remove()}if(l){let e=window.getTemplate("uploadMeta");e&&l.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,a=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,a&&(a.htmlFor=o)}})),s}normalizeFieldData(e){return e?(Array.isArray(e.uploads)?e.uploads=new Set(e.uploads):e.uploads||(e.uploads=new Set),Array.isArray(e.groups)||(e.groups=[]),e.groups=e.groups.map((e=>({...e,uploads:Array.isArray(e.uploads)?e.uploads:[]}))),e):null}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),250)}async persistFieldState(e){const t=this.getFieldData(e);t&&await this.saveFieldData(t)}async saveBlobData(e,t){const s=await t.arrayBuffer(),o=this.uploadStore.get(e)||{id:e};o.blobData={buffer:s,name:t.name,type:t.type,size:t.size,lastModified:t.lastModified||Date.now()},await this.uploadStore.save(o)}async getBlobData(e){const t=this.uploadStore.get(e);if(!t?.blobData)return null;const s=new Blob([t.blobData.buffer],{type:t.blobData.type});return new File([s],t.blobData.name,{type:t.blobData.type,lastModified:t.blobData.lastModified})}handleFieldStoreEvent(e,t){if("data-loaded"===e)this.fieldStoreReady=!0,this.checkIfBothStoresReady()}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.uploadStoreReady=!0,this.checkIfBothStoresReady();break;case"item-saved":this.showSaveIndicator(t.key)}}checkIfBothStoresReady(){this.fieldStoreReady&&this.uploadStoreReady&&!this.hasCheckedForUploads&&(this.hasCheckedForUploads=!0,this.checkForStoredUploads())}async checkForStoredUploads(){const e=this.fieldStore.getAll().filter((e=>{if(!e.uploads)return!1;return(e.uploads instanceof Set?Array.from(e.uploads):Array.isArray(e.uploads)?e.uploads:[]).some((e=>{const t=this.uploadStore.get(e);return t&&!t.operationId&&["completed","processed","local_processing","processed-original"].includes(t.status)}))}));0!==e.length&&this.showRecoveryNotification(e)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),s=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let o,a=window.getTemplate("restoreNotification");if(!a)return void console.error("Restore notification template not found");if(s>0){o=`${s} ${s>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else o=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const r=a.querySelector(".restore-details");r&&(r.textContent=o);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const s=e.querySelector("h3");s&&(s.textContent=t.config.name||"Unnamed Field");const o=e.querySelector(".item-grid.restore");for(let e of t.uploads){const s=this.uploadStore.get(e);let a=window.getTemplate("uploadItem");if(!a)continue;const r=await this.getBlobData(s.id);if(r)try{const e=this.createPreviewUrl(r);let[o,i,l,n,d]=[a.querySelector('[name="featured"]'),a.querySelector("img"),a.querySelector("video"),a.querySelector("label > span"),a.querySelector("details")];a.dataset.uploadId=s.id,a.dataset.fieldId=t.id;let c=this.getSubtypeFromMime(r.type);switch(a.dataset.subtype=c,c){case"image":[i.src,i.alt]=[e,r.name??s.meta?.originalName??""],l.remove(),n.remove();break;case"video":l.src=e,i.remove(),n.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}n.innerText=s.originalFile.name,n.prepend(t),i.remove(),l.remove()}a.dataset.previewUrl=e}catch(e){console.warn("Failed to create preview for upload:",s.id,e)}const i=a.querySelector("summary span");i&&(i.textContent=s.meta?.originalName||"Unknown file");const l=a.querySelector("details");l&&s.meta&&(l.textContent=`${this.formatBytes(s.meta.size)} • ${s.meta.type}`),a.querySelectorAll("input").forEach((e=>{let t=e.id;if(t){let o=t+s.id,a=e.parentNode.querySelector(`label[for="${t}"]`);e.id=o,a&&(a.htmlFor=o)}})),o&&o.appendChild(a)}a.querySelector(".wrap").appendChild(o)}document.querySelector(".field.upload").appendChild(a),a=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(a),this.restoreSelection=new window.jvbHandleSelection({container:a,ui:{selectAll:a.querySelector("#select-all-restore"),count:a.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}async handleRestoreAll(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=[];e.querySelectorAll(".item.upload").forEach((e=>{let s=e.dataset.uploadId,o=e.dataset.fieldId;t.push({uploadId:s,fieldId:o})})),await this.restoreSelectedUploads(t),this.cleanupRestore()}showSaveIndicator(e){}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){await this.fieldStore.clear(),await this.uploadStore.clear()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.sortableInstances.forEach((e=>{e?.destroy&&e.destroy()})),this.sortableInstances.clear(),this.uploadElements.clear(),this.fieldElements.clear(),this.groupElements.clear(),this.selected.clear(),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStoreReady=!1,this.uploadStoreReady=!1,this.hasCheckedForUploads=!1;const{fields:e,uploads:t}=window.jvbStore.register("uploads",[{storeName:"fields",keyPath:"id",indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],TTL:6048e5,delayFetch:!0},{storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}],delayFetch:!0}]);this.fieldStore=e,this.uploadStore=t,window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.uploadElements=new Map,this.fieldElements=new Map,this.groupElements=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.sortableInstances=new Map,this.initWorker(),this.subscribers=new Set,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.init()}async init(){this.initListeners(),this.queue.subscribe(((e,t)=>{if(!["uploads","uploads/meta","uploads/groups"].includes(t.endpoint))return;const o=t.data instanceof FormData?t.data.get("fieldId"):t.data?.fieldId;switch(e){case"cancel-operation":o&&this.handleOperationCancelled(o);break;case"operation-status":o&&this.updateFieldStatus(o,t.status);break;case"operation-complete":this.handleOperationComplete(t,o);break;case"operation-failed":case"operation-failed-permanent":this.handleOperationFailed(t,o)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}scanFields(e,t){console.log(t,"autoUpload");e.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e,t)))}registerUploader(e,t){const o=this.determineFieldId(e),s=this.extractFieldConfig(e,t),a=this.buildFieldUI(e);console.log(s,"registering with config");const r={id:o,config:s,uploads:new Set,groups:[],state:"ready",timestamp:Date.now()};return this.fieldStore.save(r),this.fieldElements.set(o,{element:e,ui:a,config:s}),e.dataset.uploader=o,this.addFieldSelectionHandler(o),"single"!==s.type&&this.initSortable(o),o}extractFieldConfig(e,t){return{autoUpload:t,destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},o=e.querySelector(".group-display");return o&&(t.groups={display:o,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initSortable(e){if(!window.Sortable)return;!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0);const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((t=>{const o=t.classList.contains("group")?t.closest(".upload-group")?.dataset.groupId:null;this.createSortableForGrid(t,e,o)}));const o=t.element.querySelector(".empty-group");o&&!o.sortableInstance&&(o.sortableInstance=new Sortable(o,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:e,pull:!1,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:t=>this.handleDrop(t,e)}))}syncSortableSelection(e,t){this.sortableInstances.forEach(((o,s)=>{if(s.startsWith(e)){o.el.querySelectorAll(".item").forEach((e=>{const o=e.dataset.uploadId;t.has(o)?Sortable.utils.select(e):Sortable.utils.deselect(e)}))}}))}handleDrop(e,t){const o=e.to,s=e.from,a=e.items?.length>0?e.items:[e.item],r=a.map((e=>e.dataset.uploadId));switch(this.getDropTargetType(o)){case"empty-group":this.handleDropToEmptyGroup(a,r,t);break;case"preview":default:this.handleDropToPreview(a,r,t);break;case"group":this.handleDropToGroup(a,r,o,s,t)}this.updateSortableState(o),s!==o&&this.updateSortableState(s)}getDropTargetType(e){return e.classList.contains("empty-group")?"empty-group":e.classList.contains("preview")?"preview":e.classList.contains("group")?"group":"unknown"}handleDropToGroup(e,t,o,s,a){try{if(o===s)return void this.handleReorder({to:o,items:e});t.forEach((e=>{this.addToGroup(e,o,!1)})),this.schedulePersistance(a);const r=e.length>1?`Moved ${e.length} items to group`:"Moved item to group";this.a11y.announce(r);const i=this.selectionHandlers.get(a);i?.clearSelection()}catch(t){this.handleDropError(e,a,t)}}handleDropToPreview(e,t,o){try{t.forEach((e=>{this.removeFromGroup(e)})),this.schedulePersistance(o);const s=e.length>1?`Moved ${e.length} items to preview`:"Moved item to preview";this.a11y.announce(s);const a=this.selectionHandlers.get(o);a?.clearSelection()}catch(t){this.handleDropError(e,o,t)}}handleDropError(e,t,o,s="An error occurred"){console.error("Drop error:",o);const a=this.fieldElements.get(t);a?.ui?.preview&&e.forEach((e=>a.ui.preview.appendChild(e))),this.a11y.announce(`${s}. Items returned to preview.`)}handleDropToEmptyGroup(e,t,o){try{const s=this.createGroup(o);if(!s)return void this.handleDropError(e,o,new Error("Group creation failed"),"Failed to create group");e.forEach(((e,o)=>{s.grid.appendChild(e),this.addToGroup(t[o],s.grid,!1)})),this.schedulePersistance(o);const a=e.length>1?`Created group with ${e.length} items`:"Created group with item";this.a11y.announce(a);const r=this.selectionHandlers.get(o);r?.clearSelection()}catch(t){this.handleDropError(e,o,t)}}updateSortableState(e){const t=e?.sortableInstance;t&&t.option("disabled",!1)}refreshSortable(e){const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((e=>this.updateSortableState(e)))}handleReorder(e){const t=e.to,o=t.closest(".field, .upload");if(!o)return;let s=Array.from(t.querySelectorAll(".item:not(.sortable-ghost):not(.sortable-clone)")).map((e=>e.dataset.uploadId)).filter((e=>e)),a=o.querySelector('input[type="hidden"]');a&&s.length>0&&(a.value=s.join(","));const r=this.getFieldIdFromElement(t);if(r){const e=this.getFieldData(r);if(t.classList.contains("group")){const o=t.dataset.groupId,a=e?.groups?.find((e=>e.id===o));a&&(a.uploads=s)}this.schedulePersistance(r)}this.a11y.announce("Item reordered"),o.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex,items:s},bubbles:!0}))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const o=Array.from(e.dataTransfer.files);if(0===o.length)return;const s=this.getFieldIdFromElement(t);s&&(this.processFiles(s,o),this.a11y.announce(`${o.length} file(s) dropped for upload`))}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const o=Array.from(e.target.files);o.length>0&&t&&this.processFiles(t,o)}if(t){const o=this.getFieldData(t);if(!o.config.autoUpload)return;"post_group"===o?.config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e)}}async processFiles(e,t){const o=this.getFieldData(e),s=this.fieldElements.get(e);if(!o||!s)return;s.ui.dropZone&&(s.ui.dropZone.hidden=!0),s.ui.groups?.display&&(s.ui.groups.display.hidden=!1);const a=t.length;let r=0;this.updateUploadProgress(e,0,a,"Processing files...");const i=Array.from(t).map((async t=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,n={id:i,attachmentId:null,fieldId:e,status:"local_processing",groupId:null,meta:{originalName:t.name,size:t.size,type:t.type}};await this.uploadStore.save(n);const l=this.createPreviewUrl(t),d=t.type.startsWith("image/")?await this.processImage(t,o.config.subtype):t;this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),await this.saveBlobData(i,d||t);const c=this.getSubtypeFromMime(t.type),u=this.createUploadElement({id:i,preview:l,meta:n.meta,subtype:c},"post_group"===o.config.destination);s.ui.preview&&(s.ui.preview.appendChild(u),this.uploadElements.set(i,{element:u,preview:l,location:s.ui.preview}));const p=this.uploadStore.get(i);return p&&(p.status="processed",await this.uploadStore.save(p)),o.uploads.add(i),await this.saveFieldData(o),r++,this.updateUploadProgress(e,r,a,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),setTimeout((()=>this.showUploadProgress(i,!1)),1e3),i}catch(o){return console.error("Error processing file:",t.name,o),r++,this.updateUploadProgress(e,r,a,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),this.refreshSortable(e),o.config.autoUpload&&"post_group"!==o.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}async processImage(e,t){const o=this.worker.settings.timeout;return new Promise(((s,a)=>{let r,i=!1;r=setTimeout((()=>{i||(i=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),a(new Error(`Processing timeout for ${e.name}`)))}),o),this.worker.tasks.set(t,{file:e,timeoutId:r}),this.handleProcess(e,t).then((e=>{i||(i=!0,clearTimeout(r),this.worker.tasks.delete(t),s(e))})).catch((e=>{i||(i=!0,clearTimeout(r),this.worker.tasks.delete(t),a(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const o=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,o,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,o,.85)}async processOnMainThread(e,t,o){return new Promise(((s,a)=>{const r=new Image,i=document.createElement("canvas"),n=i.getContext("2d");let l=null;const d=()=>{r.onload=null,r.onerror=null,l&&(URL.revokeObjectURL(l),l=null),i.width=1,i.height=1,n.clearRect(0,0,1,1)};r.onload=()=>{try{const{width:l,height:c}=this.calculateOptimalDimensions(r,t);i.width=l,i.height=c,n.imageSmoothingEnabled=!0,n.imageSmoothingQuality="high",n.drawImage(r,0,0,l,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,o);i.toBlob((t=>{if(d(),t){const o=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});s(o)}else a(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),a(new Error(`Canvas processing failed: ${e.message}`))}},r.onerror=()=>{d(),a(new Error(`Failed to load image: ${e.name}`))};try{l=this.createPreviewUrl(e),r.src=l}catch(e){d(),a(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,o,s){return new Promise(((a,r)=>{if(!this.worker.worker)return void r(new Error("Worker not available"));const i=`${t}_${Date.now()}`,n=t=>{if(t.data.messageId===i)if(this.worker.worker.removeEventListener("message",n),this.worker.worker.removeEventListener("error",l),t.data.success){const o=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});a(o)}else r(new Error(t.data.error||"Worker processing failed"))},l=e=>{this.worker.worker.removeEventListener("message",n),this.worker.worker.removeEventListener("error",l),r(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",n),this.worker.worker.addEventListener("error",l),this.worker.worker.postMessage({messageId:i,file:e,maxDimension:o,quality:s,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n\t\t\t\tself.onmessage = async function(e) {\n\t\t\t\t\tconst { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst bitmap = await createImageBitmap(file);\n\t\t\t\t\t\tconst scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n\t\t\t\t\t\tconst width = Math.round(bitmap.width * scale);\n\t\t\t\t\t\tconst height = Math.round(bitmap.height * scale);\n\t\t\t\t\t\tconst canvas = new OffscreenCanvas(width, height);\n\t\t\t\t\t\tconst ctx = canvas.getContext('2d');\n\t\t\t\t\t\tctx.imageSmoothingEnabled = true;\n\t\t\t\t\t\tctx.imageSmoothingQuality = 'high';\n\t\t\t\t\t\tctx.drawImage(bitmap, 0, 0, width, height);\n\t\t\t\t\t\tbitmap.close();\n\t\t\t\t\t\tconst blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });\n\t\t\t\t\t\tself.postMessage({ messageId, success: true, blob: blob, format: outputFormat });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tself.postMessage({ messageId, success: false, error: error.message });\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t"],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:o,height:s}=e;if(o<=t&&s<=t)return{width:o,height:s};const a=Math.min(t/o,t/s);return{width:Math.round(o*a),height:Math.round(s*a)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}async submitUploads(e){const t=this.getFieldData(e);this.fieldElements.get(e);if(!t?.uploads||0===t.uploads.size)return;let o=Array.from(t.uploads);if(0===o.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const s=this.getFieldGroups(e);if(0===s.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const a=[],r=new FormData;let i=[];for(const e of s){const t={images:[],fields:{}};for(let[o,s]of Object.entries(e.changes))t.fields[o]=s;const s=o.filter((t=>{const o=this.uploadStore.get(t);return o?.groupId===e.id}));for(const e of s){const o=await this.getBlobData(e);if(o){r.append("files[]",o);const s={upload_id:e,index:i.length},a=this.uploadElements.get(e),n=a?.element?.querySelector('[name="featured"]');n?.checked&&(t.fields.featured=e),t.images.push(s),i.push(e)}}a.push(t)}const n=o.filter((e=>{const t=this.uploadStore.get(e);return!t?.groupId}));for(const e of n){const t={images:[],fields:{}},o=await this.getBlobData(e);if(o){r.append("files[]",o);const s={upload_id:e,index:i.length};t.images.push(s),i.push(e)}a.push(t)}r.append("content",t.config.content),r.append("user",t.config.itemID),r.append("posts",JSON.stringify(a)),r.append("upload_ids",JSON.stringify(i));const l={endpoint:"uploads/groups",method:"POST",data:r,title:`Creating ${a.length} ${t.config.content}${a.length>1?"s":""} from uploads...`,popup:`Creating ${a.length} post${a.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{const e=await this.queue.addToQueue(l);return o.forEach((t=>{const o=this.uploadStore.get(t);o&&(o.operationId=e,o.status="queued",this.uploadStore.save(o),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),this.a11y.announce(`Creating ${a.length} post${a.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}}async queueUpload(e){const t=this.getFieldData(e);if(!t?.uploads||0===t.uploads.size)return;const o=Array.from(t.uploads),s=this.prepareUploadData(t,o);this.a11y.announce("Queuing for upload");const a={endpoint:"uploads",method:"POST",data:s,title:`Uploading ${o.length} file${o.length>1?"s":""} to server...`,popup:`Uploading ${o.length} file${o.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{const e=await this.queue.addToQueue(a);return o.forEach((t=>{const o=this.uploadStore.get(t);o&&(o.operationId=e,o.status="queued",this.uploadStore.save(o),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),e}catch(e){throw e}}async prepareUploadData(e,t){const o=new FormData;o.append("content",e.config.content),o.append("mode",e.config.mode),o.append("field_name",e.config.name),o.append("fieldId",e.id),o.append("field_type",e.config.type),o.append("subtype",e.config.subtype),o.append("item_id",e.config.itemID),o.append("destination",e.config.destination||"meta");let s=[];const a=t.map((async e=>{const t=this.uploadStore.get(e);if(!t)return;const a=await this.getBlobData(e);a&&(o.append("files[]",a),s.push(t.id))}));return await Promise.all(a),o.append("upload_ids",JSON.stringify(s)),o}async queueUploadMeta(e){const t=this.getUploadIdFromElement(e.target),o=this.uploadStore.get(t);if(!o)return;if(!this.getFieldData(o.fieldId))return;let s={};s[e.target.name]=e.target.value,o.meta={...o.meta,...s},await this.uploadStore.save(o);let a={};a[o.attachmentId??o.id]=o.meta;const r={endpoint:"uploads/meta",method:"POST",data:a,title:"Updating meta",canMerge:!0,headers:{action_nonce:window.auth.getNonce("dash")}};try{await this.queue.addToQueue(r)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:o.id})}}async handleOperationComplete(e,t){if((e.result?.data||e.serverData?.data||[]).forEach((e=>{const t=this.uploadStore.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploadStore.save(t),this.updateUploadStatus(e.upload_id,"completed"))})),!t)return;const o=this.getFieldData(t);if(!o)return;const s=Array.from(o.uploads).filter((e=>{const t=this.uploadStore.get(e);return"completed"===t?.status}));for(const e of s)await this.clearUpload(e,!1),o.uploads.delete(e);0===o.uploads.size?(await this.clearFieldFromStores(t),this.a11y.announce("All uploads completed successfully")):await this.saveFieldData(o),this.updateFieldState(t)}handleOperationFailed(e,t){(e.data instanceof FormData?JSON.parse(e.data.get("upload_ids")||"[]"):e.data.upload_ids||[]).forEach((t=>{const o=this.uploadStore.get(t);o&&(o.status="operation-failed-permanent"===e.status?"failed_permanent":"failed",this.uploadStore.save(o),this.updateUploadStatus(t,o.status))})),t&&this.updateFieldState(t)}async handleOperationCancelled(e){const t=this.getFieldData(e);if(!t)return;const o=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const e of o)await this.clearUpload(e,!1);await this.clearFieldFromStores(e),this.updateFieldState(e),this.a11y.announce("Upload cancelled")}getFieldGroups(e){const t=this.getFieldData(e);return t?.groups?t.groups.map((e=>({id:e.id,uploads:e.uploads||[],changes:e.changes||{}}))):[]}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const o=e.closest(".item");o&&t.push({uploadId:o.dataset.uploadId,fieldId:o.dataset.fieldId})})),t}async restoreSelectedUploads(e){const t=new Map;e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)}));for(const[e,o]of t.entries()){const t=this.fieldStore.get(e);t&&(t.uploads=o,await this.restoreField(t))}}async restoreField(e){const{config:t,context:o,uploads:s,groups:a,id:r}=e;o?.modalType&&await this.openModalForRestore(o);let i=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!i){const e=`${t.content}_${t.itemID}_${t.name}`;i=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!i)return void console.warn(`Field ${t.name} not found for restoration`,t);let n=i.dataset.uploader;n&&this.fieldElements.has(n)||(n=this.registerUploader(i));const l=this.fieldElements.get(n),d=this.getFieldData(n);if(!l||!d)return void console.error("Failed to register field for restoration");d.state=e.state||"ready",l.ui||(l.ui=this.buildFieldUI(i)),l.ui.groups?.display&&(l.ui.groups.display.hidden=!1),l.ui.dropZone&&(l.ui.dropZone.hidden=!0),a&&a.length>0&&await this.restoreGroups(n,a);const c=s instanceof Set?Array.from(s):Array.isArray(s)?s:[];for(const e of c){const t=this.uploadStore.get(e);t&&await this.restoreUpload(n,t)}await this.saveFieldData(d),this.updateFieldState(n),this.maybeLockUploads(n),this.refreshSortable(n),console.log(t),t.autoUpload&&"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(n)}async restoreUpload(e,t){const o=this.fieldElements.get(e),s=this.getFieldData(e);if(!o||!s)return void console.error("Field not found for upload restoration:",e);const a=await this.getBlobData(t.id);if(!a)return void console.warn("Blob data not found for upload:",t.id);const r=this.createPreviewUrl(a),i=this.getSubtypeFromMime(a.type),n=this.createUploadElement({id:t.id,preview:r,meta:t.meta||{originalName:a.name,size:a.size,type:a.type},subtype:i},"post_group"===s.config.destination);let l;if(t.groupId){const e=this.groupElements.get(t.groupId);if(e?.grid){l=e.grid;const o=s.groups?.find((e=>e.id===t.groupId));o&&(o.uploads||(o.uploads=[]),o.uploads.includes(t.id)||o.uploads.push(t.id))}else l=o.ui.preview,t.groupId=null}else l=o.ui.preview;l?l.appendChild(n):o.ui.preview&&(o.ui.preview.appendChild(n),l=o.ui.preview),this.uploadElements.set(t.id,{element:n,preview:r,location:l}),s.uploads||(s.uploads=new Set),s.uploads.add(t.id),t.status="processed",await this.uploadStore.save(t),l&&this.updateSortableState(l)}async restoreGroups(e,t){const o=this.fieldElements.get(e),s=this.getFieldData(e);if(o&&s){for(const o of t){const t=this.createGroup(e,o.id);if(!t){console.warn("Failed to create group:",o.id);continue}const a=s.groups?.find((e=>e.id===o.id));if(a&&(o.changes&&(a.changes={...o.changes}),o.uploads&&(a.uploads=[...o.uploads]),o.changes)){const e=t.element.querySelector('[name*="post_title"]'),s=t.element.querySelector('[name*="post_excerpt"]');e&&o.changes.post_title&&(e.value=o.changes.post_title),s&&o.changes.post_excerpt&&(s.value=o.changes.post_excerpt)}}await this.saveFieldData(s)}else console.error("Field not found for group restoration:",e)}async openModalForRestore(e){if(!e)return;const{modalType:t,itemId:o}=e;let s=null;switch(t){case"create":s=document.querySelector('[data-action="create"]');break;case"edit":o&&(s=document.querySelector(`[data-action="edit"][data-id="${o}"]`));break;case"bulkEdit":s=document.querySelector('[data-action="bulk-edit"]')}s?(s.click(),await new Promise((e=>setTimeout(e,300)))):console.warn("Modal trigger not found for restoration:",e)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const o=t<0?0:t,s=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,s)).toFixed(o))+" "+["Bytes","KB","MB","GB"][s]}async clearUpload(e,t=!0){const o=this.uploadElements.get(e);if(o&&(this.revokePreviewUrl(o.preview),o.element)){const e=o.element.dataset.previewUrl;this.revokePreviewUrl(e),delete o.element.dataset.previewUrl}if(this.uploadElements.delete(e),await this.uploadStore.delete(e),t){const t=this.uploadStore.get(e);t?.fieldId&&await this.schedulePersistance(t.fieldId)}}async clearFieldFromStores(e){const t=this.getFieldData(e);if(t?.uploads){const e=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const t of e)await this.uploadStore.delete(t)}await this.fieldStore.delete(e)}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}updateFieldState(e){const t=this.fieldElements.get(e),o=this.getFieldData(e);if(!t||!o)return;const s=t.element,a=o.uploads?.size||0,r=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;s.dataset.hasUploads=a>0?"true":"false",s.dataset.uploadCount=a.toString(),s.dataset.hasGroups=r?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${a} item${1!==a?"s":""}`)}updateUploadProgress(e,t,o,s){const a=this.fieldElements.get(e);if(!a?.ui?.progress?.progress)return;const r=a.ui.progress,i=o>0?t/o*100:0;r.fill&&(r.fill.style.width=`${i}%`),r.text&&(r.text.textContent=s),r.count&&(r.count.textContent=`${t}/${o}`),r.progress.hidden=t===o}updateFieldStatus(e,t){const o=this.getFieldData(e);o&&(o.state=t,this.saveFieldData(o))}updateUploadStatus(e,t){const o=this.uploadStore.get(e);o&&(o.status=t,this.uploadStore.save(o),this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploadElements.get(e),o=this.uploadStore.get(e);if(!o||!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${o.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(o.status),o.status)}showUploadProgress(e,t=!0){const o=this.uploadElements.get(e);if(!o?.element)return;const s=o.element.querySelector(".progress");s&&(t?(s.style.removeProperty("animation"),s.hidden=!1):(s.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{s.hidden=!0}),300)))}updateUploadItemProgress(e,t,o=null){const s=this.uploadElements.get(e);if(!s?.element)return;const a=s.element.querySelector(".progress");if(!a)return;const r=a.querySelector(".fill"),i=a.querySelector(".details"),n=a.querySelector(".icon");r&&(r.style.width=`${t}%`),o&&i&&(i.textContent=this.getStatusText(o)),o&&n&&(n.innerHTML=this.getStatusIcon(o).outerHTML)}maybeLockUploads(e){const t=this.fieldElements.get(e),o=this.getFieldData(e);if(!t?.ui?.dropZone||!o)return;const s=o.uploads?.size||0,a="post_group"===o.config.destination?20:o.config?.maxFiles||999;t.ui.dropZone.hidden=s>=a,t.element.classList.toggle("at-max-uploads",s>=a),"post_group"===o.config.destination&&s>=a&&this.a11y.announce("Maximum of 20 uploads reached. Please submit current uploads before adding more.")}createSortableForGrid(e,t,o=null){if(!e||e.sortableInstance)return;const s=new Sortable(e,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:t,pull:!0,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>this.handleDrop(e,t),onSelect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&!t.checked&&(t.checked=!0,t.dispatchEvent(new Event("change",{bubbles:!0})))},onDeselect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&t.checked&&(t.checked=!1,t.dispatchEvent(new Event("change",{bubbles:!0})))},onAdd:e=>this.updateSortableState(e.to),onRemove:e=>this.updateSortableState(e.from)});e.sortableInstance=s;const a=o?`${t}-group-${o}`:`${t}-preview`;return this.sortableInstances.set(a,s),s}createGroup(e,t=null){const o=this.getFieldData(e),s=this.fieldElements.get(e);if(!o||!s)return null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const a=this.createGroupElement(t,e);if(!a)return null;s.ui.groups||(s.ui.groups={groups:new Map,container:null,empty:null,display:null}),s.ui.groups.groups.set(t,a),s.ui.groups.container&&s.ui.groups.empty?s.ui.groups.container.insertBefore(a,s.ui.groups.empty):s.ui.groups.container&&s.ui.groups.container.appendChild(a);const r=a.querySelector(".item-grid.group");this.groupElements.set(t,{element:a,grid:r,fieldId:e}),o.groups||(o.groups=[]);return o.groups.find((e=>e.id===t))||(o.groups.push({id:t,uploads:[],changes:{}}),this.saveFieldData(o)),this.addGroupSelectionHandler(e,t),r&&this.createSortableForGrid(r,e,t),{id:t,element:a,grid:r}}createGroupElement(e,t){let o=window.getTemplate("imageGroup");if(!o)return;o.dataset.groupId=e,o.dataset.fieldId=t;let s=window.getTemplate("groupMetadata");const a=o.querySelector(".fields");if(a&&s){a.append(s);const r=a.querySelector('[name="post_title"]'),i=a.querySelector('[name="post_excerpt"]');r&&(r.id=`${e}_title`,r.name=`${e}[post_title]`),i&&(i.id=`${e}_excerpt`,i.name=`${e}[post_excerpt]`);const n=this.getFieldData(t);if(n&&""!==n.config.content){let e=o.querySelector("summary");e&&(e.textContent=n.config.content+" Fields")}}else o.querySelector("details")?.remove();const r=o.querySelector(".item-grid.group");return r&&(r.dataset.groupId=e),o}deleteGroup(e,t=!0){const o=this.groupElements.get(e);if(!o)return;const s=this.getFieldData(o.fieldId);if(!s)return;const a=s.groups?.find((t=>t.id===e));let r=!0;t&&a?.uploads?.length>0&&(r=!window.confirm("Delete uploads in group?")),t&&r&&a?.uploads&&a.uploads.forEach((e=>{this.removeFromGroup(e)})),s.groups&&(s.groups=s.groups.filter((t=>t.id!==e)),this.saveFieldData(s)),o.element&&(o.element.remove(),this.a11y.announce("Group removed")),this.groupElements.delete(e);const i=`${o.fieldId}-group-${e}`,n=this.sortableInstances.get(i);n?.destroy&&n.destroy(),this.sortableInstances.delete(i),this.schedulePersistance(o.fieldId)}addToGroup(e,t=null,o=!0){const s=this.uploadStore.get(e),a=this.uploadElements.get(e);if(!s||!a)return;const r=this.getFieldData(s.fieldId),i=this.fieldElements.get(s.fieldId);if(!r||!i)return;if(!t&&a.location===i.ui.preview||t===a.location)return;if(s.groupId){const t=r.groups?.find((e=>e.id===s.groupId));t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length&&this.deleteGroup(s.groupId))}const n=a.element.querySelector('[name*="select-item"]');n&&(n.checked=!1);let l=a.element.querySelector('[name="featured"]');if(l&&(l.hidden=!t),!t||t.classList.contains("preview"))t=i.ui.preview,s.groupId=null;else{const o=t.dataset.groupId;l&&(l.name=o+"_"+l.name);const a=r.groups?.find((e=>e.id===o));a&&(a.uploads||(a.uploads=[]),a.uploads.push(e),s.groupId=o)}a.location=t,t.append(a.element),this.uploadStore.save(s),o&&this.saveFieldData(r),this.updateSortableState(t),a.location&&a.location!==t&&this.updateSortableState(a.location)}removeFromGroup(e){const t=this.uploadStore.get(e),o=this.uploadElements.get(e);if(!t||!o)return;const s=this.getFieldData(t.fieldId),a=this.fieldElements.get(t.fieldId);if(!s||!a)return;if(t.groupId){const o=s.groups?.find((e=>e.id===t.groupId));o&&(o.uploads=o.uploads.filter((t=>t!==e)),0===o.uploads.length&&this.deleteGroup(t.groupId,!1)),t.groupId=null}a.ui?.preview&&(a.ui.preview.appendChild(o.element),o.location=a.ui.preview);const r=o.element.querySelector('[name="featured"]');r&&(r.hidden=!0,r.checked=!1),this.uploadStore.save(t),this.updateSortableState(a.ui.preview)}removeUpload(e,t){const o=this.getFieldData(e),s=this.uploadStore.get(t),a=this.uploadElements.get(t);if(!o||!s)return;if(o.uploads?.delete(t),s.groupId){const e=o.groups?.find((e=>e.id===s.groupId));e&&(e.uploads=e.uploads.filter((e=>e!==t)),0===e.uploads.length&&this.deleteGroup(s.groupId))}a?.element?.remove(),this.clearUpload(t),this.saveFieldData(o),this.updateFieldState(e),this.maybeLockUploads(e);const r=this.selectionHandlers.get(e);r&&r.deselect(t),this.a11y.announce("Upload removed")}handleGroupMetaChange(e){const t=this.getGroupFromElement(e);if(!t)return;const o=this.getFieldData(t.fieldId),s=o?.groups?.find((e=>e.id===t.element.dataset.groupId));if(!s)return;s.changes||(s.changes={});let a=e.name;a.includes("group")&&(a=a.replace(`${s.id}_`,"").replace(`${s.id}[`,"").replace("]","")),s.changes[a]=e.value,this.saveFieldData(o),this.schedulePersistance(t.fieldId)}handleAction(e){const t=e.dataset.action,o=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":const t=this.fieldElements.get(o);t&&(t.element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(o));break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),o=t?.dataset.uploader;if(!o)return;const s=this.selected.get(o);if(s&&0!==s.size){const e=this.createGroup(o);if(!e)return;s.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(o);t?.clearSelection(),this.a11y.announce(`Created group with ${s.size} items`)}else this.createGroup(o);this.schedulePersistance(o)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const o=t.dataset.groupId,s=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(o),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(s)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const o=t.dataset.uploadId,s=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(s,o),this.a11y.announce("Item removed"),this.schedulePersistance(s))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fieldElements.get(e);if(!t?.element)return;const o=new window.jvbHandleSelection({container:t.element,ui:{selectAll:t.element.querySelector('[name="select-all-uploads"]'),bulkControls:t.element.querySelector(".selection-actions"),count:t.element.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return o.subscribe(((t,o)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.syncSortableSelection(e,o.selectedItems),this.selected.set(e,o.selectedItems);break;case"select-all":this.handleSelectAll(o.container,o.selected)}})),this.selectionHandlers.set(e,o),o}addGroupSelectionHandler(e,t){const o=`${e}_${t}`;if(this.selectionHandlers.has(o))return this.selectionHandlers.get(o);const s=this.groupElements.get(t);if(!s?.element)return;const a=new window.jvbHandleSelection({container:s.element,ui:{selectAll:s.element.querySelector(this.selectors.groups.selectAll),bulkControls:s.element.querySelector(this.selectors.groups.actions),count:s.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return a.subscribe(((t,o)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,o.selectedItems);break;case"select-all":this.handleSelectAll(o.container,o.selected)}})),this.selectionHandlers.set(o,a),a}handleSelectAll(e,t){}getFieldData(e){const t=this.fieldStore.get(e);return t?(Array.isArray(t.uploads)?t.uploads=new Set(t.uploads):t.uploads||(t.uploads=new Set),Array.isArray(t.groups)||(t.groups=[]),t):null}async saveFieldData(e){await this.fieldStore.save({...e,timestamp:Date.now()})}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const o={field:{selector:this.selectors.field.field,key:"uploader",getRuntimeData:e=>this.fieldElements.get(e),getStoreData:e=>this.getFieldData(e)},upload:{selector:this.selectors.items.item,key:"uploadId",getRuntimeData:e=>this.uploadElements.get(e),getStoreData:e=>this.uploadStore.get(e)},group:{selector:this.selectors.groups.container,key:"groupId",getRuntimeData:e=>this.groupElements.get(e),getStoreData:e=>{const t=this.groupElements.get(e);if(!t)return null;const o=this.getFieldData(t.fieldId);return o?.groups?.find((t=>t.id===e))}}},s=o[t];if(!s)return null;const a=e.closest(s.selector);if(!a)return null;const r=a.dataset[s.key];return{...s.getRuntimeData(r),...s.getStoreData(r)}}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){const t=this.getFromElement(e,"field");return t?.id??null}getUploadIdFromElement(e){const t=this.getFromElement(e,"upload");return t?.id??null}getGroupIdFromElement(e){const t=this.getFromElement(e,"group");return t?.id??null}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]||0}createUploadElement(e,t=!1){let o=window.getTemplate("uploadItem");if(!o)return;o.dataset.uploadId=e.id,o.dataset.subtype=e.subtype||"image";let[s,a,r,i,n]=[o.querySelector('[name="featured"]'),o.querySelector("img"),o.querySelector("video"),o.querySelector("label > span"),o.querySelector("details")];switch(s&&(s.value=e.id),e.subtype){case"image":a&&(a.src=e.preview,a.alt=e.meta?.originalName||""),r?.remove(),i?.remove();break;case"video":r&&(r.src=e.preview),a?.remove(),i?.remove();break;case"document":const t=e.meta?.originalName||"",o=t.split(".").pop()?.toLowerCase()||"",s={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},n=window.getIcon(s[o]||"file");i&&(i.innerText=t,i.prepend(n)),a?.remove(),r?.remove()}if(n){let e=window.getTemplate("uploadMeta");e&&n.append(e)}return o.draggable=t,o.querySelectorAll("input").forEach((t=>{let o=t.id;if(o){let s=o+e.id,a=t.parentNode.querySelector(`label[for="${o}"]`);t.id=s,a&&(a.htmlFor=s)}})),o}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),250)}async persistFieldState(e){const t=this.getFieldData(e);t&&await this.saveFieldData(t)}async saveBlobData(e,t){const o=await t.arrayBuffer(),s=this.uploadStore.get(e)||{id:e};s.blobData={buffer:o,name:t.name,type:t.type,size:t.size,lastModified:t.lastModified||Date.now()},await this.uploadStore.save(s)}async getBlobData(e){const t=this.uploadStore.get(e);if(!t?.blobData)return null;const o=new Blob([t.blobData.buffer],{type:t.blobData.type});return new File([o],t.blobData.name,{type:t.blobData.type,lastModified:t.blobData.lastModified})}async getFilesForForm(e){const t=e.querySelectorAll("[data-upload-field]"),o=[];for(const e of t){const t=this.determineFieldId(e),s=await this.getFilesForField(t);o.push(...s)}return o}async getFilesForField(e){const t=this.getFieldData(e);if(!t?.uploads)return[];const o=[],s=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const e of s){const s=this.uploadStore.get(e);if(!s)continue;const a=await this.getBlobData(e);a&&o.push({file:a,uploadId:e,fieldName:t.config.name,meta:s.meta||{}})}return o}handleFieldStoreEvent(e,t){if("data-loaded"===e)this.fieldStoreReady=!0,this.checkIfBothStoresReady()}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.uploadStoreReady=!0,this.checkIfBothStoresReady();break;case"item-saved":this.showSaveIndicator(t.key)}}checkIfBothStoresReady(){this.fieldStoreReady&&this.uploadStoreReady&&!this.hasCheckedForUploads&&(this.hasCheckedForUploads=!0,this.checkForStoredUploads())}async checkForStoredUploads(){const e=this.fieldStore.getAll().filter((e=>{if(!e.uploads)return!1;return(e.uploads instanceof Set?Array.from(e.uploads):Array.isArray(e.uploads)?e.uploads:[]).some((e=>{const t=this.uploadStore.get(e);return t&&!t.operationId&&["completed","processed","local_processing","processed-original"].includes(t.status)}))}));0!==e.length&&await this.showRecoveryNotification(e)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),o=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let s,a=window.getTemplate("restoreNotification");if(!a)return void console.error("Restore notification template not found");if(o>0){s=`${o} ${o>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else s=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const r=a.querySelector(".restore-details");r&&(r.textContent=s);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const o=e.querySelector("h3");o&&(o.textContent=t.config.name||"Unnamed Field");const s=e.querySelector(".item-grid.restore");for(let e of t.uploads){const o=this.uploadStore.get(e);let a=window.getTemplate("uploadItem");if(!a)continue;const r=await this.getBlobData(o.id);if(r)try{const e=this.createPreviewUrl(r);let[s,i,n,l,d]=[a.querySelector('[name="featured"]'),a.querySelector("img"),a.querySelector("video"),a.querySelector("label > span"),a.querySelector("details")];a.dataset.uploadId=o.id,a.dataset.fieldId=t.id;let c=this.getSubtypeFromMime(r.type);switch(a.dataset.subtype=c,c){case"image":[i.src,i.alt]=[e,r.name??o.meta?.originalName??""],n.remove(),l.remove();break;case"video":n.src=e,i.remove(),l.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}l.innerText=o.originalFile.name,l.prepend(t),i.remove(),n.remove()}a.dataset.previewUrl=e}catch(e){console.warn("Failed to create preview for upload:",o.id,e)}const i=a.querySelector("summary span");i&&(i.textContent=o.meta?.originalName||"Unknown file");const n=a.querySelector("details");n&&o.meta&&(n.textContent=`${this.formatBytes(o.meta.size)} • ${o.meta.type}`),a.querySelectorAll("input").forEach((e=>{let t=e.id;if(t){let s=t+o.id,a=e.parentNode.querySelector(`label[for="${t}"]`);e.id=s,a&&(a.htmlFor=s)}})),s&&s.appendChild(a)}a.querySelector(".wrap").appendChild(s)}document.querySelector(".field.upload").appendChild(a),a=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(a),this.restoreSelection=new window.jvbHandleSelection({container:a,ui:{selectAll:a.querySelector("#select-all-restore"),count:a.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}async handleRestoreAll(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=[];e.querySelectorAll(".item.upload").forEach((e=>{let o=e.dataset.uploadId,s=e.dataset.fieldId;t.push({uploadId:o,fieldId:s})})),await this.restoreSelectedUploads(t),this.cleanupRestore()}showSaveIndicator(e){}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){await this.fieldStore.clear(),await this.uploadStore.clear()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((o=>{try{o(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.sortableInstances.forEach((e=>{e?.destroy&&e.destroy()})),this.sortableInstances.clear(),this.uploadElements.clear(),this.fieldElements.clear(),this.groupElements.clear(),this.selected.clear(),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
build/feed/view.asset.php
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => '90c2ce15e482c81ed55a');
<?php return array('dependencies' => array(), 'version' => '9b079150b16e2c23daab');
build/feed/view.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.cache=new window.jvbCache("feed"),this.error=window.jvbError,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.initElements(),this.initFilters(),this.loadWhenAble())}loadWhenAble(){"requestIdleCallback"in window?requestIdleCallback((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),100)}initElements(){this.currentTaxonomies=new Set,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"]'),this.ui.taxonomies=this.ui.filterContainer.querySelectorAll("[data-taxonomy]"),this.ui.content.length>0?this.contentTypes=Array.from(this.ui.content).map((e=>e.value)):this.contentTypes=[this.container.dataset.content],this.ui.taxonomies.length>0?this.taxonomies=Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):this.taxonomies=[]}async initTaxonomies(){this.selector=window.jvbSelector;const e=document.querySelectorAll('[data-filter="taxonomy"]');this.selector.isInitializing=!0,e.forEach((e=>{const t=e.dataset.taxonomy;this.currentTaxonomies.add(t),this.selector.registerFilterButton(e,{button:e,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax}),this.addTaxonomyPreloadListeners(e,t)})),this.selector.isInitializing=!1,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}addTaxonomyPreloadListeners(e,t){const i=()=>{this.selector.preloadTaxonomy(t)};e.addEventListener("mouseenter",i,{once:!0}),e.addEventListener("pointerdown",i,{once:!0}),e.addEventListener("focus",i,{once:!0})}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;t.size>0?this.taxonomyFilters[i]=Array.from(t.keys()):delete this.taxonomyFilters[i];let s={page:1};Object.keys(this.taxonomyFilters).length>0&&(s.taxonomy=this.taxonomyFilters),this.updateFilter(s)}clearAllTaxonomies(){this.taxonomyFilters={},window.removeChildren(this.ui.selectedTax),this.updateFilter({taxonomy:null,page:1})}initFilters(){this.filters={content:this.contentTypes[0],orderby:"date",order:"desc",page:1},this.config.context&&(this.filters.context=this.config.context),this.config.source&&(this.filters.source=this.config.source),this.processCachedFilters(),this.processURLFilters(),this.syncUIToFilters()}syncUIToFilters(){Object.entries(this.filters).forEach((([e,t])=>{const i=this.ui.filterContainer.querySelector(`[data-filter="${e}"][value="${t}"]`);i&&(i.checked=!0)})),this.updateContentFor(this.filters.content)}nextPage(){this.store.setFilter("page",this.store.filters.page++)}initStore(){const e=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:216e5,showLoading:!0,required:"content",delayFetch:!0});this.store=e.feed,this.store.subscribe(((e,t)=>{"data-loaded"===e&&(this.renderItems(),this.ui.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse.has_more&&(this.ui.loadMore.hidden=!this.store.lastResponse.has_more))}))}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse&&this.store.lastResponse.has_more&&this.nextPage()}))}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.filters.page>1)return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;["content","order","orderby","favourites","match"].forEach((t=>{let i=e.get(`f_${t}`);if(i){this.filters[t]=i;let e=this.ui.filters[t];e&&(e.checked=!0)}}));let t=!1;if(e.forEach(((e,i)=>{if(i.startsWith("f_tax_")){t=!0;const s=i.replace("f_tax_","");this.taxonomyFilters[s]||(this.taxonomyFilters[s]=[]),this.taxonomyFilters[s]=e.split(",").map(Number)}})),t)for(let[e,t]in Object.entries(this.taxonomyFilters)){let i=this.ui.filterContainer.querySelector(`[data-taxonomy="${e}"]`);i&&(i.dataset.fieldId?(this.selector.get(i.dataset.fieldId).selectedTerms=new Set(t),this.selector.initFieldDisplay(i.dataset.fieldId)):this.selector.registerField(i,{button:i,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax,selectedItems:t}))}return!0}updateURL(){const e=new URLSearchParams;["content","order","orderby","match"].forEach((t=>{this.filters[t]&&e.set(`f_${t}`,this.filters[t])})),Object.entries(this.taxonomyFilters).forEach((([t,i])=>{i.length>0&&e.set(`f_tax_${t}`,i.join(","))}));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;window.history.pushState({filters:this.filters},"",t)}renderItems(){let e=this.store.getFiltered();if(1===this.store.filters.page&&window.removeChildren(this.ui.grid),0===e.length)return void this.a11y.announceItems(0,this.store.filters.page>0);const t=document.createDocumentFragment(),i=s=>{const r=Math.min(s+10,e.length);for(let i=s;i<r;i++){const s=e[i],r=this.createItemElement(s);t.appendChild(r)}r<e.length?requestAnimationFrame((()=>i(r))):(this.removePlaceholders(),this.ui.grid.append(t),this.observeImages(this.ui.grid),this.config.gallery&&this.gallery.updateGalleryItems(this.gallery.getGalleryItems()),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,this.store.filters.page>1,this.store.hasMore))};e.length>0?i(0):this.a11y.announceItems(0,this.store.filters.page>1,!1),this.ui.filters.match.hidden=0===Object.keys(this.taxonomyFilters).length,this.ui.clearFilter.hidden=0===Object.keys(this.taxonomyFilters).length}createItemElement(e){let t=window.getTemplate("feed-item");return Object.hasOwn(t.dataset,"timeline")?this.createTimelineElement(e,t):t}createTimelineElement(e,t){var i,s;let[r,a,o,n,l,d,h,c,m,u]=[t,t.querySelector("a"),t.querySelector("img.before"),t.querySelector("img.after"),t.querySelector("summary span:last-of-type"),t.querySelector("p.started time"),t.querySelector("p.updated time"),t.querySelector("p.total b"),t.querySelector(".term-list"),Object.values(e.fields.order)],f=u.length-1,y=e.images[u[0].post_thumbnail],g=e.images[u[f].post_thumbnail];return[r.dataset.id,a.href,o.src,o.dataset.small,o.dataset.medium,n.src,n.dataset.small,n.dataset.medium,l.textContent,d.textContent,h.textContent,c.textContent]=[e.id,e.url,y.tiny,y.small,y.medium,g.tiny,g.small,g.medium,`${l.textContent} ${f} Tx`,null!==(i=u[0].date)&&void 0!==i?i:e.date,null!==(s=u[f].date)&&void 0!==s?s:"",`${f} Treatments`],t}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}addPlaceholders(){let e=this.contentTypes.length;const t=document.createDocumentFragment();for(let i=0;i<12;i++){let i,s=window.getTemplate("placeholderTemplate"),r=Math.floor(Math.random()*e);i=this.ui.content.length>0?this.ui.content.filter((e=>e.value===this.contentTypes[r])).querySelector(".icon").cloneNode(!0):window.getIcon(this.container.dataset.icon),s.append(i),t.append(s)}this.ui.grid.append(t)}updateFilter(e){let t=["taxonomy","favourites","match",...Object.keys(this.filters)];e=Object.keys(e).filter((e=>t.includes(e))).reduce(((t,i)=>(t[i]=e[i],t)),{}),window.getDifferences.map(this.filters,e)&&(this.filters={...this.filters,...e},this.updateURL(),this.store.setFilters(e))}updateContentFor(e){this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]').forEach((t=>{const i=t.dataset.for?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)})),this.ui.filterContainer.querySelectorAll("[data-for]").forEach((t=>{const i=t.dataset.for?.split(",")||[];i.length>0&&(t.hidden=!i.includes(e),t.hidden&&t.checked&&(t.checked=!1))}));const t=this.ui.filterContainer.querySelector('[name="orderby"]:checked');this.updateOrderDirectionVisibility(t?.value)}updateOrderDirectionVisibility(e){const t=this.ui.filterContainer.querySelector(".order-direction");if(t){const i=t.dataset.forOrder?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)}}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,"IntersectionObserver"in window&&(this.imageObserver=new IntersectionObserver((e=>{e.forEach((e=>{this.loadImage(e.target),this.imageObserver.unobserve(e.target)}))}),{rootMargin:"100px",threshold:.1})),"ResizeObserver"in window?this.resizeObserver=new ResizeObserver((()=>{window.debouncer.schedule("feed-update-images",(()=>this.updateImageSizes()),250)})):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)}loadImage(e){const t=this.getAppropriateImageSize(e);t&&t!==e.src&&(e.src=t,e.dataset.loaded="true")}getAppropriateImageSize(e){return window.innerWidth<768&&e.dataset.small?e.dataset.small:e.dataset.medium?e.dataset.medium:e.src}observeImages(e){e.querySelectorAll("img[data-small], img[data-medium]").forEach((e=>{e.dataset.loaded||this.imageObserver.observe(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.elements.loadMore)?this.nextPage():window.targetCheck(e,this.elements.clearFilter)?this.clearAllTaxonomies():window.targetCheck(e,".remove-item")&&this.handleRemoveSelectedTerm(e)}handleRemoveSelectedTerm(e){const t=e.target.closest(".selected-item");if(!t)return;const i=parseInt(t.dataset.id),s=t.dataset.taxonomy;this.taxonomyFilters[s]&&(this.taxonomyFilters[s]=this.taxonomyFilters[s].filter((e=>e!==i)),0===this.taxonomyFilters[s].length&&delete this.taxonomyFilters[s]),t.remove(),this.updateFilter({taxonomy:Object.keys(this.taxonomyFilters).length>0?this.taxonomyFilters:null,page:1})}handleChange(e){let t=e.target;Object.hasOwn(t.dataset,"filter")&&("content"===t.dataset.filter?(this.updateContentFor(t.value),this.updateFilter({content:t.value,page:1})):"orderby"===t.dataset.filter?(this.updateOrderDirectionVisibility(t.value),this.updateFilter({orderby:t.value,page:1})):"order"===t.dataset.filter?this.updateFilter({order:t.value,page:1}):"match"===t.dataset.filter?this.updateFilter({match:t.checked?"all":"any",page:1}):"favourites"===t.dataset.filter&&this.updateFilter({favourites:t.checked,page:1}))}}document.addEventListener("DOMContentLoaded",(function(){window.feedBlock=new e}))})();
(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.cache=new window.jvbCache("feed"),this.error=window.jvbError,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.initElements(),this.initFilters(),this.loadWhenAble())}loadWhenAble(){"requestIdleCallback"in window?requestIdleCallback((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),100)}initElements(){this.currentTaxonomies=new Set,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"]'),this.ui.taxonomies=this.ui.filterContainer.querySelectorAll("[data-taxonomy]"),this.ui.content.length>0?this.contentTypes=Array.from(this.ui.content).map((e=>e.value)):this.contentTypes=[this.container.dataset.content],this.ui.taxonomies.length>0?this.taxonomies=Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):this.taxonomies=[]}async initTaxonomies(){this.selector=window.jvbSelector;const e=document.querySelectorAll('[data-filter="taxonomy"]');this.selector.isInitializing=!0,e.forEach((e=>{const t=e.dataset.taxonomy;this.currentTaxonomies.add(t),this.selector.registerFilterButton(e,{button:e,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax}),this.addTaxonomyPreloadListeners(e,t)})),this.selector.isInitializing=!1,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}addTaxonomyPreloadListeners(e,t){const i=()=>{this.selector.preloadTaxonomy(t)};e.addEventListener("mouseenter",i,{once:!0}),e.addEventListener("pointerdown",i,{once:!0}),e.addEventListener("focus",i,{once:!0})}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;t.size>0?this.taxonomyFilters[i]=Array.from(t.keys()):delete this.taxonomyFilters[i];let s={page:1};Object.keys(this.taxonomyFilters).length>0&&(s.taxonomy=this.taxonomyFilters),this.updateFilter(s)}clearAllTaxonomies(){this.taxonomyFilters={},window.removeChildren(this.ui.selectedTax),this.updateFilter({taxonomy:null,page:1})}initFilters(){this.filters={content:this.contentTypes[0],orderby:"date",order:"desc",page:1},this.config.context&&(this.filters.context=this.config.context),this.config.source&&(this.filters.source=this.config.source),this.processCachedFilters(),this.processURLFilters(),this.syncUIToFilters()}syncUIToFilters(){Object.entries(this.filters).forEach((([e,t])=>{const i=this.ui.filterContainer.querySelector(`[data-filter="${e}"][value="${t}"]`);i&&(i.checked=!0)})),this.updateContentFor(this.filters.content)}nextPage(){this.store.setFilter("page",this.store.filters.page++)}initStore(){const e=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:216e5,showLoading:!0,required:"content",delayFetch:!0});this.store=e.feed,this.store.subscribe(((e,t)=>{"data-loaded"===e&&(this.renderItems(),this.ui.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse.has_more&&(this.ui.loadMore.hidden=!this.store.lastResponse.has_more))}))}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse&&this.store.lastResponse.has_more&&this.nextPage()}))}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.filters.page>1)return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;["content","order","orderby","favourites","match"].forEach((t=>{let i=e.get(`f_${t}`);if(i){this.filters[t]=i;let e=this.ui.filters[t];e&&(e.checked=!0)}}));let t=!1;if(e.forEach(((e,i)=>{if(i.startsWith("f_tax_")){t=!0;const s=i.replace("f_tax_","");this.taxonomyFilters[s]||(this.taxonomyFilters[s]=[]),this.taxonomyFilters[s]=e.split(",").map(Number)}})),t)for(let[e,t]in Object.entries(this.taxonomyFilters)){let i=this.ui.filterContainer.querySelector(`[data-taxonomy="${e}"]`);i&&(i.dataset.fieldId?(this.selector.get(i.dataset.fieldId).selectedTerms=new Set(t),this.selector.initFieldDisplay(i.dataset.fieldId)):this.selector.registerField(i,{button:i,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax,selectedItems:t}))}return!0}updateURL(){const e=new URLSearchParams;["content","order","orderby","match"].forEach((t=>{this.filters[t]&&e.set(`f_${t}`,this.filters[t])})),Object.entries(this.taxonomyFilters).forEach((([t,i])=>{i.length>0&&e.set(`f_tax_${t}`,i.join(","))}));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;window.history.pushState({filters:this.filters},"",t)}renderItems(){let e=this.store.getFiltered();if(1===this.store.filters.page&&window.removeChildren(this.ui.grid),0===e.length)return void this.a11y.announceItems(0,this.store.filters.page>0);const t=document.createDocumentFragment(),i=s=>{const r=Math.min(s+10,e.length);for(let i=s;i<r;i++){const s=e[i],r=this.createItemElement(s);t.appendChild(r)}r<e.length?requestAnimationFrame((()=>i(r))):(this.removePlaceholders(),this.ui.grid.append(t),this.observeImages(this.ui.grid),this.config.gallery&&this.gallery.updateGalleryItems(this.gallery.getGalleryItems()),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,this.store.filters.page>1,this.store.hasMore))};e.length>0?i(0):this.a11y.announceItems(0,this.store.filters.page>1,!1),this.ui.filters.match.hidden=0===Object.keys(this.taxonomyFilters).length,this.ui.clearFilter.hidden=0===Object.keys(this.taxonomyFilters).length}createItemElement(e){let t=window.getTemplate("feed-item");return Object.hasOwn(t.dataset,"timeline")?this.createTimelineElement(e,t):t}createTimelineElement(e,t){var i,s;let[r,a,o,n,l,d,h,c,m,u]=[t,t.querySelector("a"),t.querySelector("img.before"),t.querySelector("img.after"),t.querySelector("summary span:last-of-type"),t.querySelector("p.started time"),t.querySelector("p.updated time"),t.querySelector("p.total b"),t.querySelector(".term-list"),Object.values(e.fields.order)],f=u.length-1,y=e.images[u[0].post_thumbnail],g=e.images[u[f].post_thumbnail];return[r.dataset.id,a.href,o.src,o.dataset.small,o.dataset.medium,n.src,n.dataset.small,n.dataset.medium,l.textContent,d.textContent,h.textContent,c.textContent]=[e.id,e.url,y.tiny,y.small,y.medium,g.tiny,g.small,g.medium,`${l.textContent} ${f} Tx`,null!==(i=u[0].date)&&void 0!==i?i:e.date,null!==(s=u[f].date)&&void 0!==s?s:"",`${f} Treatments`],t}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}addPlaceholders(){let e=this.contentTypes.length;const t=document.createDocumentFragment();for(let i=0;i<12;i++){let i,s=window.getTemplate("placeholderTemplate"),r=Math.floor(Math.random()*e);i=this.ui.content.length>0?this.ui.content.filter((e=>e.value===this.contentTypes[r])).querySelector(".icon").cloneNode(!0):window.getIcon(this.container.dataset.icon),s.append(i),t.append(s)}this.ui.grid.append(t)}updateFilter(e){let t=["taxonomy","favourites","match",...Object.keys(this.filters)];e=Object.keys(e).filter((e=>t.includes(e))).reduce(((t,i)=>(t[i]=e[i],t)),{}),window.getDifferences.map(this.filters,e)&&(this.filters={...this.filters,...e},this.updateURL(),this.store.setFilters(e))}updateContentFor(e){this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]').forEach((t=>{const i=t.dataset.for?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)})),this.ui.filterContainer.querySelectorAll("[data-for]").forEach((t=>{const i=t.dataset.for?.split(",")||[];i.length>0&&(t.hidden=!i.includes(e),t.hidden&&t.checked&&(t.checked=!1))}));const t=this.ui.filterContainer.querySelector('[name="orderby"]:checked');this.updateOrderDirectionVisibility(t?.value)}updateOrderDirectionVisibility(e){const t=this.ui.filterContainer.querySelector(".order-direction");if(t){const i=t.dataset.forOrder?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)}}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,"IntersectionObserver"in window&&(this.imageObserver=new IntersectionObserver((e=>{e.forEach((e=>{this.loadImage(e.target),this.imageObserver.unobserve(e.target)}))}),{rootMargin:"100px",threshold:.1})),"ResizeObserver"in window?this.resizeObserver=new ResizeObserver((()=>{window.debouncer.schedule("feed-update-images",(()=>this.updateImageSizes()),250)})):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)}loadImage(e){const t=this.getAppropriateImageSize(e);t&&t!==e.src&&(e.src=t,e.dataset.loaded="true")}getAppropriateImageSize(e){return window.innerWidth<768&&e.dataset.small?e.dataset.small:e.dataset.medium?e.dataset.medium:e.src}observeImages(e){e.querySelectorAll("img[data-small], img[data-medium]").forEach((e=>{e.dataset.loaded||this.imageObserver.observe(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.elements.loadMore)?this.nextPage():window.targetCheck(e,this.elements.clearFilter)?this.clearAllTaxonomies():window.targetCheck(e,".remove-item")&&this.handleRemoveSelectedTerm(e)}handleRemoveSelectedTerm(e){const t=e.target.closest(".selected-item");if(!t)return;const i=parseInt(t.dataset.id),s=t.dataset.taxonomy;this.taxonomyFilters[s]&&(this.taxonomyFilters[s]=this.taxonomyFilters[s].filter((e=>e!==i)),0===this.taxonomyFilters[s].length&&delete this.taxonomyFilters[s]),t.remove(),this.updateFilter({taxonomy:Object.keys(this.taxonomyFilters).length>0?this.taxonomyFilters:null,page:1})}handleChange(e){let t=e.target;Object.hasOwn(t.dataset,"filter")&&("content"===t.dataset.filter?(this.updateContentFor(t.value),this.updateFilter({content:t.value,page:1})):"orderby"===t.dataset.filter?(this.updateOrderDirectionVisibility(t.value),this.updateFilter({orderby:t.value,page:1})):"order"===t.dataset.filter?this.updateFilter({order:t.value,page:1}):"match"===t.dataset.filter?this.updateFilter({match:t.checked?"all":"any",page:1}):"favourites"===t.dataset.filter&&this.updateFilter({favourites:t.checked,page:1}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
build/forms/view.asset.php
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => '6084ed3247c497c65c42');
<?php return array('dependencies' => array(), 'version' => '918c373929631a4cc439');
build/forms/view.js
@@ -1 +1 @@
(()=>{class o{constructor(){this.controller=new window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((o=>{this.controller.registerForm(o,{autosave:!0})})),this.controller.subscribe(((o,t)=>{"form-submit"===o&&this.handleFormSubmission(t)}))}async handleFormSubmission(o){let t=o.formId,e=o.config,r=o.fullData,s=e.element,n={"X-WP-Nonce":jvbSettings.nonce,"Content-Type":"application/json"};s.closest(".jvb-form-block"),this.controller.showFormStatus(t,"uploading");try{const o=await fetch(`${jvbSettings.api}forms`,{method:"POST",headers:n,body:JSON.stringify(r)});if(!o.ok){this.controller.showFormStatus(t,"error");const e=await o.json().catch((()=>({})));throw new Error(e.message||`Request failed with status ${o.status}`)}this.controller.showFormStatus(t,"submitted"),this.controller.showSummary(t,".jvb-form-block")}catch(o){throw o}finally{this.controller.store.delete(t)}}}document.addEventListener("DOMContentLoaded",(function(){new o}))})();
(()=>{class o{constructor(){this.controller=new window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((o=>{this.controller.registerForm(o,{autosave:!0,autoUpload:!1})})),this.controller.subscribe(((o,r)=>{"form-submit"===o&&this.handleFormSubmission(r)}))}async handleFormSubmission(o){let r=o.formId,e=o.config,t=o.fullData,n=e.element;const s=new FormData;for(const[o,r]of Object.entries(t))"_wpnonce"!==o&&"_wp_http_referer"!==o&&(Array.isArray(r)?r.forEach((r=>s.append(`${o}[]`,r))):"object"==typeof r&&null!==r?s.append(o,JSON.stringify(r)):s.append(o,r));window.jvbUploads&&(await window.jvbUploads.getFilesForForm(n)).forEach((({file:o,fieldName:r,uploadId:e,meta:t})=>{s.append(`${r}[]`,o)}));let l={};n.closest(".jvb-form-block"),this.controller.showFormStatus(r,"uploading");try{const o=await fetch(`${jvbSettings.api}forms`,{method:"POST",credentials:"same-origin",headers:l,body:s}),e=await o.json();if(!o.ok)return this.controller.showFormStatus(r,"error"),void this.controller.handleFormError(n,e);if(this.controller.showFormStatus(r,"submitted"),this.controller.showSummary(r,".jvb-form-block"),window.jvbUploads){const o=n.querySelectorAll("[data-upload-field]");for(const r of o){const o=window.jvbUploads.determineFieldId(r);await window.jvbUploads.clearFieldFromStores(o)}}}catch(o){console.error("Form submission error:",o),this.controller.showFormStatus(r,"error"),this.controller.handleFormError(n,{message:"Network error. Please check your connection and try again.",code:"network_error"})}finally{this.controller.store.delete(r)}}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((r=>{"auth-loaded"===r&&new o}))}))})();
build/gmbreviews/render.php
@@ -57,12 +57,12 @@
        ob_start();
        ?>
        <div class="gmb-reviews">
            <div class="row btw">
            <div class="row center">
            <?php
            if ($showStats && !empty($average) && !empty($total)) {
                ?>
                <p>
                    <span class="stars" aria-label="<?= $average ?> out of 5 stars">
                    <span class="stars" title="<?= $average ?> out of 5 stars">
                        <?php
                        $fullStars = floor($average);
                        $hasHalfStar = ($average - $fullStars) >= 0.5;
@@ -91,6 +91,8 @@
            }
            ?>
        </div>
            <?php
            if ($showReviewLink && !empty($reviewUrl)) {
                ?>
@@ -104,7 +106,6 @@
                <?php
            }
            ?>
        </div>
        <ul>
            <?php
@@ -145,6 +146,14 @@
                            <?php } ?>
                            <div class="row start 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
@@ -155,14 +164,7 @@
                                        <?= esc_html($formatted_date) ?>
                                    </time>
                                <?php } ?>
                                <?php if ($showRating && $rating > 0) { ?>
                                    <div class="stars" aria-label="<?= $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 } ?>
                            </div>
                        </cite>
                    </blockquote>
build/gmbreviews/style-index-rtl.css
@@ -1 +1 @@
.gmb-reviews{max-width:none}.gmb-reviews>.row.btw{max-width:var(--wide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--wider);padding:0}.gmb-reviews ul li{background-color:var(--base-100);margin:2rem 0;padding:1rem;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){right:-2rem}.gmb-reviews ul li:nth-of-type(2n){left:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{right:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--radius-outer);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{right:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
.gmb-reviews{max-width:none}.gmb-reviews>.row.center{margin:0 auto;max-width:var(--content);--gap:.5rem 6rem}.gmb-reviews>.row.center p{width:-moz-fit-content;width:fit-content}.gmb-reviews .button{display:flex;height:-moz-max-content;height:max-content;margin:0 auto 2rem;width:66.6%}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:flex-start}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--full);padding:0}.gmb-reviews ul li{max-width:none;padding:4rem 1rem;width:100%}.gmb-reviews ul li:nth-of-type(odd){background-color:var(--base-50)}.gmb-reviews ul li:nth-of-type(odd) blockquote{--background:var(--base-50)}.gmb-reviews ul li:nth-of-type(2n){background-color:var(--base-100)}.gmb-reviews ul li:nth-of-type(2n) blockquote{--background:var(--base-100)}.gmb-reviews blockquote{margin:0 auto;max-width:var(--content);padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{right:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--radius-outer);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{right:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
build/gmbreviews/style-index.css
@@ -1 +1 @@
.gmb-reviews{max-width:none}.gmb-reviews>.row.btw{max-width:var(--wide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--wider);padding:0}.gmb-reviews ul li{background-color:var(--base-100);margin:2rem 0;padding:1rem;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){left:-2rem}.gmb-reviews ul li:nth-of-type(2n){right:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{left:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--radius-outer);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{left:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
.gmb-reviews{max-width:none}.gmb-reviews>.row.center{margin:0 auto;max-width:var(--content);--gap:.5rem 6rem}.gmb-reviews>.row.center p{width:-moz-fit-content;width:fit-content}.gmb-reviews .button{display:flex;height:-moz-max-content;height:max-content;margin:0 auto 2rem;width:66.6%}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:flex-start}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--full);padding:0}.gmb-reviews ul li{max-width:none;padding:4rem 1rem;width:100%}.gmb-reviews ul li:nth-of-type(odd){background-color:var(--base-50)}.gmb-reviews ul li:nth-of-type(odd) blockquote{--background:var(--base-50)}.gmb-reviews ul li:nth-of-type(2n){background-color:var(--base-100)}.gmb-reviews ul li:nth-of-type(2n) blockquote{--background:var(--base-100)}.gmb-reviews blockquote{margin:0 auto;max-width:var(--content);padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{left:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--radius-outer);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{left:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
inc/admin/Integrations.php
@@ -64,20 +64,28 @@
        wp_enqueue_script(
            'jvb-admin-scripts',
            JVB_URL . 'assets/js/min/integrations.min.js',
            [],
            ['jvb-auth'],
            '1.0.0',
            true
        );
        wp_localize_script(
            'jvb-admin-scripts',
            'jvbSettings',
            [
        $queue = [
                'api'    => rest_url('jvb/v1/'),
                'currentUser'   => get_current_user_id(),
                'nonce'  => wp_create_nonce('wp_rest'),
            ]
        );
            'redirect' => wp_login_url(home_url(add_query_arg(null, null))),
            'labels' => jvbGetLabels(),
        ];
        wp_localize_script('jvb-auth', 'jvbSettings', $queue);
//
//      wp_localize_script(
//          'jvb-admin-scripts',
//          'jvbSettings',
//          [
//              'api'    => rest_url('jvb/v1/'),
//              'currentUser'   => get_current_user_id(),
//              'nonce'  => wp_create_nonce('wp_rest'),
//          ]
//      );
    }
    /**
inc/blocks/FormBlock.php
@@ -5,6 +5,7 @@
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use Exception;
use JVBase\utility\Features;
use WP_Block;
if (!defined('ABSPATH')) {
@@ -36,7 +37,6 @@
    public function __construct()
    {
        $this->cache = CacheManager::for('form_blocks', WEEK_IN_SECONDS);
        // Initialize forms from filter
        $this->forms = $this->registerForms();
        $this->form_contact = apply_filters('jvb_form_contact', '');
@@ -47,6 +47,28 @@
        // Register forms data for the block editor
        add_action('enqueue_block_editor_assets', [$this, 'localizeFormsData']);
        add_action('init', [$this, 'registerBlock']);
        add_filter('render_block', [$this, 'maybeEnqueueScripts'], 10, 2);
    }
    /**
     * Enqueue scripts when rendering form block
     */
    public function maybeEnqueueScripts(string $block_content, array $block): string
    {
        // Only process our form blocks
        if ($block['blockName'] !== 'jvb/forms') {
            return $block_content;
        }
        // Enqueue Turnstile if needed
        if (Features::forSite()->hasIntegration('cloudflare')) {
            $cloudflare = JVB()->connect('cloudflare');
            if ($cloudflare->isSetUp()) {
                $cloudflare->enqueueTurnstileScripts();
            }
        }
        return $block_content;
    }
    public function registerBlock()
@@ -264,7 +286,7 @@
        }
        echo '<form id="' . esc_attr($form_id) . '" data-form-id="'.esc_attr($type).'" data-save="form" data-noautosave>';
        wp_nonce_field('jvb_form_' . $type);
//      wp_nonce_field('jvb_form_' . $type);
    }
    /**
@@ -392,7 +414,7 @@
     */
    protected function renderTurnstile(): void
    {
        if (!jvbSiteUsesCloudflare()) {
        if (!Features::hasIntegration('cloudflare')) {
            return;
        }
inc/blocks/VideoCoverBlock.php
@@ -57,7 +57,8 @@
        $date = date('c',strtotime($post->post_date));
        $title = $attributes['title'] ?? $post->post_title;
        $description = $attributes['description'] ?? $post->post_excerpt;
        $title = "Legacy Tattoo Removal";
        $description="A video of Madi Rawson performing laser tattoo removal treatments.";
        // If no video sources, return empty
        if (empty($video_sources)) {
            return '';
inc/helpers/ui.php
@@ -152,6 +152,9 @@
 */
function jvbHelpMenu():string
{
    if (!Features::forSite()->has('helpMenu')) {
        return '';
    }
    $out = get_option(BASE.'help_menu');
    if ($out === false) {
inc/integrations/Cloudflare.php
@@ -11,7 +11,6 @@
{
    private string $site_key;
    private string $secret_key;
    private bool $auto_protect_forms = true;
    private string $theme = 'light';
    private string $size = 'normal';
@@ -56,13 +55,9 @@
    {
        $this->site_key = $this->credentials['site_key'] ?? '';
        $this->secret_key = $this->credentials['secret_key'] ?? '';
        $this->auto_protect_forms = $this->credentials['auto_protect_forms'] ?? true;
        $this->theme = $this->credentials['theme'] ?? 'light';
        $this->size = $this->credentials['size'] ?? 'normal';
        if ($this->isSetUp()) {
            $this->initializeTurnstileProtection();
        }
    }
    /**
@@ -105,36 +100,6 @@
        return array_key_exists('success', $result);
    }
    /**
     * Initialize Turnstile protection on various forms
     */
    private function initializeTurnstileProtection(): void
    {
        // Skip on local development if configured
        if (defined('JVB_LOCAL') && strpos(get_home_url(), JVB_LOCAL) !== false) {
            return;
        }
        if (!$this->auto_protect_forms) {
            return;
        }
        // WordPress login/registration forms
        add_action('login_enqueue_scripts', [$this, 'enqueueTurnstileScripts']);
        add_action('login_form', [$this, 'renderTurnstile']);
        add_action('register_form', [$this, 'renderTurnstile']);
        add_action('lostpassword_form', [$this, 'renderTurnstile']);
        // Verification hooks
        add_filter('authenticate', [$this, 'verifyLoginTurnstile'], 99, 3);
        add_filter('registration_errors', [$this, 'verifyRegisterTurnstile'], 10, 3);
        add_action('lostpassword_post', [$this, 'verifyLostpasswordTurnstile']);
        // Custom form support
        add_action('jvb_form_before_submit', [$this, 'renderTurnstile']);
        add_filter('jvb_form_validate', [$this, 'validateFormTurnstile'], 10, 2);
    }
    protected function validateCredentials(array $credentials): bool
    {
        if (empty($credentials['site_key'])) {
@@ -218,6 +183,7 @@
     */
    public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool
    {
        $this->ensureInitialized();
        if (!$this->isSetUp()) {
            return false;
        }
@@ -265,78 +231,6 @@
    }
    /**
     * Verify login form Turnstile
     */
    public function verifyLoginTurnstile($user, string $username, string $password)
    {
        // Skip verification if already logged in or no credentials
        if (is_user_logged_in() || empty($username) || empty($password)) {
            return $user;
        }
        // Skip on AJAX requests for compatibility
        if (wp_doing_ajax()) {
            return $user;
        }
        // If already have an error, return it
        if (is_wp_error($user)) {
            return $user;
        }
        // Verify Turnstile
        if (!$this->verifyTurnstile()) {
            return new \WP_Error(
                'turnstile_verification_failed',
                '<strong>ERROR</strong>: Please complete the security check.'
            );
        }
        return $user;
    }
    /**
     * Verify registration form Turnstile
     */
    public function verifyRegisterTurnstile($errors, string $sanitized_user_login, string $user_email)
    {
        if (!$this->verifyTurnstile()) {
            $errors->add(
                'turnstile_verification_failed',
                '<strong>ERROR</strong>: Please complete the security check.'
            );
        }
        return $errors;
    }
    /**
     * Verify lost password form Turnstile
     */
    public function verifyLostpasswordTurnstile($errors): void
    {
        if (!$this->verifyTurnstile()) {
            if (!is_wp_error($errors)) {
                $errors = new \WP_Error();
            }
            $errors->add(
                'turnstile_verification_failed',
                '<strong>ERROR</strong>: Please complete the security check.'
            );
        }
    }
    /**
     * Validate Turnstile for custom JVB forms
     */
    public function validateFormTurnstile(array $errors, array $form_data): array
    {
        if (!$this->verifyTurnstile()) {
            $errors[] = 'Please complete the security verification.';
        }
        return $errors;
    }
    /**
     * Get site key for frontend use
     */
    public function getSiteKey(): string
inc/integrations/Helcim.php
@@ -1136,20 +1136,23 @@
    private function sendWelcomeEmail(\WP_User $user, string $reset_key): void
    {
        $site_name = get_bloginfo('name');
        $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login');
        $reset_url = get_home_url(null, "login?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login');
        $message = sprintf(
            "Welcome to %s!\n\n" .
            "Your account has been created. Please click the link below to set your password:\n\n" .
            "Your account has been created. Please click the button below to set your password:\n\n" .
            "%s\n\n" .
            "Or, copy and paste the link below:\n\n".
            "%s\n\n" .
            "Once you've set your password, you can:\n" .
            "- View your order history\n" .
            "- Save your favorite items\n" .
            "- Speed up checkout with saved payment methods\n\n" .
            "Thanks,\n%s",
            "If you didn't create this account, please ignore this email.\n\n" .
            "Thanks,\n",
            $site_name,
            $reset_url,
            $site_name
            JVB()->email()->button('Reset Password', $reset_url),
            JVB()->email()->link($reset_url),
        );
        JVB()->email()->sendEmail(
inc/integrations/Integrations.php
@@ -774,9 +774,6 @@
            return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
        }
        // Debug: Check if credentials are loaded
        error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
        error_log('With Credentials: '.print_r($this->credentials, true));
        $attempt = 0;
        $lastError = null;
@@ -1215,14 +1212,10 @@
    public function handleAjaxResponse()
    {
        error_log('Ajax Response: '.print_r($_GET, true));
        $code = $_GET['code'];
        $state =  $_GET['state'];
        error_log('OAuth Callback - Code: ' . $code);
        error_log('OAuth Callback - State: ' . $state);
        $state_parts = explode('|', $state);
        $state_key = $state_parts[0] ?? '';
@@ -1230,16 +1223,13 @@
        $user_id = ($user_id === 0) ? null : $user_id;
        $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
        error_log('Service: '.print_r($this->service_name, true));
        $state_data = get_transient('oauth_state_' . $state_key);
        error_log('State Data: '.print_r($state_data, true));
        if (!$state_data || $state_data['service'] !== $this->service_name) {
            wp_die('Invalid state parameter', 'OAuth Error');
        }
        // Delete the transient to prevent reuse
        delete_transient('oauth_state_' . $state_key);
        error_log('Return URL: '.print_r($return_url, true));
        // Handle error from OAuth provider
        if (array_key_exists('error', $_GET)) {
            $error_description = $_GET['error_description'] ?? 'Authorization denied';
@@ -1637,8 +1627,6 @@
        $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
        // Debug log for troubleshooting
        error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
        return $auth_url;
    }
inc/integrations/Square.php
@@ -1849,17 +1849,19 @@
        $message = sprintf(
            "Welcome to %s!\n\n" .
            "Your account has been created. Please click the link below to set your password:\n\n" .
            "Your account has been created. Please click the button below to set your password:\n\n" .
            "%s\n\n" .
            "Once you've set your password, you can log in to:\n" .
            "Or, copy and paste the link below:\n\n".
            "%s\n\n" .
            "Once you've set your password, you can:\n" .
            "- View your order history\n" .
            "- Save your favorite items\n" .
            "- Speed up checkout with saved payment methods\n\n" .
            "If you didn't create this account, please ignore this email.\n\n" .
            "Thanks,\n%s",
            "Thanks,\n",
            $site_name,
            $reset_url,
            $site_name
            JVB()->email()->button('Reset Password', $reset_url),
            JVB()->email()->link($reset_url),
        );
        JVB()->email()->sendEmail(
inc/managers/CRUDManager.php
@@ -81,14 +81,15 @@
        // Statuses
        if (Features::forContent($this->content)->has('is_calendar')) {
            $this->skeleton->setCalendar();
        }else {
            $this->skeleton->setDefaultStatus();
        }
        $this->skeleton->setDefaultStatus();
        // Views
        $this->skeleton
            ->addViews(['grid', 'list', 'table'])
            ->addViews()
            ->defaultView('grid');
        $this->skeleton->addItemActions();
        // Filters
        $this->skeleton->addDateFilter();
inc/managers/EmailManager.php
@@ -24,7 +24,7 @@
class EmailManager
{
   private array $colours = JVB_EMAIL['colours'];
   public array $colours = JVB_EMAIL['colours'];
   private string $title = JVB_EMAIL['content']['title'];
   private string $prefix = JVB_EMAIL['content']['subjectPrefix'];
   private string $signature = JVB_EMAIL['content']['signature'];
@@ -82,7 +82,7 @@
     * @param string $header Optional header text for the template
     * @return bool Whether the email was sent successfully
     */
    public function sendEmail(string $to, string $subject, string $message, string $header = ''):bool
    public function sendEmail(string $to, string $subject, string $message, string $header = '', array $headers = [], array $attachments = []):bool
    {
        // Make sure the content type is set to HTML
        add_filter('wp_mail_content_type', [$this, 'setHtmlContentType']);
@@ -91,7 +91,7 @@
        $formatted_message = $this->getEmailTemplate($message, $header);
        // Send the email
        $result = wp_mail($to, $subject, $formatted_message);
        $result = wp_mail($to, $subject, $formatted_message, $headers, $attachments);
        // Reset content type filter to avoid affecting other emails
        remove_filter('wp_mail_content_type', [$this, 'setHtmlContentType']);
@@ -105,13 +105,17 @@
     *
     * @return string
     */
    private function getEmailTemplate(string $content, string $header = ''):string
    private function getEmailTemplate(string $content, string $headerText = ''):string
    {
        $logo = get_custom_logo();
        $custom_logo_id = get_theme_mod( 'custom_logo' );
        $logo_thumbnail = wp_get_attachment_image_src( $custom_logo_id, 'custom-logo-thumbnail' );
        $logo = ($logo_thumbnail) ? '<img src="' . esc_url( $logo_thumbnail[0] ) . '" alt="Site Logo" width="150" height="150" style="max-width:120px;height:auto;">' : '';
        // Default header if none provided
        if (empty($header)) {
            $header = $this->title;
        if (empty($headerText)) {
            $headerText = $this->title;
        }
        return '<!DOCTYPE html>
@@ -120,6 +124,27 @@
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>' . $this->title . ' | ' . $this->site_name .'</title>
        </head>
        <body style="font-family:Segoe UI, Tahoma, Geneva, Verdana, sans-serif;background-color: '.$this->colours['light'].';color:'.$this->colours['dark'].';line-height:1.5;margin:0;padding:0;">
            <div style="background-color:'.$this->colours['dark'].';color:'.$this->colours['light'].';padding:5px;text-align:center;font-size:24px;font-weight:900;letter-spacing:1.5px;text-transform:uppercase;border-radius:5px 5px 0 0;">
                <a href="' . $this->site_url . '" style="display:inline-block;vertical-align:middle;">
                    ' . $logo . '
                </a>
                <p style="display:inline-block;vertical-align:middle;margin: 0 auto 0 0;">' . $headerText . '</p>
            </div>
            <div style="padding:4rem 0;width:100%;max-width:600px;margin:0 auto;">
                ' . $content . '
                '.$this->signature().'
            </div>
            <div style="border-radius:0 0 5px 5px;text-align:center;margin-top:20px;font-size:12px;background-color:'.$this->colours['light-200'].'; color: '.$this->colours['dark-200'].';padding:10px;line-height:1.6;">
                ' . $this->footer . '
            </div>
        </body>
        </html>';
    }
    private function oldStyle(){
        $oldStyle ='
            <style>
                body, table, td, p, a, li, blockquote {
                    line-height: 1.5;
@@ -161,8 +186,7 @@
                    vertical-align: middle;
                }
                .header p {
                    margin: 0;
                    margin-left: auto;
                    margin: 0 auto 0 0;
                }
                .content {
@@ -194,12 +218,24 @@
                    text-align: center;
                    margin-top: 20px;
                    font-size: 12px;
                    backgorund-color: ' . $this->colours['light-200'] . ';
                    background-color: ' . $this->colours['light-200'] . ';
                    color: ' . $this->colours['dark-200'] . ';
                    padding: 10px;
                    line-height: 1.6;
                }
                .notice {
                    text-align: center;
                    border-radius: 0 8px 8px 0;
                    margin: 1rem 0;
                    border-left: 4px solid '.$this->colours['action-0'].';
                    padding: 1rem;
                    background-color: '.$this->colours['light-100'].';
                }
                .notice strong {
                    color: '.$this->colours['action-0'].';
                }
                .divider {
                    border-top: 1px solid ' . $this->colours['dark-200'] . ';
                    margin: 25px 0;
@@ -300,25 +336,7 @@
    }
}
            </style>
        </head>
        <body>
            <div class="email-container">
                <div class="header">
                    <a href="' . $this->site_url . '">
                        ' . $logo . '
                    </a>
                    <p>' . $header . '</p>
                </div>
                <div class="content">
                    ' . $content . '
                </div>
                <div class="footer">
                    ' . $this->footer . '
                </div>
            </div>
        </body>
        </html>';
        ';
    }
    /**
@@ -336,8 +354,6 @@
            $user->user_login,
        );
        $message = apply_filters('jvbNewUserEmail', $message, $user);
        $message .= $this->signature;
        $wp_new_user_notification_email['message'] = $this->getEmailTemplate($message, JVB_EMAIL['types']['newUser']['subject']?:'New User');
        // Change the subject line
@@ -362,7 +378,6 @@
        $message .= '<p><strong>Username:</strong> ' . $user->user_login . '</p>';
        $message .= '<p><strong>Email:</strong> ' . $user->user_email . '</p>';
        $message = apply_filters('jvbNewUserAdminEmail', $message, $user);
        $message .= $this->signature;
        $emailData['message'] = $this->getEmailTemplate($message, 'New User Registration');
        $emailData['subject'] = $this->prefix .'New ' . str_replace(BASE, '', array_values($user->roles)[0]).': '.$user->display_name;
@@ -390,15 +405,15 @@
            %s
            <p>Or copy and paste this link into your browser:</p>
            %s
            <div class="divider"></div>
            %s
            <p>This password reset link is only valid for 24 hours.</p>',
            $user->display_name,
            $user_login,
            JVB()->email()->button($reset_url,'Reset Password'),
            JVB()->email()->link($reset_url)
            $this->button($reset_url,'Reset Password'),
            $this->link($reset_url),
            $this->divider()
        );
        $content = apply_filters('jvbPasswordResetEmail', $content, $user_login, $user, $reset_url);
        $content .= $this->signature;
        return $this->getEmailTemplate($content, 'Password Reset');
    }
@@ -432,16 +447,16 @@
        <p>Ideally you already know this: someone asked to change the email for your account.</p>
        <p><strong>Old Email:</strong> %s</p>
        <p><strong>New Email:</strong> %s</p>
        <div class="divider"></div>
        %s
        <p>If this is news to you, or you did not request this - please contact us immediately. You can <a href="sms:+18258239916">text us</a> or reply to this email."></a></p>
        %s',
            $newUser['first_name'],
            $oldUser['user_email'],
            $newUser['user_email'],
            JVB()->email()->button(wp_login_url(), 'Log In To Your Account')
            $this->divider(),
            $this->button(wp_login_url(), 'Log In To Your Account')
        );
        $content = apply_filters('jvbEmailChangeRequestEmail', $content, $oldUser, $newUser);
        $content .= $this->signature;
        $email_change_email['message'] = $this->getEmailTemplate($content, 'Email Address Changed');
        $prefix = JVB_EMAIL['types']['emailChange']['showPrefix']??true;
        $prefix = ($prefix) ? $this->prefix : '';
@@ -469,15 +484,14 @@
            %s
            <p>Or copy and paste this link into your browser:</p>
            %s',
            JVB()->email()->button($confirm_url, 'Confirm this Email'),
            JVB()->email()->link($confirm_url)
            $this->button($confirm_url, 'Confirm this Email'),
            $this->link($confirm_url)
        );
        $content = apply_filters('jvbEmailChangedEmail', $content, $confirm_url);
        $content .= '<div class="divider"></div>';
        $content .= $this->divider();
        $content .= '<p>If you did not request this change, you can safely ignore this email and nothing will change.</p>';
        $content .= $this->signature;
        return $this->getEmailTemplate($content, 'Confirm Email Change');
    }
@@ -499,10 +513,9 @@
            <p>You can <a href="sms:+18259257398">text us</a>, or reply to this email.</p>
            %s',
            $oldUser['first_name'],
            JVB()->email()->button(wp_login_url(), 'Log In to Your Account')
            $this->button(wp_login_url(), 'Log In to Your Account')
        );
        $content = apply_filters('jvbPasswordChangeEmail', $content, $oldUser, $newUser);
        $content .= $this->signature;
        $pass_change_email['message'] = $this->getEmailTemplate($content, 'Password Changed');
        $prefix = JVB_EMAIL['types']['passwordChange']['showPrefix']??true;
@@ -545,14 +558,13 @@
            <p>Or copy and paste this link into your browser:</p>
            %s',
            $request_name,
            JVB()->email()->button($confirm_url, 'Confirm'),
            JVB()->email()->link($confirm_url)
            $this->button($confirm_url, 'Confirm'),
            $this->link($confirm_url)
        );
        $message = apply_filters('jvbPersonalDataExport', $message, $request_type, $confirm_url, $email_data);
        $message .= '<div class="divider"></div>';
        $message .= $this->divider();
        $message .= '<p>If you did not make this request, you can safely ignore this email.</p>';
        $message .= $this->signature;
        return $this->getEmailTemplate($message, 'Action Confirmation');
    }
@@ -577,28 +589,30 @@
            %s
            <p>Or you can copy and paste this link into your browser:</p>
            %s
            <div class="divider"></div>
            %s
            <p><strong>Important:</strong> For privacy and security, this link will expire at %s.</p>',
            JVB()->email()->button($download_url, 'Download Your Data'),
            JVB()->email()->link($download_url),
            $this->button($download_url, 'Download Your Data'),
            $this->link($download_url),
            $this->divider(),
            $expiresAt
        );
        $message = apply_filters('jvbPersonalDataExported', $message, $download_url, $expiresAt, $email_data);
        $message .= $this->signature;
        return $this->getEmailTemplate($message, 'Your Personal Data Export');
    }
    public function signature():string
    {
        return $this->signature;
        return '<p><i>'.$this->signature.'</i></p>';
    }
    public function button(string $link, string $title):string
    {
        return sprintf(
            '<p style="text-align: center;"><a href="%s" class="button">%s</a></p>',
            '<p style="text-align: center;"><a href="%s" style="display:inline-block;padding:16px 10px;border-radius:4px;background-color:%s;color:%s;text-decoration:none;font-weight:bold;margin:15px 0;letter-spacing:1px;text-transform:uppercase;">%s</a></p>',
            $link,
            $this->colours['action-0'],
            $this->colours['action-contrast'],
            $title
        );
    }
@@ -606,11 +620,263 @@
    public function link(string $link):string
    {
        return sprintf(
            '<p style="user-select:all;">%s</p>',
            '<code style="color:%s;border:1px solid %s;background-color:%s;border-radius:4px;user-select:all;">%s</code>',
            $this->colours['dark-200'],
            $this->colours['dark-100'],
            $this->colours['light-100'],
            $link
        );
    }
    public function divider():string
    {
        return '<div style="border-top:1px solid '.$this->colours['dark-200'].';margin:25px 0;"></div>';
    }
    public function notice(string $text):string
    {
        return '<div style="border-radius: 0 8px 8px 0; margin: 1rem 0; border-left: 4px solid '.$this->colours['action-0'].';padding:1rem;background-color:'.$this->colours['light-100'].';">
        '.str_replace('<strong>', '<strong style="color:'.$this->colours['action-0'].'">',$text).'
        </div>';
    }
    public function callout(string $text):string
    {
        return sprintf(
            '<div style="padding:2rem;margin:2rem 3rem;background-color:%s;color:%s;">%s</div>',
            $this->colours['action-0'],
            $this->colours['action-contrast'],
            str_replace('<a', '<a style="background-color:'.$this->colours['action-contrast'].';padding: 0 .125rem;border-radius:4px;"', $text)
        );
    }
    public function table(array $summarize, string $title = '', array $actions = []):string
    {
        if (empty($summarize)){
            return '';
        }
        if (!empty($title)) {
            $title = sprintf(
            '<h2 style="color:%s;border-bottom:2px solid %s;padding-bottom:15px;margin-bottom:20px;text-align:center;">%s</h2>',
            $this->colours['dark-200'],
            $this->colours['dark-200'],
            $title
            );
        }
        $content = '';
        foreach ($summarize as $index=> $item) {
            if (!array_key_exists('label', $item) && !array_key_exists('value', $item)) {
                continue;
            }
            $content .= sprintf(
                '<div style="padding:10px 0;border-bottom:1px solid %s;background-color:%s;">
                    <span style="display:inline-block;vertical-align:top;font-weight:600;color:%s;width:%s;">%s</span>
                    <div style="display:inline-block;vertical-align:top;width:%s;">%s</div>
                </div>',
                $this->colours['dark-200'],
                ($index%2 === 0) ? $this->colours['light-100'] : $this->colours['light-50'],
                $this->colours['dark-200'],
                '19%',
                $item['label'],
                '80%',
                $item['value']
            );
        }
        return sprintf(
            '<div style="max-width:500px;margin:40px auto;padding:30px;background-color:%s;border-radius:10px;border:2px dashed %s;">%s%s</div>',
            $this->colours['light-100'],
            $this->colours['dark-200'],
            $title,
            $content
        );
    }
    public function card(string $content, string $title = ''):string
    {
        $titleHtml = '';
        if (!empty($title)) {
            $titleHtml = sprintf(
                '<h3 style="margin:0 0 15px 0;color:%s;font-size:18px;font-weight:600;">%s</h3>',
                $this->colours['dark'],
                esc_html($title)
            );
        }
        return sprintf(
            '<div style="background-color:%s;border:1px solid %s;border-radius:8px;padding:20px;margin:15px 0;">%s%s</div>',
            $this->colours['light-50'],
            $this->colours['light-200'],
            $titleHtml,
            $content
        );
    }
    public function stat(string $number, string $label, string $description = ''):string
    {
        $desc = $description ? sprintf(
            '<p style="margin:5px 0 0 0;font-size:12px;color:%s;">%s</p>',
            $this->colours['dark-200'],
            esc_html($description)
        ) : '';
        return sprintf(
            '<div style="text-align:center;padding:20px;background-color:%s;border-radius:8px;margin:10px 0;">
            <div style="font-size:36px;font-weight:700;color:%s;margin:0 0 5px 0;">%s</div>
            <div style="font-size:14px;font-weight:600;color:%s;text-transform:uppercase;letter-spacing:1px;">%s</div>
            %s
        </div>',
            $this->colours['light-100'],
            $this->colours['action-0'],
            esc_html($number),
            $this->colours['dark-200'],
            esc_html($label),
            $desc
        );
    }
    public function grid(array $items, int $columns = 2):string
    {
        $width = floor(100 / $columns) - 2; // 2% gap
        $html = '<div style="display:table;width:100%;margin:20px 0;">';
        foreach ($items as $index => $item) {
            if ($index > 0 && $index % $columns === 0) {
                $html .= '</div><div style="display:table;width:100%;margin:20px 0;">';
            }
            $html .= sprintf(
                '<div style="display:table-cell;width:%d%%;padding:10px;vertical-align:top;">%s</div>',
                $width,
                $item
            );
        }
        $html .= '</div>';
        return $html;
    }
    public function image(string $src, string $alt = '', int $maxWidth = 600):string
    {
        return sprintf(
            '<div style="text-align:center;margin:20px 0;">
            <img src="%s" alt="%s" style="max-width:%dpx;width:100%%;height:auto;border-radius:8px;" />
        </div>',
            esc_url($src),
            esc_attr($alt),
            $maxWidth
        );
    }
    public function h1(string $text):string
    {
        return sprintf(
            '<h1 style="color:%s;font-size:24px;font-weight:700;margin:20px 0 10px 0;">%s</h1>',
            $this->colours['dark'],
            $text
        );
    }
    public function h2(string $text):string
    {
        return sprintf(
            '<h2 style="color:%s;font-size:20px;font-weight:600;margin:20px 0 10px 0;border-bottom:2px solid %s;padding-bottom:10px;">%s</h2>',
            $this->colours['dark'],
            $this->colours['dark-200'],
            $text
        );
    }
    public function h3(string $text):string
    {
        return sprintf(
            '<h3 style="color:%s;font-size:18px;font-weight:600;margin:15px 0 10px 0;">%s</h3>',
            $this->colours['dark-200'],
            $text
        );
    }
    public function list(array $items, bool $ordered = false):string
    {
        $tag = $ordered ? 'ol' : 'ul';
        $style = $ordered
            ? 'list-style-type:decimal;padding-left:20px;margin:10px 0;'
            : 'list-style-type:disc;padding-left:20px;margin:10px 0;';
        $html = sprintf('<%s style="%s">', $tag, $style);
        foreach ($items as $item) {
            $html .= sprintf(
                '<li style="margin:5px 0;line-height:1.6;">%s</li>',
                $item
            );
        }
        $html .= sprintf('</%s>', $tag);
        return $html;
    }
    public function codeBlock(string $code, string $language = ''):string
    {
        return sprintf(
            '<pre style="background-color:%s;border:1px solid %s;border-radius:4px;padding:15px;overflow-x:auto;font-family:\'Courier New\',monospace;font-size:13px;line-height:1.4;"><code>%s</code></pre>',
            $this->colours['light-100'],
            $this->colours['dark-200'],
            esc_html($code)
        );
    }
    public function badge(string $text, string $type = 'default'):string
    {
        $colors = [
            'success' => ['bg' => '#d4edda', 'text' => '#155724'],
            'warning' => ['bg' => '#fff3cd', 'text' => '#856404'],
            'error' => ['bg' => '#f8d7da', 'text' => '#721c24'],
            'info' => ['bg' => '#d1ecf1', 'text' => '#0c5460'],
            'default' => ['bg' => $this->colours['light-200'], 'text' => $this->colours['dark-200']]
        ];
        $color = $colors[$type] ?? $colors['default'];
        return sprintf(
            '<span style="display:inline-block;padding:4px 12px;border-radius:12px;background-color:%s;color:%s;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">%s</span>',
            $color['bg'],
            $color['text'],
            esc_html($text)
        );
    }
    public function alert(string $text, string $type = 'info'):string
    {
        $configs = [
            'success' => ['bg' => '#d4edda', 'border' => '#28a745', 'text' => '#155724', 'icon' => '✓'],
            'warning' => ['bg' => '#fff3cd', 'border' => '#ffc107', 'text' => '#856404', 'icon' => '⚠'],
            'error' => ['bg' => '#f8d7da', 'border' => '#dc3545', 'text' => '#721c24', 'icon' => '✕'],
            'info' => ['bg' => '#d1ecf1', 'border' => $this->colours['action-0'], 'text' => '#0c5460', 'icon' => 'ℹ']
        ];
        $config = $configs[$type] ?? $configs['info'];
        return sprintf(
            '<div style="border-radius:8px;margin:1rem 0;border-left:4px solid %s;padding:1rem;background-color:%s;color:%s;">
            <strong style="font-size:16px;">%s</strong> %s
        </div>',
            $config['border'],
            $config['bg'],
            $config['text'],
            $config['icon'],
            $text
        );
    }
    public function spacer(int $height = 20):string
    {
        return sprintf('<div style="height:%dpx;"></div>', $height);
    }
    public function prefix():string
    {
        return $this->prefix;
    }
}
inc/managers/ErrorHandler.php
@@ -252,10 +252,27 @@
    protected function notifyAdmin(string $component, string $message, array $context):bool
    {
        $admin_email = get_option('admin_email');
        $subject = "[edmonton.ink Critical Error] {$component}";
        $body = "Error: {$message}\n\nContext: " . print_r($context, true);
        $subject = "[" . get_bloginfo('name') . " Critical Error] {$component}";
        return JVB()->email()->sendEmail($admin_email, $subject, $body);
        $body = JVB()->email()->alert(
            'A critical error has occurred and requires immediate attention',
            'error'
        );
        $body .= JVB()->email()->h2('Error Details');
        $body .= JVB()->email()->card(
            '<p><strong>Component:</strong> ' . esc_html($component) . '</p>' .
            '<p><strong>Message:</strong></p>' .
            JVB()->email()->codeBlock($message),
            'Error Information'
        );
        if (!empty($context)) {
            $body .= JVB()->email()->h3('Additional Context');
            $body .= JVB()->email()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
        }
        return JVB()->email()->sendEmail($admin_email, $subject, $body, 'CRITICAL ERROR');
    }
    /**
@@ -352,76 +369,89 @@
     */
    public function sendErrorSummary():bool
    {
        // Get summary data
        $summary = $this->gatherErrorSummary();
        // Only send if there are errors
        if (empty($summary['frequent']) && empty($summary['critical'])) {
            return false;
        }
        $admin_email = get_option('admin_email');
        $site_name = get_bloginfo('name');
        $today = date('Y-m-d');
        $yesterday = date('Y-m-d', strtotime('-1 day'));
        $subject = "[{$site_name}] Daily Error Summary - " . date('Y-m-d');
        $subject = "[{$site_name}] Daily Error Summary - {$today}";
        // Header with alert
        $body = JVB()->email()->h1('Daily Error Summary');
        $body .= sprintf('<p>Error summary for <strong>%s</strong></p>', $yesterday);
        // Build email body
        $body = "= Error Summary for {$yesterday} =\n\n";
        // Add frequent errors section
        if (!empty($summary['frequent'])) {
            $body .= "== Most Frequent Errors ==\n\n";
            foreach ($summary['frequent'] as $index => $error) {
                $body .= ($index + 1) . ". [{$error->component}] {$error->error_type}\n";
                $body .= "   Message: " . wp_trim_words($error->message, 20, '...') . "\n";
                $body .= "   Count: {$error->count}\n\n";
            }
        // Summary stats in a grid
        if (!empty($summary['stats'])) {
            $stats = [
                JVB()->email()->stat($summary['stats']->total_errors, 'Total Errors'),
                JVB()->email()->stat($summary['stats']->critical_count, 'Critical', 'Requires attention'),
                JVB()->email()->stat($summary['stats']->error_count, 'Errors'),
                JVB()->email()->stat($summary['stats']->warning_count, 'Warnings')
            ];
            $body .= JVB()->email()->grid($stats, 4);
        }
        // Add critical errors section
        // Alert if critical errors exist
        if (!empty($summary['critical'])) {
            $body .= "== Recent Critical Errors ==\n\n";
            $body .= JVB()->email()->alert(
                sprintf('Found %d critical errors that need immediate attention', count($summary['critical'])),
                'error'
            );
        }
            foreach ($summary['critical'] as $index => $error) {
                $body .= ($index + 1) . ". [{$error->component}] {$error->error_type}\n";
                $body .= "   Time: {$error->created_at}\n";
                $body .= "   Message: " . $error->message . "\n\n";
        $body .= JVB()->email()->spacer(20);
                // Include context for critical errors if available
        // Frequent errors section
        if (!empty($summary['frequent'])) {
            $body .= JVB()->email()->h2('Most Frequent Errors');
            foreach ($summary['frequent'] as $error) {
                $cardContent = JVB()->email()->badge($error->count . 'x', 'warning') . ' ';
                $cardContent .= '<strong>' . esc_html($error->error_type) . '</strong>';
                $cardContent .= '<p style="margin:10px 0 5px 0;font-size:13px;">' . esc_html(wp_trim_words($error->message, 15)) . '</p>';
                $cardContent .= '<p style="margin:0;font-size:12px;color:' . JVB()->email()->colours['dark-200'] . ';">
                Source: ' . esc_html($error->source) . ' |
                Logged in: ' . $error->logged_in_count . ' |
                Logged out: ' . $error->logged_out_count . '
            </p>';
                $body .= JVB()->email()->card($cardContent, $error->component);
            }
        }
        // Critical errors section
        if (!empty($summary['critical'])) {
            $body .= JVB()->email()->spacer(30);
            $body .= JVB()->email()->h2('Recent Critical Errors');
            foreach ($summary['critical'] as $error) {
                $cardContent = '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>';
                $cardContent .= '<p><strong>Message:</strong></p>';
                $cardContent .= JVB()->email()->codeBlock($error->message);
                // Include context if available
                if (!empty($error->context)) {
                    $context = json_decode($error->context, true);
                    if (is_array($context)) {
                        $body .= "   Context:\n";
                        foreach ($context as $key => $value) {
                            if (is_array($value) || is_object($value)) {
                                $value = json_encode($value);
                            }
                            $body .= "     - {$key}: {$value}\n";
                        }
                    }
                    $body .= "\n";
                }
                        $cardContent .= '<p><strong>Context:</strong></p>';
                        $cardContent .= JVB()->email()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
            }
        }
        // Add dashboard link if available
                $body .= JVB()->email()->card($cardContent, $error->component . ': ' . $error->error_type);
            }
        }
        // Dashboard link
        $admin_url = admin_url('admin.php?page=jvb-error-logs');
        $body .= "View detailed error logs in the dashboard: {$admin_url}\n\n";
        $body .= JVB()->email()->spacer(30);
        $body .= JVB()->email()->button($admin_url, 'View Detailed Logs');
        // Send the email
        $sent = JVB()->email()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY');
        // Log that summary was sent
        if ($sent) {
            error_log("[ErrorHandler] Daily error summary sent to {$admin_email}");
        } else {
            error_log("[ErrorHandler] Daily error summary was not sent.");
        }
        return $sent;
        return JVB()->email()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY');
    }
    /**
inc/managers/FormManager.php
File was deleted
inc/managers/LoginManager.php
@@ -67,8 +67,14 @@
        // Allow other features to register handlers
        do_action('jvbLoginManagerInit', $this);
        add_action('user_register', array($this, 'saveRegistrationFields'), 999, 2);
        add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeLoginSitemap'], 10, 1);
    }
    public function excludeLoginSitemap(array $ids): array
    {
        $ids[] = $this->getLoginPage();
        return $ids;
    }
    /**************************************************************************
       * SETUP & CONFIGURATION
    **************************************************************************/
inc/managers/MagicLinkManager.php
@@ -507,60 +507,54 @@
    protected function getLoginEmailTemplate(string $name, string $magic_url): string
    {
        $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
        $content .= '<p>Click the button below to sign in to your account. This link expires in 15 minutes.</p>';
        $content .= JVB()->email()->button($magic_url, 'Sign In');
        $content .= '<p>Or copy and paste this link into your browser of choice:</p>';
        $content = JVB()->email()->h2('Hey ' . esc_html($name) . '!');
        $content .= '<p>Click the button below to sign in to your account instantly - no password needed!</p>';
        $content .= JVB()->email()->button($magic_url, 'Sign In Now');
        $content .= '<p>Or copy and paste this link into your browser:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= '<p>If you didn\'t request this, you can safely ignore this email. The link will expire in 15 minutes.</p>';
        $content .= JVB()->email()->signature();
        $content .= JVB()->email()->divider();
        $content .= '<p>If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
        return $content;
    }
    protected function getSignupEmailTemplate(string $name, string $magic_url): string
    {
        $content = '<h2>Welcome' . ($name ? ', ' . esc_html($name) : '') . '!</h2>';
        $content .= '<p>You\'re almost there! Click the button below to complete your registration and access your account.</p>';
        $content = JVB()->email()->h2('Welcome' . ($name ? ', ' . esc_html($name) : '') . '!');
        $content .= '<p>Click the button below to complete your registration and access your account!</p>';
        $content .= JVB()->email()->button($magic_url, 'Complete Registration');
        $content .= '<p>Or copy and paste this link into your browser of choice:</p>';
        $content .= '<p>Or copy and paste this link:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= '<p>This link expires in 15 minutes.</p>';
        $content .= JVB()->email()->signature();
        $content .= JVB()->email()->spacer(10);
        $content .= '<p><small>This link expires in 24 hours.</small></p>';
        return $content;
    }
    protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text, array $context): string
    {
        $content = '<h2>Hey' . ($name ? ' ' . esc_html($name) : '') . '!</h2>';
        $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . '!</p>';
        $content = JVB()->email()->h2('Hey' . ($name ? ' ' . esc_html($name) : '') . '!');
        $content .= sprintf(
            '<p><strong>%s</strong> thinks you\'d love %s!</p>',
            esc_html($referrer_name),
            esc_html(get_bloginfo('name'))
        );
        if (array_key_exists('message', $context) && $context['message']!== '') {
            $content .= wpautop($context['message']);
        if (!empty($context['message'])) {
            $content .= JVB()->email()->callout(nl2br(esc_html($context['message'])));
        }
        if ($reward_text) {
            $content .= '<p>' . esc_html($reward_text) . '</p>';
            $content .= JVB()->email()->alert(
                '<strong>Special Welcome Offer:</strong> ' . esc_html($reward_text),
                'success'
            );
        }
        $content .= JVB()->email()->button($magic_url, 'Join Now');
        $content .= '<p>Or copy and paste this link into your browser of choice:</p>';
        $content .= '<p>Or copy and paste this link:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= '<p>This link expires in 14 days.</p>';
        $content .= JVB()->email()->signature();
        $content .= JVB()->email()->spacer(10);
        $content .= '<p><small>This invitation expires in 14 days.</small></p>';
        return $content;
    }
    protected function getResetEmailTemplate(string $name, string $magic_url): string
    {
        $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
        $content .= '<p>We received a request to reset your password. Click the button below to sign in and update your password.</p>';
        $content .= JVB()->email()->button($magic_url, 'Reset Password');
        $content .= '<p>Or copy and paste this link into your browser of choice:</p>';
        $content .= JVB()->email()->link($magic_url);
        $content .= '<p>If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
        $content .= JVB()->email()->signature();
        return $content;
    }
}
inc/managers/NotificationManager.php
@@ -1069,16 +1069,17 @@
        }
        // Add footer content
        $content .= '<div class="divider"></div>';
        $content .= sprintf(
            '<p>You\'re receiving this %s digest because you follow artists on edmonton.ink. ' .
            'You can <a href="%s" class="text-link">adjust your notification settings</a> at any time.</p>',
            $frequency,
            esc_url(add_query_arg([
        $content .= JVB()->email()->divider();
        $settings_url = add_query_arg([
                'utm_source'   => 'email',
                'utm_medium'   => 'digest',
                'utm_campaign' => $this->campaign
            ], site_url('/dash/settings/')))
        ], site_url('/dash/settings/'));
        $content .= sprintf(
            '<p>You\'re receiving this %s digest because you follow artists on edmonton.ink. You can %s at any time.</p>',
            $frequency,
            '<a href="' . esc_url($settings_url) . '">adjust your notification settings</a>'
        );
        return $content;
@@ -1240,8 +1241,7 @@
            return '';
        }
        $content = '<h3>Other Updates</h3>';
        $content .= '<ul style="padding-left: 20px;">';
        $items = [];
        // Group notifications by type
        $by_type = [];
@@ -1254,9 +1254,6 @@
        // Process each type
        foreach ($by_type as $type => $type_notifications) {
            $config = $this->notification_types[ $type ] ?? [];
            $icon   = $config['icon'] ?? 'info';
            foreach ($type_notifications as $notification) {
                $message = $notification->message;
                if (empty($message)) {
@@ -1264,15 +1261,12 @@
                }
                if (!empty($message)) {
                    $content .= sprintf('<li>%s</li>', $message);
                    $items[] = ['label' => '', 'value' => $message];
                }
            }
        }
        $content .= '</ul>';
        $content .= '<div class="divider"></div>';
        return $content;
        return JVB()->email()->table($items, 'Other Updates');
    }
    /**
inc/managers/OperationQueue.php
@@ -1686,28 +1686,23 @@
     */
    public function emailDailyMetricsReport():void
    {
        $metrics_table = $this->wpdb->prefix . $this->metricsTable;
        $yesterday     = date('Y-m-d', strtotime('-1 day'));
        // Get yesterday's metrics
        $metrics = $this->wpdb->get_results($this->wpdb->prepare(
            "SELECT * FROM $metrics_table WHERE date = %s",
            $yesterday
        ));
        if (empty($metrics)) {
            return; // No metrics to report
            return;
        }
        $admin_email = get_option('admin_email');
        $site_name   = get_bloginfo('name');
        $subject = "[$site_name] Daily Queue Performance - " . $yesterday;
        $subject = "[$site_name] Daily Queue Performance Report - " . date('Y-m-d', strtotime('-1 day'));
        $message = "Daily Queue Performance Report for $yesterday\n\n";
        $message       .= "SUMMARY:\n";
        // Calculate totals
        $total_ops     = 0;
        $total_success = 0;
        $total_failed  = 0;
@@ -1720,54 +1715,69 @@
            $total_items   += $metric->total_items_processed;
        }
        $message .= "- Total Operations: $total_ops\n";
        $message .= "- Successful: $total_success\n";
        $message .= "- Failed: $total_failed\n";
        $message .= "- Success Rate: " . round(($total_success / max(1, $total_ops)) * 100, 2) . "%\n";
        $message .= "- Total Items Processed: $total_items\n\n";
        $success_rate = round(($total_success / max(1, $total_ops)) * 100, 1);
        $message .= "DETAILS BY OPERATION TYPE:\n";
        $message = JVB()->email()->h1('Daily Queue Performance Report');
        $message .= sprintf('<p>Report for <strong>%s</strong></p>', $yesterday);
        // Summary stats in grid
        $stats = [
            JVB()->email()->stat($total_ops, 'Operations'),
            JVB()->email()->stat($total_success, 'Successful', '✓'),
            JVB()->email()->stat($total_failed, 'Failed', $total_failed > 0 ? '⚠' : ''),
            JVB()->email()->stat($success_rate . '%', 'Success Rate')
        ];
        $message .= JVB()->email()->grid($stats, 4);
        $message .= JVB()->email()->spacer(20);
        // Alert if success rate is low
        if ($success_rate < 90) {
            $message .= JVB()->email()->alert(
                sprintf('Success rate of %s%% is below the 90%% threshold', $success_rate),
                'warning'
            );
        }
        $message .= JVB()->email()->h2('Details by Operation Type');
        // Details for each operation type
        foreach ($metrics as $metric) {
            $message .= "• $metric->type:\n";
            $message .= "  - Operations: $metric->total_operations\n";
            $message .= "  - Success: $metric->successful_operations\n";
            $message .= "  - Failed: $metric->failed_operations\n";
            $details = [];
            $details[] = ['label' => 'Total', 'value' => $metric->total_operations];
            $details[] = ['label' => 'Success', 'value' => JVB()->email()->badge($metric->successful_operations, 'success')];
            $details[] = ['label' => 'Failed', 'value' => $metric->failed_operations > 0 ? JVB()->email()->badge($metric->failed_operations, 'error') : '0'];
            if ($metric->average_duration) {
                $message .= "  - Avg. Duration: " . round($metric->average_duration, 2) . " seconds\n";
                $details[] = ['label' => 'Avg Duration', 'value' => round($metric->average_duration, 2) . 's'];
            }
            $message .= "  - Items Processed: $metric->total_items_processed\n";
            if ($metric->peak_queue_size) {
                $message .= "  - Peak Queue Size: $metric->peak_queue_size\n";
            }
            $details[] = ['label' => 'Items Processed', 'value' => number_format($metric->total_items_processed)];
            if ($metric->peak_memory_usage) {
                $memory_mb = round($metric->peak_memory_usage / 1024 / 1024, 2);
                $message   .= "  - Peak Memory Usage: $memory_mb MB\n";
                $details[] = ['label' => 'Peak Memory', 'value' => $memory_mb . ' MB'];
            }
            if ($metric->peak_cpu_usage) {
                $cpu_percent = round($metric->peak_cpu_usage * 50, 2); // Assuming 2 cores
                $message     .= "  - Peak CPU Usage: $cpu_percent%\n";
            $message .= JVB()->email()->card(
                JVB()->email()->table($details),
                esc_html($metric->type)
            );
            }
            $message .= "\n";
        }
        // Add any outstanding queue items
        // Current queue status
        $pending_count = $this->getCurrentQueueSize();
        if ($pending_count > 0) {
            $message .= "CURRENT QUEUE STATUS:\n";
            $message .= "- $pending_count operations currently pending in the queue\n\n";
            $message .= JVB()->email()->spacer(20);
            $message .= JVB()->email()->notice(
                sprintf('<strong>Current Queue:</strong> %d operations pending', $pending_count)
            );
        }
        $message .= "This is an automated report. Please check the admin dashboard for more details.";
        $message .= JVB()->email()->spacer(20);
        $message .= JVB()->email()->button(admin_url('admin.php?page=jvb-queue'), 'View Queue Dashboard');
        // Send email
        JVB()->email()->sendEmail($admin_email, $subject, $message);
        JVB()->email()->sendEmail($admin_email, $subject, $message, 'QUEUE REPORT');
    }
    /**
inc/managers/ReferralManager.php
@@ -142,11 +142,17 @@
        ];
        if (Features::hasIntegration('cloudflare') && JVB()->connect('cloudflare')->isSetUp()) {
            $requirements[] = 'cloudflare-turnstile';
            JVB()->connect('cloudflare')->enqueueTurnstileScripts();
        }
        if (is_singular(BASE.'dash')) {
            $requirements[] = 'jvb-form';
            $requirements[] = 'jvb-view';
            wp_enqueue_script('jvb-referral-admin',
            JVB_URL.'assets/js/min/referralAdmin.min.js',
            ['jvb-referral'],
            '1.0.0',
            true);
        }
        wp_enqueue_script(
            'jvb-referral',
@@ -653,11 +659,8 @@
    {
        $yesterday = date('Y-m-d', strtotime('-1 day'));
        // Get new referrals from yesterday
        $new_referrals = $this->wpdb->get_results($this->wpdb->prepare(
            "SELECT
            r.*,
            u.display_name as referrer_name
            "SELECT r.*, u.display_name as referrer_name
        FROM {$this->referrals_table} r
        JOIN {$this->wpdb->users} u ON r.referrer_id = u.ID
        WHERE DATE(r.referred_at) = %s
@@ -665,48 +668,46 @@
            $yesterday
        ));
        // Only send if there's at least 1 new referral
        if (empty($new_referrals)) {
            return;
        }
        // Build email content
        $content = '<h2>Daily Referral Report</h2>';
        $content .= '<p><strong>' . count($new_referrals) . '</strong> new referral' .
            (count($new_referrals) !== 1 ? 's' : '') . ' yesterday (' . $yesterday . ')</p>';
        $content = JVB()->email()->h1('Daily Referral Report');
        $content .= JVB()->email()->stat(
            count($new_referrals),
            count($new_referrals) === 1 ? 'New Referral' : 'New Referrals',
            'From ' . $yesterday
        );
        $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
        $content .= '<thead><tr style="background: #f5f5f5;">';
        $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Referee</th>';
        $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Email</th>';
        $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Referrer</th>';
        $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Code</th>';
        $content .= '</tr></thead><tbody>';
        $content .= JVB()->email()->spacer(20);
        $content .= JVB()->email()->h2('New Referrals');
        // Build list of referrals
        foreach ($new_referrals as $ref) {
            $content .= '<tr>';
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($ref->referee_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($ref->referee_email));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($ref->referrer_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($ref->referral_code));
            $content .= '</tr>';
            $cardContent = sprintf(
                '<p><strong>%s</strong> (%s)</p>',
                esc_html($ref->referee_name),
                esc_html($ref->referee_email)
            );
            $cardContent .= sprintf(
                '<p style="font-size:13px;color:%s;">Referred by: %s | Code: %s</p>',
                JVB()->email()->colours['dark-200'],
                esc_html($ref->referrer_name),
                JVB()->email()->badge($ref->referral_code, 'info')
            );
            $content .= JVB()->email()->card($cardContent);
        }
        $content .= '</tbody></table>';
        // Get admin email
        $to = get_option('admin_email');
        $subject = sprintf('[%s] %d New Referral%s',
        $subject = sprintf(
            '[%s] %d New Referral%s',
            get_bloginfo('name'),
            count($new_referrals),
            count($new_referrals) !== 1 ? 's' : '');
            count($new_referrals) !== 1 ? 's' : ''
        );
        JVB()->email()->sendEmail($to, $subject, $content);
        JVB()->email()->sendEmail($to, $subject, $content, 'DAILY REPORT');
    }
    /**
@@ -724,12 +725,43 @@
            return;
        }
        $content = JVB()->email()->h1('Weekly Referral Summary');
        $content .= JVB()->email()->stat(
            $total_referrals,
            'Total Referrals',
            'This week'
        );
        $content .= JVB()->email()->spacer(30);
        $content .= JVB()->email()->h2('Top 10 Referrers');
        // Leaderboard style
        $rank = 1;
        foreach ($top_referrers as $referrer) {
            $rankBadge = $rank <= 3
                ? JVB()->email()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
                : '<span style="font-weight:600;color:' . JVB()->email()->colours['dark-200'] . ';">#' . $rank . '</span>';
            $cardContent = sprintf(
                '<p>%s <strong>%s</strong></p>',
                $rankBadge,
                esc_html($referrer->user_name)
            );
            $stats = [
                JVB()->email()->stat($referrer->referral_count, 'Total Referrals'),
                JVB()->email()->stat($referrer->treated_count, 'Treated')
            ];
            $cardContent .= JVB()->email()->grid($stats, 2);
            $content .= JVB()->email()->card($cardContent);
            $rank++;
        }
        $to = get_option('admin_email');
        $subject = '[' . get_bloginfo('name') . '] Weekly Referral Summary - ' . date('F j, Y');
        $message = $this->generateWeeklyReportEmail($top_referrers, $total_referrals);
        JVB()->email()->sendEmail($to, $subject, $message);
        JVB()->email()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
    }
    /**
@@ -760,52 +792,6 @@
    }
    /**
     * Generate HTML email for daily report
     *
     * @param array $referrals
     * @param string $period
     * @return string
     */
    protected function generateReportEmail(array $referrals, string $period): string
    {
        $count = count($referrals);
        $content = sprintf('<p>You have <strong>%d new referral%s</strong> today.</p>',
            $count,
            $count !== 1 ? 's' : ''
        );
        $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
        $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Referred By</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">New User</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Email</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Status</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Time</th>';
        $content .= '</tr></thead><tbody>';
        foreach ($referrals as $referral) {
            $content .= '<tr>';
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referrer_name ?? 'Unknown'));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referee_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referee_email));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html(ucfirst($referral->status)));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html(date('g:i A', strtotime($referral->referred_at))));
            $content .= '</tr>';
        }
        $content .= '</tbody></table>';
        $content .= '<p><small>See attached CSV for full details.</small></p>';
        return jvbGetEmailTemplate($content, 'Daily Referral Report');
    }
    /**
     * Generate HTML email for weekly report
     *
     * @param array $top_referrers
@@ -820,31 +806,22 @@
            $total_referrals !== 1 ? 's' : ''
        );
        $content .= '<h3>Top 10 Referrers This Week</h3>';
        $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
        $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Rank</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">User</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Total Referrals</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Treated</th>';
        $content .= '</tr></thead><tbody>';
        $referrers = [];
        $rank = 1;
        foreach ($top_referrers as $referrer) {
            $content .= '<tr>';
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>', $rank++);
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referrer->user_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
                $referrer->referral_count);
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
                $referrer->treated_count);
            $content .= '</tr>';
            $referrers[] = [
                'label' => '#' . $rank++ . ' - ' . esc_html($referrer->user_name),
                'value' => sprintf(
                    '<strong>Total Referrals:</strong> %d | <strong>Treated:</strong> %d',
                    $referrer->referral_count,
                    $referrer->treated_count
                )
            ];
        }
        $content .= '</tbody></table>';
        $content .= JVB()->email()->table($referrers, 'Top 10 Referrers This Week');
        return jvbGetEmailTemplate($content, 'Weekly Referral Summary');
        return $content;
    }
    /**
@@ -1102,6 +1079,7 @@
                'autocomplete'=>'off',
                'data-referrer' => $referrer_name
            ]).'
                '.$turnstile.'
                <button type="button" class="button-secondary check-code-btn">
                    '.jvbIcon('check-circle', ['size' => 16]).' Verify Code
                </button>
@@ -1113,7 +1091,6 @@
                <p class="helper-text">
                    We\'ll send you a link to complete your registration.
                </p>
                '.$turnstile.'
            </form>
            <div class="success-content" hidden>
                <h3>Check Your Email!</h3>
@@ -1232,8 +1209,9 @@
            <h4>Your Referral Link</h4>
            <div class="copy-group row btw nowrap">
                <code id="referral-link" class="copy-target"><?= esc_url($share_url) ?></code>
                <button type="button" class="copy-btn" data-target="referral-link" aria-label="Copy referral link">
                    <?php echo jvbIcon('copy', ['size' => 16]); ?>
                <button type="button" class="copy-btn" data-target="referral-link" title="Copy referral link">
                    <?= jvbIcon('copy'); ?>
                    <?= jvbIcon('check-circle'); ?>
                </button>
            </div>
            <p class="hint">Quickest and easiest: autofills your code.</p>
@@ -1242,8 +1220,9 @@
            <h4>Your Code</h4>
            <div class="copy-group row btw nowrap">
                <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code>
                <button type="button" class="copy-btn" data-target="referral-code" aria-label="Copy referral code">
                    <?php echo jvbIcon('copy', ['size' => 16]); ?>
                <button type="button" class="copy-btn" data-target="referral-code" title="Copy referral code">
                    <?= jvbIcon('copy'); ?>
                    <?= jvbIcon('check-circle'); ?>
                </button>
            </div>
            <p class="hint">Manually copy and paste the code</p>
@@ -2476,20 +2455,20 @@
        <!-- Referral Code Card -->
        <div class="card">
            <h3>Share Code</h3>
            <div class="row btw nowrap">
                <code class="code"><?= esc_html($referral_code) ?></code>
                <button class="button copy-btn" data-code="<?= esc_attr($referral_code) ?>">
                    Copy Code
                </button>
            </div>
            <h3>Share Link</h3>
            <div class="row btw nowrap">
                <code class="share-link">
                    <?= home_url('/?ref=' . $referral_code) ?>
                </code>
                <button class="button copy-btn" data-code="<?= home_url('/?ref=' . $referral_code) ?>">
                    Copy Link
                <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'); ?>
                    <?= jvbIcon('check-circle'); ?>
                </button>
            </div>
            <h3>Share Code</h3>
            <div class="row 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'); ?>
                    <?= jvbIcon('check-circle'); ?>
                </button>
            </div>
        </div>
@@ -2812,7 +2791,7 @@
        $referrer_first_name = $referrer ? strtok($referrer->display_name, ' ') : 'Your friend';
        // Get reward text
        $reward_text = $this->getRewardText(false); // Just "20% off" or "$25 off"
        $reward_text = $this->getRewardText(); // Just "20% off" or "$25 off"
        $booking_url = apply_filters('jvb_referral_booking_url', home_url('/contact'));
        $estimate_url = apply_filters('jvb_referral_estimate_url', home_url('/estimate'));
inc/managers/SEO/BreadcrumbManager.php
@@ -45,14 +45,30 @@
            return [];
        }
        $key = get_queried_object_id() ?: 'home';
        $crumbs = $this->cache->get($key);
        switch (true) {
            case is_singular():
                $key = get_queried_object_id();
                break;
            case is_post_type_archive():
                $obj = get_queried_object();
                $key = $obj->name;
                break;
            case is_tax():
                $obj = get_queried_object();
                $key = $obj->taxonomy;
                break;
            default:
                $key = 'home';
                break;
        }
        $crumbs = $this->cache->get($key);
        if ($crumbs !== false) {
            return $crumbs;
        }
        $crumbs = $this->buildCrumbs();
        $crumbs = apply_filters('jvbBreadcrumbs',$crumbs);
        $this->cache->set($key, $crumbs);
        return $crumbs;
@@ -73,7 +89,6 @@
        ];
        $obj = get_queried_object();
        if (is_tax()) {
            $crumbs = $this->addTaxonomyCrumbs($crumbs, $obj);
        } elseif (is_singular()) {
@@ -171,7 +186,7 @@
        if (array_key_exists($name, JVB_CONTENT)) {
            $crumbs[] = [
                'name' => JVB_CONTENT[$name]['breadcrumb'] ?? JVB_CONTENT[$name]['plural'],
                'url'  => get_post_type_archive_link($type),
                'url'  => get_post_type_archive_link($type)
            ];
        }
@@ -241,6 +256,7 @@
        $out .= '<ol itemscope itemtype="https://schema.org/BreadcrumbList">';
        $position = 1;
        $total = count($crumbs);
        foreach ($crumbs as $crumb) {
            $label = '<span itemprop="name">' . strtolower($crumb['name']) . '</span>';
@@ -253,8 +269,7 @@
            // Add link if URL exists and not current page
            if ($crumb['url'] !== false) {
                $isCurrent = isset($crumb['id']) && $crumb['id'] === get_queried_object_id();
                if (!$isCurrent) {
                if ($total !== $position) {
                    $aOpen = '<a itemprop="item" href="' . esc_url($crumb['url']) . '" title="' . esc_attr($crumb['name']) . '">';
                    $aClose = '</a>';
                }
inc/managers/SEO/SchemaOutputManager.php
@@ -50,6 +50,69 @@
        // Output our schema
        add_action('wp_head', [$this, 'outputSchema'], 1);
        add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeHiddenSingles'], 10, 1);
    }
    /**
     * Exclude posts from sitemap based on hide_single and is_timeline flags
     *
     * @param array $ids Array of post IDs to exclude
     * @return array Modified array with hidden posts added
     */
    public function excludeHiddenSingles(array $ids): array
    {
        $hiddenTypes = [];
        $timelineTypes = [];
        // Find post types with hide_single or is_timeline flags
        foreach (JVB_CONTENT as $slug => $config) {
            $postType = BASE . $slug;
            if (!empty($config['hide_single'])) {
                $hiddenTypes[] = $postType;
            }
            if (!empty($config['is_timeline'])) {
                $timelineTypes[] = $postType;
            }
        }
        $hiddenIds = [];
        // Get all posts from hide_single types
        if (!empty($hiddenTypes)) {
            $hiddenIds = $this->cache->remember(
                'hidden_single_posts',
                function() use ($hiddenTypes) {
                    return get_posts([
                        'post_type' => $hiddenTypes,
                        'posts_per_page' => -1,
                        'fields' => 'ids',
                        'post_status' => 'publish',
                    ]);
                }
            );
        }
        // Get child posts from timeline types
        if (!empty($timelineTypes)) {
            $timelineChildIds = $this->cache->remember(
                'timeline_child_posts',
                function() use ($timelineTypes) {
                    return get_posts([
                        'post_type' => $timelineTypes,
                        'posts_per_page' => -1,
                        'fields' => 'ids',
                        'post_status' => 'publish',
                        'post_parent__not_in' => [0], // Only get posts with a parent
                    ]);
                }
            );
            $hiddenIds = array_merge($hiddenIds, $timelineChildIds);
        }
        return array_merge($ids, $hiddenIds);
    }
    /**
inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
add_action('init', 'jvbRegisterScripts', 5);
function jvbRegisterScripts() {
    $version = '1.0.9';
    $version = '1.0.99';
    $strategy = [
        'strategy'  => 'defer',
        'in_footer' => true
@@ -150,7 +150,7 @@
    wp_register_script(
        'jvb-integrations',
        JVB_URL.'assets/js/min/integrations.min.js',
        [],
        ['jvb-auth'],
        $version,
        $strategy
    );
inc/managers/_setup.php
@@ -64,7 +64,9 @@
//require(JVB_DIR . '/inc/managers/SchemaManager.php');
//require(JVB_DIR . '/inc/managers/SEOMetaManager.php');
require(JVB_DIR . '/inc/managers/SEO/_setup.php');
if (Features::forSite()->has('is_directory')) {
require(JVB_DIR . '/inc/managers/DirectoryManager.php');
}
require(JVB_DIR . '/inc/managers/ImageGenerator.php');
require(JVB_DIR . '/inc/managers/AdminPages.php');
require(JVB_DIR . '/inc/managers/RoleManager.php');
inc/rest/routes/FormRoutes.php
@@ -6,6 +6,7 @@
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use JVBase\blocks\FormBlock;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -32,15 +33,13 @@
        $this->action = 'form-';
        $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS);
        // Initialize Cloudflare Turnstile if available
        $this->turnstile = class_exists('JVBase\managers\CloudflareTurnstile') && jvbSiteUsesCloudflare()
            ? new CloudflareTurnstile()
            : null;
        // Add query vars
        add_filter('query_vars', [$this, 'addQueryVars']);
    }
    /**
     * Register REST routes
     */
@@ -51,13 +50,13 @@
            [
                'methods' => 'POST',
                'callback' => [$this, 'submitForm'],
                'permission_callback' => '__return_true', // Public endpoint
                'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited
            ],
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'getForms'],
                    'permission_callback' => [$this, 'checkPermission']
                ]
            ]
        ]);
        // Get specific form configuration
@@ -95,10 +94,10 @@
            $form_type = $request->get_param('form_type');
            $form_id = $request->get_param('form_id');
            $form_data = $request->get_params();
            $files = $request->get_file_params();
            error_log('Form submission: '.print_r($request->get_params(), true));
            // Process the submission
            $result = $this->handleFormSubmission($form_type, $form_id, $form_data);
            $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files);
            if (is_wp_error($result)) {
                return new WP_REST_Response([
@@ -123,36 +122,65 @@
        }
    }
    protected function verifyTurnstile(string $token): bool
    {
        if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
            return true;
        }
        if (empty($token)) {
            return false;
        }
        return JVB()->connect('cloudflare')->verifyTurnstile($token);
    }
    /**
     * Handle the actual form submission logic
     */
    protected function handleFormSubmission(string $form_type, string $form_id, array $form_data): array|WP_Error
    protected function handleFormSubmission(string $form_type, string $form_id, array $form_data, array $files): array|WP_Error
    {
        // Get form configuration
        $form_config = FormBlock::getForm($form_type);
        error_log('Config: '.print_r($form_config, true));
        if (!$form_config) {
            return new WP_Error('invalid_form', 'Form configuration not found.');
        }
        // Verify Turnstile
        $turnstile_token = $form_data['cf-turnstile-response'] ?? '';
        // Verify Turnstile if enabled
        //TODO: Reenable
//      if (jvbSiteUsesCloudflare() && $this->turnstile) {
//          error_log('Verifying turnstile...');
//          $turnstile_token = $form_data['cf-turnstile-response'] ?? '';
//          if (!$this->turnstile->verifyTurnstile($turnstile_token)) {
//              return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.');
//          }
//      }
        if (!$this->verifyTurnstile($turnstile_token)) {
            return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.');
        }
        // Validate file uploads if present
        if (!empty($files)) {
            try {
                $files = $this->validateFileUploads($files, $form_config);
            } catch (\Exception $e) {
                return new WP_Error('file_validation_failed', 'File validation error: ' . $e->getMessage());
            }
        }
        // Validate and sanitize form data
        try {
        $processed_data = $this->validateAndSanitizeData($form_config, $form_data);
        error_log('Processed data: '.print_r($processed_data, true));
        } catch (\Exception $e) {
            return new WP_Error('validation_failed', 'Data validation error: ' . $e->getMessage());
        }
        if (array_key_exists('success', $processed_data) && $processed_data['success'] === false) {
            return $processed_data;
        }
        // Send email notification
        $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data);
        // Send email notification with attachments
        try {
            $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data, $files);
        } catch (\Exception $e) {
            ;
            return new WP_Error('email_failed', 'Email error: ' . $e->getMessage());
        }
        if (!$email_sent) {
            return new WP_Error('email_failed', 'Failed to send your message. Please try again later.');
@@ -239,136 +267,314 @@
    /**
     * Send email notification
     */
    protected function sendEmailNotification(string $form_type, array $form_config, array $form_data): bool
    /**
     * Send email notification
     */
    protected function sendEmailNotification(string $form_type, array $form_config, array $form_data, array $files = []): bool
    {
        $admin_email = apply_filters('jvb_form_email_to', $form_config['email_to'], $form_type, $form_data);
        $subject = apply_filters('jvb_form_email_subject', $form_config['email_subject'], $form_type, $form_data);
        // Get submitter details
        $submitter_email = $form_data['email'] ?? null;
        $submitter_name = $form_data['name'] ?? '';
        $submitter_name = '';
        // Generate unique message ID for threading
        $form_id = $form_data['form_id'] ?? uniqid();
        $timestamp = current_time('timestamp');
        $message_id = sprintf('<%s-%s-%s@%s>',
            $form_type,
            $form_id,
            $timestamp,
            parse_url(home_url(), PHP_URL_HOST)
        );
        // Build unified email body
        $body = '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; line-height: 1.6; color: #333;">';
        $body .= '<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem;">New Form Submission: ' . esc_html($form_config['title']) . '</h2>';
        $body .= '<p style="color: #6b7280;"><strong>Submitted:</strong> ' . current_time('F j, Y \a\t g:i A') . '</p>';
        // Add note about email thread if submitter email exists
        if ($submitter_email) {
            $body .= '<div style="background: #f0f9ff; border-left: 4px solid #3b82f6; padding: 1rem; margin: 1rem 0; border-radius: 0 8px 8px 0;">';
            $body .= '<p style="margin: 0; color: #1e40af;"><strong>💬 Email Thread Created</strong></p>';
            $body .= '<p style="margin: 0.5rem 0 0 0; color: #1e40af; font-size: 0.9em;">This email includes both the admin and the person who submitted the form. Any replies will be shared between all parties, creating a conversation thread.</p>';
            $body .= '</div>';
        if (array_key_exists('first_name', $form_data)) {
            $submitter_name = $form_data['first_name'];
            $submitter_name .= array_key_exists('last_name', $form_data) ? ' '.$form_data['last_name'] : '';
        } elseif (array_key_exists('name', $form_data)) {
            $submitter_name = $form_data['name'];
        }
        $body .= '<div style="margin: 2rem 0;">';
        // Add submission details in a nice format
        foreach ($form_config['fields'] as $field_name => $config) {
            $label = $config['summaryTitle'] ?? $config['label'];
            //Submitted Value
            $value = $form_data[$field_name];
            if (str_contains($label, '%s')) {
                switch ($config['type']) {
                    case 'true_false':
                        $replace = ($value === '1') ? $config['isTrue'] : $config['isFalse'];
                        break;
                }
                $label = sprintf(
                    $label,
                    $replace
                );
            }
            $body .= '<div style="margin-bottom: 1rem; padding: 0.75rem; background: #f9fafb; border-radius: 6px;">';
            $body .= '<strong style="color: #374151; display: block; margin-bottom: 0.25rem;">' . esc_html($label) . ':</strong>';
            $body .= '<span style="color: #6b7280;">' . nl2br(esc_html($value)) . '</span>';
            $body .= '</div>';
        }
        $body .= '</div>';
        // Add footer
        $body .= '<div style="border-top: 1px solid #e5e7eb; padding-top: 1rem; margin-top: 2rem;">';
        $body .= '<p style="color: #9ca3af; font-size: 0.8em; margin: 0;"><em>';
        $body .= 'This message was sent through the ' . esc_html($form_config['title']) . ' on ' . get_bloginfo('name');
        // Email headers
        $headers = [];
        if ($submitter_email) {
            $body .= '<br>To continue this conversation, simply reply to this email.';
        }
        $body .= '</em></p>';
        $body .= '</div>';
        $body .= '</div>';
        // Prepare email headers for unified thread with enhanced threading
        $headers = [
            'Content-Type: text/html; charset=UTF-8',
        ];
        // Set up recipients and reply-to based on whether submitter email exists
        if ($submitter_email) {
            // Primary recipient: admin
            $to = $admin_email;
            // Add submitter as CC so they both see the email
            $cc_name = $submitter_name ? $submitter_name : 'Form Submitter';
            $cc_name = $submitter_name ?: 'Submitter';
            $headers[] = 'CC: ' . $cc_name . ' <' . $submitter_email . '>';
            // Set reply-to to include both emails for unified thread
            $site_name = get_bloginfo('name');
            $admin_name = get_bloginfo('name') . ' Team';
            $headers[] = 'Reply-To: ' . $admin_name . ' <' . $admin_email . '>, ' . $cc_name . ' <' . $submitter_email . '>';
            // Set from address to be more professional
            $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>';
            $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>';
        } else {
            // No submitter email, just send to admin
            $to = $admin_email;
            $site_name = get_bloginfo('name');
            $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>';
            $headers[] = 'From: ' . $site_name . ' <' . $admin_email . '>';
            $headers[] = 'Reply-To: ' . $admin_email;
        }
        // Allow filtering of email data
        $email_data = apply_filters('jvb_form_unified_email_data', [
            'to' => $to,
            'subject' => $subject,
            'body' => $body,
            'headers' => $headers,
            'submitter_included' => !empty($submitter_email),
            'submitter_email' => $submitter_email,
            'message_id' => $message_id
        ], $form_type, $form_data);
        // Build email body
        $body = JVB()->email()->h2($form_config['title']);
        $body .= '<p style="font-size:13px;color:' . JVB()->email()->colours['dark-200'] . ';">
        Submitted: ' . current_time('F j, Y \a\t g:i A') . '
    </p>';
        // Send the unified email
        $email_sent = JVB()->email()->sendEmail($email_data['to'], $email_data['subject'], $email_data['body'], implode(';',$email_data['headers']));
        // Log the email sending for debugging
        if ($email_sent) {
            error_log("Form email sent successfully. Recipients: {$to}" .
                ($submitter_email ? " (CC: {$submitter_email})" : "") .
                " | Message-ID: {$message_id}");
        } else {
            error_log("Failed to send form email to: {$to}" .
                ($submitter_email ? " (CC: {$submitter_email})" : ""));
        // Add thread notice if CC'd
        if ($submitter_email) {
            $body .= JVB()->email()->notice(
                '<strong>Email Thread Created</strong><br>Reply to this email to continue the conversation with ' .
                esc_html($submitter_name ?: 'the submitter') . '.'
            );
        }
        // Build form data array for table
        $form = [];
        foreach ($form_config['fields'] as $field_name => $config) {
            // Skip file upload fields
            if (in_array($config['type'], ['upload'])) {
                continue;
            }
            $value = $form_data[$field_name] ?? '';
            // Skip empty fields
            if (empty($value)) {
                continue;
            }
            $form[] = [
                'label' => $config['summaryTitle'] ?? $config['label'],
                'value' => $this->formatFieldValueForEmail($field_name, $value, $config)
            ];
        }
        $body .= JVB()->email()->spacer(20);
        $body .= JVB()->email()->table($form);
        // Show attachment info
        $attachments = $this->processFileAttachments($files);
        if (!empty($attachments)) {
            $body .= JVB()->email()->spacer(10);
            $body .= JVB()->email()->alert(
                sprintf('%d file(s) attached to this email', count($attachments)),
                'info'
            );
        }
        // Send the email
        $email_sent = JVB()->email()->sendEmail(
            $to,
            $subject,
            $body,
            '',
            $headers,
            $attachments
        );
        return $email_sent;
    }
    /**
     * Format field value for email display
     */
    protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string
    {
        if (empty($value)) {
            return '';
        }
        $type = $field_config['type'] ?? 'text';
        $type = $field_config['subType']?:$type;
        switch ($type) {
            case 'phone':
            case 'tel':
                // Format phone number as xxx-xxx-xxxx
                $cleaned = preg_replace('/\D/', '', $value);
                if (strlen($cleaned) === 10) {
                    return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6);
                }
                return $value;
            case 'checkbox':
            case 'set':
                // Convert array of values to comma-separated labels
                if (!is_array($value)) {
                    $value = explode(',', $value);
                }
                $labels = [];
                foreach ($value as $val) {
                    $val = trim($val);
                    if (isset($field_config['options'][$val])) {
                        $labels[] = $field_config['options'][$val];
                    } else {
                        $labels[] = $val; // Fallback to value if no label found
                    }
                }
                return implode(', ', $labels);
            case 'radio':
            case 'select':
                // Return label instead of value
                if (isset($field_config['options'][$value])) {
                    return $field_config['options'][$value];
                }
                return $value;
            case 'textarea':
            case 'wysiwyg':
                // Keep line breaks for email display
                return $value; // nl2br is already applied in the email body
            case 'true_false':
                $true_text = $field_config['true_text'] ?? 'Yes';
                $false_text = $field_config['false_text'] ?? 'No';
                return ($value === '1' || $value === 1 || $value === true) ? $true_text : $false_text;
            case 'email':
                return $value; // Will be clickable in email client
            case 'url':
                return $value; // Will be clickable in email client
            default:
                return is_array($value) ? implode(', ', $value) : $value;
        }
    }
    /**
     * Process uploaded files for email attachments
     * Files are in PHP's tmp directory and will be automatically cleaned up after request
     */
    protected function processFileAttachments(array $files): array
    {
        $attachments = [];
        if (empty($files)) {
            return $attachments;
        }
        foreach ($files as $field_name => $field_files) {
            // Skip metadata fields
            if (str_ends_with($field_name, '_meta')) {
                continue;
            }
            // Handle single file
            if (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) {
                if (!empty($field_files['tmp_name']) && is_uploaded_file($field_files['tmp_name'])) {
                    $attachments[] = $field_files['tmp_name'];
                }
                continue;
            }
            // Handle multiple files (field[])
            if (is_array($field_files) && isset($field_files['tmp_name'])) {
                foreach ($field_files['tmp_name'] as $index => $tmp_name) {
                    if (!empty($tmp_name) && is_uploaded_file($tmp_name)) {
                        $attachments[] = $tmp_name;
                    }
                }
            }
        }
        return $attachments;
    }
    protected function validateFileUploads(array $files, array $form_config): array
    {
        $validated_files = [];
        $max_file_size = 10 * 1024 * 1024; // 10MB default
        $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
        foreach ($files as $field_name => $field_files) {
            // Skip metadata fields
            if (str_ends_with($field_name, '_meta')) {
                continue;
            }
            // Get field config for this upload field
            $field_config = $form_config['fields'][$field_name] ?? null;
            // Override defaults if field config specifies them
            if ($field_config) {
                $max_file_size = $field_config['max_file_size'] ?? $max_file_size;
                $allowed_types = $field_config['allowed_types'] ?? $allowed_types;
            }
            // Handle multiple files uploaded to same field (field[])
            if (isset($field_files['tmp_name']) && is_array($field_files['tmp_name'])) {
                $valid_indices = [];
                foreach ($field_files['tmp_name'] as $index => $tmp_name) {
                    $file = [
                        'tmp_name' => $tmp_name,
                        'size' => $field_files['size'][$index] ?? 0,
                        'type' => $field_files['type'][$index] ?? '',
                        'name' => $field_files['name'][$index] ?? '',
                        'error' => $field_files['error'][$index] ?? UPLOAD_ERR_OK
                    ];
                    // Validate this file
                    if ($this->isValidFile($file, $max_file_size, $allowed_types)) {
                        $valid_indices[] = $index;
                    }
                }
                // Keep only valid files in the original structure
                if (!empty($valid_indices)) {
                    $validated_files[$field_name] = [
                        'tmp_name' => [],
                        'size' => [],
                        'type' => [],
                        'name' => [],
                        'error' => []
                    ];
                    foreach ($valid_indices as $index) {
                        $validated_files[$field_name]['tmp_name'][] = $field_files['tmp_name'][$index];
                        $validated_files[$field_name]['size'][] = $field_files['size'][$index];
                        $validated_files[$field_name]['type'][] = $field_files['type'][$index];
                        $validated_files[$field_name]['name'][] = $field_files['name'][$index];
                        $validated_files[$field_name]['error'][] = $field_files['error'][$index];
                    }
                }
            }
            // Handle single file
            elseif (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) {
                if ($this->isValidFile($field_files, $max_file_size, $allowed_types)) {
                    $validated_files[$field_name] = $field_files;
                }
            }
        }
        return $validated_files;
    }
    /**
     * Validate a single file
     */
    protected function isValidFile(array $file, int $max_file_size, array $allowed_types): bool
    {
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            error_log("File upload error: " . $file['error'] . " for " . ($file['name'] ?? 'unknown'));
            return false;
        }
        // Check file size
        if ($file['size'] > $max_file_size) {
            error_log("File too large: " . $file['size'] . " bytes (max: $max_file_size) for " . ($file['name'] ?? 'unknown'));
            return false;
        }
        // Check file type
        if (!in_array($file['type'], $allowed_types)) {
            error_log("Invalid file type: " . $file['type'] . " for " . ($file['name'] ?? 'unknown'));
            return false;
        }
        // Security check - verify it's actually uploaded
        if (!is_uploaded_file($file['tmp_name'])) {
            error_log("Security check failed: file not uploaded via POST for " . ($file['name'] ?? 'unknown'));
            return false;
        }
        return true;
    }
    /**
     * Record submission for rate limiting
inc/rest/routes/LoginRoutes.php
@@ -152,11 +152,11 @@
        // Attempt login
        $user = wp_signon([
            'user_login'    => $username,
            'user_email'    => $username,
            'user_password' => $password,
            'remember' => $remember
        ], false);
        if (is_wp_error($user)) {
            // Track failed attempt
            $this->trackFailedLogin($username);
@@ -167,7 +167,6 @@
                401
            ) : false;
        }
        // Clear failed attempts on success
        $this->clearFailedAttempts($username);
@@ -175,6 +174,8 @@
        wp_set_current_user($user->ID);
        wp_set_auth_cookie($user->ID, $remember);
        // Store session fingerprint for hijacking protection
        if ($request) {
            $this->storeSessionFingerprint($user->ID, $request);
@@ -267,13 +268,12 @@
     */
    protected function getSessionId(int $user_id): string
    {
        // Use WordPress session tokens
        $sessions = WP_Session_Tokens::get_instance($user_id);
        $token = wp_get_session_token(); // Current session token
        if (!$token) {
            // Fallback to user-specific hash that changes on password reset
            return md5($user_id . get_user_meta($user_id, 'session_tokens', true));
            // Fallback to a hash based on user ID and current timestamp
            // This will be replaced once the session token is available
            return md5($user_id . time());
        }
        return md5($token);
@@ -532,7 +532,7 @@
            update_user_meta($user_id, BASE . $key, sanitize_text_field($value));
        }
        $redirect = $this->getRedirect($user, $request->get_param('redirect_to'), 'register');
        $redirect = $this->getRedirect($user, $request->get_param('redirect_to')??get_home_url(null,'/dash'), 'register');
        // Handle token handlers
        do_action('jvbUserRegistered', $user_id, $email, $data);
inc/rest/routes/QueueRoutes.php
@@ -193,7 +193,8 @@
            'progress_count' => intval($operation['progress_count'] ?? 0),
            'count' => intval($operation['count'] ?? 1),
            'retries' => intval($operation['retries'] ?? 0),
            'data' => json_decode($operation['request_data'] ?? '{}', true)
            'data' => json_decode($operation['request_data'] ?? '{}', true),
            'result'    => json_decode($operation['result']??'{}', true)
        ];
        // Convert timestamps to ISO 8601 format with proper timezone
inc/rest/routes/ReferralRoutes.php
@@ -640,8 +640,21 @@
        if ($result['success']) {
            $this->cache->clear();
        }
        error_log('Result: '.print_r($result, true));
        return $result;
        // Build summary message
        $textResult = 'Sent invitations. ';
        $textResult .= 'Success: ' . count($result['result']['success']) . '. ';
        $textResult .= 'Failed: ' . count($result['result']['failed']) . '.';
        return [
            'success'   => true,
            'message'   => $textResult,
            'details'   => [
                'successful' => $result['result']['success'],
                'failed'     => $result['result']['failed'],
                'total'      => count($data['invitations'])
            ]
        ];
    }
    /**
@@ -649,20 +662,31 @@
     */
    public function handleClientUpload(WP_REST_Request $request): WP_REST_Response
    {
        $files = $request->get_file_params();
        if (!isset($files['file'])) {
        // Access files from $_FILES directly for REST API uploads
        if (empty($_FILES['file'])) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'No file uploaded'
            ], 400);
        }
        $file = $files['file'];
        $file = $_FILES['file'];
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'File upload error: ' . $file['error']
            ], 400);
        }
        // Validate file type
        $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain'];
        if (!in_array($file['type'], $allowed_types)) {
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime_type = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'File must be a CSV'
@@ -679,7 +703,7 @@
        // Import using JaneAppClientImporter
        $importer = new JaneAppClientImporter();
        $default_role = get_option(BASE . 'client_import_role', JVB_USER);
        $default_role = get_option(BASE . 'referral_role', JVB_USER);
        $options = [
            'update_existing' => true,
@@ -716,7 +740,7 @@
        return new WP_REST_Response([
            'success' => true,
            'message' => $message,
            'items' => $result,
            'stats' => $result,
            'skipped_details' => $details
        ]);
    }
@@ -726,20 +750,31 @@
     */
    public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response
    {
        $files = $request->get_file_params();
        if (!isset($files['file'])) {
        // Access files from $_FILES directly for REST API uploads
        if (empty($_FILES['file'])) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'No file uploaded'
            ], 400);
        }
        $file = $files['file'];
        $file = $_FILES['file'];
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'File upload error: ' . $file['error']
            ], 400);
        }
        // Validate file type
        $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain'];
        if (!in_array($file['type'], $allowed_types)) {
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime_type = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'File must be a CSV'
inc/ui/CRUDSkeleton.php
@@ -238,7 +238,7 @@
    /**
     * Add a view type (grid, table, list, timeline)
     */
    public function addViews(?array $views):self
    public function addViews(?array $views = null):self
    {
        if (!$views) {
            $views = $this->defaultViews;
@@ -612,7 +612,8 @@
            return;
        }
        ?>
        <div class="all-filters col start" data-ignore>
        <details class="all-filters col start" data-ignore>
            <summary>Filters</summary>
            <?php
            $this->renderSearch();
@@ -624,7 +625,7 @@
                $this->renderColumnSelector();
            }
            ?>
        </div>
        </details>
        <?php
    }
@@ -1036,6 +1037,7 @@
            $temp = array_filter($this->fields, function ($field) {
                return in_array($field, $this->timelineUniqueFields);
            }, ARRAY_FILTER_USE_KEY);
            jvbDump($temp);
            $form = new MetaForm();
            echo '<template class="timelineItem">';
            $form->renderImagePreview(null,['fields' => $temp]);
@@ -1111,7 +1113,7 @@
        }
        ob_start();
        ?>
        <div class="item-actions">
        <div class="item-actions row btw abs">
            <?php
            foreach ($this->itemActions as $action) {
                $config = $this->defaultItemActions[$action];
@@ -1698,7 +1700,7 @@
    protected function getApplicableStatuses(string $prefix) {
        foreach ($this->statuses as $status) {
            if ($status === 'all' || !in_array($status, $this->allowedStatuses)) {
            if ($status === 'all' || !array_key_exists($status, $this->allowedStatuses)) {
                continue;
            }
            $config = $this->allowedStatuses[$status];
@@ -1721,9 +1723,8 @@
                   value="<?= esc_attr($status)?>"
                   id="<?=$prefix?>set-<?= esc_attr($status) ?>"
                <?= $disabled?>>
            <label for="<?=$prefix?>set-<?=esc_attr($status)?>">
                <?= jvbDashIcon($config['icon'], ['title' => $config['label']]) ?>
                <span><?= esc_html($config['label'])?></span>
            <label for="<?=$prefix?>set-<?=esc_attr($status)?>" title="<?=esc_html($config['label'])?>">
                <?= jvbDashIcon($config['icon']) ?>
            </label>
            <?php
        }
src/feed/view.js
@@ -681,6 +681,11 @@
    }
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
    window.feedBlock = new FeedBlock();
        }
    });
});
src/forms/view.js
@@ -8,7 +8,7 @@
        this.controller = new window.jvbForm();
        document.querySelectorAll('.jvb-form-block form').forEach(form => {
            this.controller.registerForm(form, {autosave: true});
            this.controller.registerForm(form, {autosave: true,autoUpload: false});
        });
        this.controller.subscribe((event, data) => {
@@ -24,9 +24,39 @@
        let formData = data.fullData;
        let form = formConfig.element;
        // Create FormData instead of JSON
        const submitData = new FormData();
        // Add all regular form fields
        for (const [key, value] of Object.entries(formData)) {
            // Skip the nonce field - we don't need it for REST API with __return_true
            if (key === '_wpnonce' || key === '_wp_http_referer') {
                continue;
            }
            if (Array.isArray(value)) {
                value.forEach(v => submitData.append(`${key}[]`, v));
            } else if (typeof value === 'object' && value !== null) {
                // For nested objects, you might want to JSON.stringify them
                submitData.append(key, JSON.stringify(value));
            } else {
                submitData.append(key, value);
            }
        }
        // Get uploaded files from UploadManager
        if (window.jvbUploads) {
            const files = await window.jvbUploads.getFilesForForm(form);
            // Append files with their field names
            files.forEach(({file, fieldName, uploadId, meta}) => {
                // Use fieldName[] for multiple files per field
                submitData.append(`${fieldName}[]`, file);
            });
        }
        let headers = {
            'X-WP-Nonce': jvbSettings.nonce,
            'Content-Type': 'application/json'
            // 'X-WP-Nonce': window.auth.getNonce()
        };
        let block = form.closest('.jvb-form-block');
@@ -35,26 +65,48 @@
        try {
            const response = await fetch(`${jvbSettings.api}forms`, {
                method: 'POST',
                credentials: 'same-origin',
                headers,
                body: JSON.stringify(formData)
                body: submitData // Send FormData instead of JSON
            });
            const result = await response.json();
            if (!response.ok) {
                this.controller.showFormStatus(formId, 'error');
                const errorData = await response.json().catch(() => ({}));
                throw new Error(errorData.message || `Request failed with status ${response.status}`);
                this.controller.handleFormError(form, result); // ✓ Show error to user
                return; // Stop processing
            }
            this.controller.showFormStatus(formId, 'submitted');
            this.controller.showSummary(formId, '.jvb-form-block');
            // Clean up uploaded files from IndexedDB after successful submission
            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) {
            throw error;
            console.error('Form submission error:', error);
            this.controller.showFormStatus(formId, 'error');
            // Show user-friendly network error - IMPROVED
            this.controller.handleFormError(form, {
                message: 'Network error. Please check your connection and try again.',
                code: 'network_error'
            });
        } finally {
            this.controller.store.delete(formId);
        }
    }
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
    window.auth.subscribe(event => {
        if (event === 'auth-loaded') {
    new FormBlock();
        }
    });
});
src/glossary/view.js
@@ -173,6 +173,7 @@
    }
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
src/gmbreviews/render.php
@@ -57,12 +57,12 @@
        ob_start();
        ?>
        <div class="gmb-reviews">
            <div class="row btw">
            <div class="row center">
            <?php
            if ($showStats && !empty($average) && !empty($total)) {
                ?>
                <p>
                    <span class="stars" aria-label="<?= $average ?> out of 5 stars">
                    <span class="stars" title="<?= $average ?> out of 5 stars">
                        <?php
                        $fullStars = floor($average);
                        $hasHalfStar = ($average - $fullStars) >= 0.5;
@@ -91,6 +91,8 @@
            }
            ?>
        </div>
            <?php
            if ($showReviewLink && !empty($reviewUrl)) {
                ?>
@@ -104,7 +106,6 @@
                <?php
            }
            ?>
        </div>
        <ul>
            <?php
@@ -145,6 +146,14 @@
                            <?php } ?>
                            <div class="row start 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
@@ -155,14 +164,7 @@
                                        <?= esc_html($formatted_date) ?>
                                    </time>
                                <?php } ?>
                                <?php if ($showRating && $rating > 0) { ?>
                                    <div class="stars" aria-label="<?= $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 } ?>
                            </div>
                        </cite>
                    </blockquote>
src/gmbreviews/style.scss
@@ -1,45 +1,53 @@
.gmb-reviews {
    max-width: none;
    > .row.btw {
        max-width:var(--wide);
        .button {
            width: 100%;
            height: max-content;
        }
    > .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: center;
        justify-content: flex-start;
        flex-wrap: nowrap;
    }
    ul {
        list-style: none;
        margin: 0;
        padding: 0;
        max-width: var(--wider);
        max-width: var(--full);
        li {
            margin: 2rem 0;
            position:relative;
            background-color: var(--base-100);
            padding: 1rem;
            @media (min-width: 768px) {
            width: 100%;
            max-width: none;
            padding: 4rem 1rem;
                &:nth-of-type(odd) {
                    left: -2rem;
                background-color: var(--base-50);
                blockquote {
                    --background: var(--base-50);
                }
                }
                &:nth-of-type(even) {
                    right: -2rem;
                background-color: var(--base-100);
                blockquote {
                    --background: var(--base-100);
                }
            }
        }
    }
    blockquote {
        margin:0;
        margin:0 auto;
        padding: 0;
        max-width: var(--content);
        .content {
            border-width: 4px 1px;
            &::after {
webpack.jvb.js
@@ -36,6 +36,7 @@
        'quill':               './assets/js/concise/quill.js',
        'queue':               './assets/js/concise/Queue.js',
        'referral':            './assets/js/concise/Referral.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',