From d7dbe7fee362d587dfc334135d9581b6216a4295 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 23 Nov 2025 04:13:56 +0000
Subject: [PATCH] =Timeline block, and feed block updated. DataStore.js refactored to not block rendering
---
base/_setup.php | 6
assets/css/icons.css | 1
assets/js/dash/TaxonomyCreator.js | 242
inc/blocks/SummaryBlock.php | 2
assets/css/dash.min.css | 2
jvb.php | 90
assets/js/min/modal.min.js | 2
inc/helpers/formatting.php | 10
inc/rest/routes/TermRoutes.php | 196
inc/managers/OperationQueue.php | 8
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 121
inc/rest/routes/ContentRoutes.php | 292
src/feed/edit.js | 346
assets/js/min/referral.min.js | 2
build/feed/block.json | 7
inc/rest/routes/FeedRoutes.php | 2026 +++--
assets/js/concise/Swiper.js | 154
build/timeline/style-index-rtl.css | 1
assets/js/min/favourites.min.js | 2
inc/integrations/_setup.php | 5
assets/js/concise/Gallery.js | 463 +
build/gmbreviews/render.php | 34
build/timeline/block.json | 26
JVBase.php | 5
inc/managers/LoginManager.php | 112
inc/meta/MetaManager.php | 7
assets/js/concise/Queue.js | 152
inc/integrations/Integrations.php | 8
src/timeline/edit.js | 38
src/feed/style.scss | 1262 +-
inc/helpers/breadcrumbs.php | 37
src/timeline/view.js | 0
build/feed/style-index.css | 2
build/feed/index.asset.php | 2
assets/js/concise/UploadManager.js | 3920 +++++-----
src/feed/view.js | 1174 +-
src/timeline/editor.scss | 0
build/timeline/index.js | 1
assets/css/forms.min.css | 2
assets/js/concise/View.js | 460
inc/blocks/_setup.php | 4
src/gmbreviews/style.scss | 64
build/timeline/style-index.css | 1
inc/meta/MetaForm.php | 14
inc/rest/routes/UploadRoutes.php | 2
assets/js/min/populate.min.js | 2
build/feed/index.js | 2
src/timeline/index.js | 33
inc/integrations/GoogleMyBusiness.php | 24
inc/blocks/GlossaryBlock.php | 2
build/gmbreviews/style-index.css | 2
build/timeline/index.css | 1
assets/js/concise/TaxonomySelector.js | 521
assets/js/Gallery.js | 2
inc/utility/Image.php | 88
assets/js/min/view.min.js | 2
inc/integrations/PostMark.php | 3
assets/js/concise/DataStore.js | 1966 ++--
webpack.jvb.js | 3
inc/utility/setup.php | 1
assets/js/min/dataStore.min.js | 2
build/feed/view.js | 2
inc/rest/routes/MagicLinkRoutes.php | 22
inc/rest/routes/LoginRoutes.php | 21
assets/js/min/a11y.min.js | 2
src/feed/block.json | 4
build/timeline/index.asset.php | 1
inc/managers/CacheManager.php | 682 +
assets/js/dash/Modal.js | 1
assets/js/dash/UtilityFunctions.js | 35
assets/js/concise/Media.js | 4
build/timeline/index-rtl.css | 1
inc/helpers/ui.php | 24
assets/js/dash/A11yHelper.js | 4
inc/forms/TaxonomySelector.php | 20
build/timeline/view.js | 0
inc/registry/TaxonomyRegistrar.php | 20
inc/managers/CRUDManager.php | 174
assets/js/min/queue.min.js | 2
inc/blocks/CustomBlocks.php | 151
assets/js/min/utility.min.js | 2
src/timeline/style.scss | 135
src/timeline/render.php | 8
assets/js/concise/DataStoreOld.js | 1621 ++-
inc/blocks/RegisterBlocks.php | 45
assets/js/min/media.min.js | 2
assets/js/concise/FrontendVotes.js | 14
build/gmbreviews/style-index-rtl.css | 2
inc/blocks/TimelineBlock.php | 181
assets/js/min/swiper.min.js | 1
assets/js/min/gallery.min.js | 2
inc/managers/IconsManager.php | 301
build/feed/style-index-rtl.css | 2
build/timeline/view.asset.php | 1
src/timeline/index.php | 0
inc/helpers/time.php | 2
assets/js/concise/Referral.js | 5
assets/js/min/selector.min.js | 2
inc/rest/RegisterRoutes.php | 5
src/gmbreviews/render.php | 34
assets/js/concise/FormController.js | 254
inc/blocks/FeedBlock.php | 565
inc/registry/PostTypeRegistrar.php | 39
inc/meta/MetaValidator.php | 1
assets/js/min/crud.min.js | 2
assets/js/concise/PopulateForm.js | 221
assets/js/dash/CRUD.js | 263
inc/helpers/renderFields.php | 89
assets/js/concise/FrontendFavourites.js | 54
inc/helpers/media.php | 20
inc/helpers/members.php | 8
inc/blocks/MenuBlock.php | 2
inc/rest/routes/QueueRoutes.php | 87
inc/utility/Features.php | 1
inc/managers/AdminPages.php | 660 +
assets/js/min/uploader.min.js | 2
assets/js/min/creator.min.js | 2
build/feed/view.asset.php | 2
src/timeline/block.json | 23
/dev/null | 252
src/feed/viewOld.js | 1996 +---
inc/rest/RestRouteManager.php | 16
123 files changed, 12,167 insertions(+), 9,888 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index 8894d0c..04bb699 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -109,14 +109,14 @@
'login' => new LoginRoutes(),
'integrations' => new IntegrationsRoutes(),
];
- if (jvbSiteUsesFeedBlock()) {
+ if (Features::forSite()->has('feed_block')) {
$this->routes['feed'] = new FeedRoutes();
}
if (jvbSiteHasNotifications()) {
$this->managers['notifications'] = new NotificationManager();
$this->routes['notifications'] = new NotificationsRoutes();
}
- if (jvbSiteUsesFeedBlock() || jvbSiteHasDashboard()) {
+ if (Features::forSite()->has('feed_block') || jvbSiteHasDashboard()) {
$this->routes['term'] = new TermRoutes();
}
@@ -263,7 +263,6 @@
public function activate():void
{
- error_log('Activate!');
// Activate roles - will be properly initialized after post types are registered
$this->roles()->activate();
}
diff --git a/assets/css/dash.min.css b/assets/css/dash.min.css
index 751536b..e3d33d7 100644
--- a/assets/css/dash.min.css
+++ b/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(--height)}.dashboard h1:first-of-type{margin-top:4rem!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!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(--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(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img{max-height:4rem}.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(--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:fit-content;padding:.5rem!important;min-width:0;min-height:0}.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(--transition-base),width var(--transition-base),padding var(--transition-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(--transition-base),width var(--transition-base),padding var(--transition-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(--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(--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(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}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(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.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(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--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}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.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(--outerRadius);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}
\ No newline at end of file
+:target{outline:0!important;padding:0!important}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--height)}.dashboard h1:first-of-type{margin-top:4rem!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!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(--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(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}.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(--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:fit-content;padding:.5rem!important;min-width:0;min-height:0}.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(--transition-base),width var(--transition-base),padding var(--transition-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(--transition-base),width var(--transition-base),padding var(--transition-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(--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(--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(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}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(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.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(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--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}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.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(--outerRadius);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}
\ No newline at end of file
diff --git a/assets/css/forms.min.css b/assets/css/forms.min.css
index 4bd1dd5..031ce4c 100644
--- a/assets/css/forms.min.css
+++ b/assets/css/forms.min.css
@@ -1 +1 @@
-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(--maxWidth)}}.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(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));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,.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(--rgb-subtle))}.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(--rgb-medium));opacity:1}.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 .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--innerRadius);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(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);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{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!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(--zz-top);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(--outerRadius);box-shadow:var(--shadow)}.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:var(--shadow);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(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));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(--height);bottom:var(--height);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(--rgb-heavy));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(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.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(--rgb-heavy))}.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(--height);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(--doubleHeight));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:var(--shadow);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(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);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:var(--shadow-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(--innerRadius);border-top-right-radius:var(--innerRadius);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(--innerRadius);border-bottom-right-radius:var(--innerRadius);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 var(--overlay-heavy);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(--innerRadius);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(--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(--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(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--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(--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(--outerRadius);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(--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:var(--shadow)}
\ No newline at end of file
+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(--maxWidth)}}.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(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));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(--rgb-subtle))}.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(--rgb-medium));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(--innerRadius);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(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.upload-group .selected .field{margin:0}.upload-group .group-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);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(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!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(--zz-top);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(--outerRadius);box-shadow:var(--shadow)}.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:var(--shadow);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(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));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(--height);bottom:var(--height);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(--rgb-heavy));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(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.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(--rgb-heavy))}.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(--height);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(--doubleHeight));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:var(--shadow);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(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);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:var(--shadow-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(--innerRadius);border-top-right-radius:var(--innerRadius);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(--innerRadius);border-bottom-right-radius:var(--innerRadius);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 var(--overlay-heavy);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(--innerRadius);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(--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(--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(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--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(--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(--outerRadius);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(--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:var(--shadow)}
\ No newline at end of file
diff --git a/assets/css/icons.css b/assets/css/icons.css
index 429920e..e69de29 100644
--- a/assets/css/icons.css
+++ b/assets/css/icons.css
@@ -1 +0,0 @@
-.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-sign-out{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMTgsMjE2YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLTYtNlY0MGE2LDYsMCwwLDEsNi02aDY0YTYsNiwwLDAsMSwwLDEySDU0VjIxMGg1OEE2LDYsMCwwLDEsMTE4LDIxNlptMTEwLjI0LTkyLjI0LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDguNDhMMjA5LjUxLDEyMkgxMTJhNiw2LDAsMCwwLDAsMTJoOTcuNTFsLTI5Ljc1LDI5Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsNDAtNDBBNiw2LDAsMCwwLDIyOC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-plus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkgxMzR2MzRhNiw2LDAsMCwxLTEyLDBWMTM0SDg4YTYsNiwwLDAsMSwwLTEyaDM0Vjg4YTYsNiwwLDAsMSwxMiwwdjM0aDM0QTYsNiwwLDAsMSwxNzQsMTI4WiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsMTkyYTYsNiwwLDAsMS02LDZIODhhNiw2LDAsMCwxLTYtNlY2Mi40OUw0NC4yNCwxMDAuMjRhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4YTYsNiwwLDEsMS04LjQ4LDguNDhMOTQsNjIuNDlWMTg2SDIzMkE2LDYsMCwwLDEsMjM4LDE5MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjAuMjQsMTAwLjI0YTYsNiwwLDAsMS04LjQ4LDBMMTc0LDYyLjQ5VjE5MmE2LDYsMCwwLDEtNiw2SDI0YTYsNiwwLDAsMSwwLTEySDE2MlY2Mi40OWwtMzcuNzYsMzcuNzVhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4QTYsNiwwLDAsMSwyMjAuMjQsMTAwLjI0WiIvPjwvc3ZnPg==');}.icon-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjQsMTk1Ljc2YTYsNiwwLDEsMS04LjQ4LDguNDhMMTI4LDEzNi40OSw2MC4yNCwyMDQuMjRhNiw2LDAsMCwxLTguNDgtOC40OEwxMTkuNTEsMTI4LDUxLjc2LDYwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MWw2Ny43Ni02Ny43NWE2LDYsMCwwLDEsOC40OCw4LjQ4TDEzNi40OSwxMjhaIi8+PC9zdmc+');}.icon-magnifying-glass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMjE5Ljc2bC01MS4zOC01MS4zOGE4Ni4xNSw4Ni4xNSwwLDEsMC04LjQ4LDguNDhsNTEuMzgsNTEuMzhhNiw2LDAsMCwwLDguNDgtOC40OFpNMzgsMTEyYTc0LDc0LDAsMSwxLDc0LDc0QTc0LjA5LDc0LjA5LDAsMCwxLDM4LDExMloiLz48L3N2Zz4=');}.icon-squares-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsNDJINTZBMTQsMTQsMCwwLDAsNDIsNTZ2NDhhMTQsMTQsMCwwLDAsMTQsMTRoNDhhMTQsMTQsMCwwLDAsMTQtMTRWNTZBMTQsMTQsMCwwLDAsMTA0LDQyWm0yLDYyYTIsMiwwLDAsMS0yLDJINTZhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabTk0LTYySDE1MmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFY1NkExNCwxNCwwLDAsMCwyMDAsNDJabTIsNjJhMiwyLDAsMCwxLTIsMkgxNTJhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabS05OCwzNEg1NmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMTA0LDEzOFptMiw2MmEyLDIsMCwwLDEtMiwySDU2YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMlptOTQtNjJIMTUyYTE0LDE0LDAsMCwwLTE0LDE0djQ4YTE0LDE0LDAsMCwwLDE0LDE0aDQ4YTE0LDE0LDAsMCwwLDE0LTE0VjE1MkExNCwxNCwwLDAsMCwyMDAsMTM4Wm0yLDYyYTIsMiwwLDAsMS0yLDJIMTUyYTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-rows{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMTM4SDQ4YTE0LDE0LDAsMCwwLTE0LDE0djQwYTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMjA4LDEzOFptMiw1NGEyLDIsMCwwLDEtMiwySDQ4YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJIMjA4YTIsMiwwLDAsMSwyLDJaTTIwOCw1MEg0OEExNCwxNCwwLDAsMCwzNCw2NHY0MGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjA4LDUwWm0yLDU0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY2NGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-table{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBaTTM4LDExMEg4MnYzNkgzOFptNTYsMEgyMTh2MzZIOTRaTTIxOCw2MlY5OEgzOFY2MlpNMzgsMTkyVjE1OEg4MnYzNkg0MEEyLDIsMCwwLDEsMzgsMTkyWm0xNzgsMkg5NFYxNThIMjE4djM0QTIsMiwwLDAsMSwyMTYsMTk0WiIvPjwvc3ZnPg==');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-eye{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDUuNDgsMTI1LjU3Yy0uMzQtLjc4LTguNjYtMTkuMjMtMjcuMjQtMzcuODFDMjAxLDcwLjU0LDE3MS4zOCw1MCwxMjgsNTBTNTUsNzAuNTQsMzcuNzYsODcuNzZjLTE4LjU4LDE4LjU4LTI2LjksMzctMjcuMjQsMzcuODFhNiw2LDAsMCwwLDAsNC44OGMuMzQuNzcsOC42NiwxOS4yMiwyNy4yNCwzNy44QzU1LDE4NS40Nyw4NC42MiwyMDYsMTI4LDIwNnM3My0yMC41Myw5MC4yNC0zNy43NWMxOC41OC0xOC41OCwyNi45LTM3LDI3LjI0LTM3LjhBNiw2LDAsMCwwLDI0NS40OCwxMjUuNTdaTTEyOCwxOTRjLTMxLjM4LDAtNTguNzgtMTEuNDItODEuNDUtMzMuOTNBMTM0Ljc3LDEzNC43NywwLDAsMSwyMi42OSwxMjgsMTM0LjU2LDEzNC41NiwwLDAsMSw0Ni41NSw5NS45NEM2OS4yMiw3My40Miw5Ni42Miw2MiwxMjgsNjJzNTguNzgsMTEuNDIsODEuNDUsMzMuOTRBMTM0LjU2LDEzNC41NiwwLDAsMSwyMzMuMzEsMTI4QzIyNi45NCwxNDAuMjEsMTk1LDE5NCwxMjgsMTk0Wm0wLTExMmE0Niw0NiwwLDEsMCw0Niw0NkE0Ni4wNiw0Ni4wNiwwLDAsMCwxMjgsODJabTAsODBhMzQsMzQsMCwxLDEsMzQtMzRBMzQsMzQsMCwwLDEsMTI4LDE2MloiLz48L3N2Zz4=');}.icon-eye-closed{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjkuMjEsMTY1YTYsNiwwLDAsMS0xMC40Miw2bC0yMC0zNS4wOGExMjIsMTIyLDAsMCwxLTM5LDE4LjA5bDYuMTcsMzdhNiw2LDAsMCwxLTQuOTMsNi45MSw2Ljg1LDYuODUsMCwwLDEtMSwuMDgsNiw2LDAsMCwxLTUuOTEtNUwxNDgsMTU2LjQ0YTEyOC44NiwxMjguODYsMCwwLDEtNDAsMEwxMDEuOTIsMTkzQTYsNiwwLDAsMSw5NiwxOThhNi44NSw2Ljg1LDAsMCwxLTEtLjA4QTYsNiwwLDAsMSw5MC4wOCwxOTFsNi4xNy0zN2ExMjIsMTIyLDAsMCwxLTM5LTE4LjA5TDM3LjIxLDE3MWE2LDYsMCwxLDEtMTAuNDItNmwyMC44NS0zNi40OGExNTIsMTUyLDAsMCwxLTIwLjMxLTIwLjc3LDYsNiwwLDAsMSw5LjM0LTcuNTRDNTMuNTQsMTIxLjExLDgzLjA3LDE0NiwxMjgsMTQ2czc0LjQ2LTI0Ljg5LDkxLjMzLTQ1Ljc3YTYsNiwwLDAsMSw5LjM0LDcuNTQsMTUyLDE1MiwwLDAsMS0yMC4zMSwyMC43N1oiLz48L3N2Zz4=');}.icon-calendar{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRIMTgyVjI0YTYsNiwwLDAsMC0xMiwwVjM0SDg2VjI0YTYsNiwwLDAsMC0xMiwwVjM0SDQ4QTE0LDE0LDAsMCwwLDM0LDQ4VjIwOGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMjA4LDM0Wk00OCw0Nkg3NFY1NmE2LDYsMCwwLDAsMTIsMFY0Nmg4NFY1NmE2LDYsMCwwLDAsMTIsMFY0NmgyNmEyLDIsMCwwLDEsMiwyVjgySDQ2VjQ4QTIsMiwwLDAsMSw0OCw0NlpNMjA4LDIxMEg0OGEyLDIsMCwwLDEtMi0yVjk0SDIxMFYyMDhBMiwyLDAsMCwxLDIwOCwyMTBabS05OC05MHY2NGE2LDYsMCwwLDEtMTIsMFYxMjkuNzFsLTcuMzIsMy42NmE2LDYsMCwxLDEtNS4zNi0xMC43NGwxNi04QTYsNiwwLDAsMSwxMTAsMTIwWm01OS41NywyOS4yNUwxNDgsMTc4aDIwYTYsNiwwLDAsMSwwLDEySDEzNmE2LDYsMCwwLDEtNC44LTkuNkwxNjAsMTQyYTEwLDEwLDAsMSwwLTE2LjY1LTExQTYsNiwwLDEsMSwxMzMsMTI1YTIyLDIyLDAsMSwxLDM2LjYyLDI0LjI2WiIvPjwvc3ZnPg==');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-sort-ascending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjYsMTI4YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLDAtMTJoNzJBNiw2LDAsMCwxLDEyNiwxMjhaTTQ4LDcwSDE4NGE2LDYsMCwwLDAsMC0xMkg0OGE2LDYsMCwwLDAsMCwxMlptNTYsMTE2SDQ4YTYsNiwwLDAsMCwwLDEyaDU2YTYsNiwwLDAsMCwwLTEyWm0xMjQuMjQtMjIuMjRhNiw2LDAsMCwwLTguNDgsMEwxOTAsMTkzLjUxVjExMmE2LDYsMCwwLDAtMTIsMHY4MS41MWwtMjkuNzYtMjkuNzVhNiw2LDAsMCwwLTguNDgsOC40OGw0MCw0MGE2LDYsMCwwLDAsOC40OCwwbDQwLTQwQTYsNiwwLDAsMCwyMjguMjQsMTYzLjc2WiIvPjwvc3ZnPg==');}.icon-sort-descending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00MiwxMjhhNiw2LDAsMCwxLDYtNmg3MmE2LDYsMCwwLDEsMCwxMkg0OEE2LDYsMCwwLDEsNDIsMTI4Wm02LTU4aDU2YTYsNiwwLDAsMCwwLTEySDQ4YTYsNiwwLDAsMCwwLDEyWk0xODQsMTg2SDQ4YTYsNiwwLDAsMCwwLDEySDE4NGE2LDYsMCwwLDAsMC0xMlpNMjI4LjI0LDgzLjc2bC00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsOC40OCw4LjQ4TDE3OCw2Mi40OVYxNDRhNiw2LDAsMCwwLDEyLDBWNjIuNDlsMjkuNzYsMjkuNzVhNiw2LDAsMCwwLDguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-floppy-disk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSw3My40MiwxODIuNTgsMzguMWExMy45LDEzLjksMCwwLDAtOS44OS00LjFINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY4My4zMUExMy45LDEzLjksMCwwLDAsMjE3LjksNzMuNDJaTTE3MCwyMTBIODZWMTUyYTIsMiwwLDAsMSwyLTJoODBhMiwyLDAsMCwxLDIsMlptNDAtMmEyLDIsMCwwLDEtMiwySDE4MlYxNTJhMTQsMTQsMCwwLDAtMTQtMTRIODhhMTQsMTQsMCwwLDAtMTQsMTR2NThINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDE3Mi42OWEyLDIsMCwwLDEsMS40MS41OEwyMDkuNDIsODEuOWEyLDIsMCwwLDEsLjU4LDEuNDFaTTE1OCw3MmE2LDYsMCwwLDEtNiw2SDk2YTYsNiwwLDAsMSwwLTEyaDU2QTYsNiwwLDAsMSwxNTgsNzJaIi8+PC9zdmc+');}.icon-columns{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsMzRINjRBMTQsMTQsMCwwLDAsNTAsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0aDQwYTE0LDE0LDAsMCwwLDE0LTE0VjQ4QTE0LDE0LDAsMCwwLDEwNCwzNFptMiwxNzRhMiwyLDAsMCwxLTIsMkg2NGEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMlpNMTkyLDM0SDE1MmExNCwxNCwwLDAsMC0xNCwxNFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRoNDBhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMTkyLDM0Wm0yLDE3NGEyLDIsMCwwLDEtMiwySDE1MmEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-arrows-left-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTcxLjc2YTYsNiwwLDAsMSwwLDguNDhsLTMyLDMyYTYsNiwwLDAsMS04LjQ4LTguNDhMMTkzLjUxLDE4Mkg0OGE2LDYsMCwwLDEsMC0xMkgxOTMuNTFsLTIxLjc1LTIxLjc2YTYsNiwwLDAsMSw4LjQ4LTguNDhaTTc1Ljc2LDExNi4yNGE2LDYsMCwwLDAsOC40OC04LjQ4TDYyLjQ5LDg2SDIwOGE2LDYsMCwwLDAsMC0xMkg2Mi40OUw4NC4yNCw1Mi4yNGE2LDYsMCwwLDAtOC40OC04LjQ4bC0zMiwzMmE2LDYsMCwwLDAsMCw4LjQ4WiIvPjwvc3ZnPg==');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-x-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4Wk0yMzAsMTI4QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-user{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjkuMTksMjEzYy0xNS44MS0yNy4zMi00MC42My00Ni40OS02OS40Ny01NC42MmE3MCw3MCwwLDEsMC02My40NCwwQzY3LjQ0LDE2Ni41LDQyLjYyLDE4NS42NywyNi44MSwyMTNhNiw2LDAsMSwwLDEwLjM4LDZDNTYuNCwxODUuODEsOTAuMzQsMTY2LDEyOCwxNjZzNzEuNiwxOS44MSw5MC44MSw1M2E2LDYsMCwxLDAsMTAuMzgtNlpNNzAsOTZhNTgsNTgsMCwxLDEsNTgsNThBNTguMDcsNTguMDcsMCwwLDEsNzAsOTZaIi8+PC9zdmc+');}.icon-crosshair{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIsMTIySDIyMS44QTk0LjEzLDk0LjEzLDAsMCwwLDEzNCwzNC4yVjI0YTYsNiwwLDAsMC0xMiwwVjM0LjJBOTQuMTMsOTQuMTMsMCwwLDAsMzQuMiwxMjJIMjRhNiw2LDAsMCwwLDAsMTJIMzQuMkE5NC4xMyw5NC4xMywwLDAsMCwxMjIsMjIxLjhWMjMyYTYsNiwwLDAsMCwxMiwwVjIyMS44QTk0LjEzLDk0LjEzLDAsMCwwLDIyMS44LDEzNEgyMzJhNiw2LDAsMCwwLDAtMTJabS05OCw4Ny43NlYyMDBhNiw2LDAsMCwwLTEyLDB2OS43NkE4Mi4wOSw4Mi4wOSwwLDAsMSw0Ni4yNCwxMzRINTZhNiw2LDAsMCwwLDAtMTJINDYuMjRBODIuMDksODIuMDksMCwwLDEsMTIyLDQ2LjI0VjU2YTYsNiwwLDAsMCwxMiwwVjQ2LjI0QTgyLjA5LDgyLjA5LDAsMCwxLDIwOS43NiwxMjJIMjAwYTYsNiwwLDAsMCwwLDEyaDkuNzZBODIuMDksODIuMDksMCwwLDEsMTM0LDIwOS43NlpNMTI4LDkwYTM4LDM4LDAsMSwwLDM4LDM4QTM4LDM4LDAsMCwwLDEyOCw5MFptMCw2NGEyNiwyNiwwLDEsMSwyNi0yNkEyNiwyNiwwLDAsMSwxMjgsMTU0WiIvPjwvc3ZnPg==');}.icon-person{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsNzBBMzAsMzAsMCwxLDAsOTgsNDAsMzAsMzAsMCwwLDAsMTI4LDcwWm0wLTQ4YTE4LDE4LDAsMSwxLTE4LDE4QTE4LDE4LDAsMCwxLDEyOCwyMlptODguODgsMTEzLjQyTDE3MS42Nyw4NC4xNkEzMCwzMCwwLDAsMCwxNDkuMTcsNzRIMTA2LjgzYTMwLDMwLDAsMCwwLTIyLjUsMTAuMTVMMzkuMTIsMTM1LjQyQTE4LDE4LDAsMCwwLDY0LjQ2LDE2MWwyMS4xMS0xNi45M0w2Ny40NCwyMTIuOTJhMTgsMTgsMCwwLDAsMzIuNzUsMTQuOTRMMTI4LDE4MGwyNy44MSw0Ny45MWExOCwxOCwwLDAsMCwzMi43NS0xNC45NGwtMTguMTMtNjguODdMMTkxLjU0LDE2MWExOCwxOCwwLDAsMCwyNS4zNC0yNS41NlptLTguNjMsMTYuODJhNiw2LDAsMCwxLTguNDksMCw0LjE1LDQuMTUsMCwwLDAtLjQ5LS40NGwtMzUuNTEtMjguNDhhNiw2LDAsMCwwLTkuNTYsNi4ybDIyLjg3LDg2LjkzYTcuNjYsNy42NiwwLDAsMCwuMzcsMSw2LDYsMCwwLDEtMTAuODgsNS4wNyw0LjM3LDQuMzcsMCwwLDAtLjI1LS40OEwxMzMuMTksMTY1YTYsNiwwLDAsMC0xMC4zOCwwTDg5LjY5LDIyMi4wNWE0LjM3LDQuMzcsMCwwLDAtLjI1LjQ4LDYsNiwwLDAsMS0xMC44OC01LjA3LDcuNjYsNy42NiwwLDAsMCwuMzctMWwyMi44Ny04Ni45M0E2LDYsMCwwLDAsOTkuMjcsMTIzLDYuMDcsNi4wNywwLDAsMCw5NiwxMjJhNiw2LDAsMCwwLTMuNzYsMS4zMkw1Ni43MywxNTEuOGE0LjE1LDQuMTUsMCwwLDAtLjQ5LjQ0LDYsNiwwLDAsMS04LjQ5LTguNDlsLjI2LS4yN0w5My4zMyw5Mi4wOUExOCwxOCwwLDAsMSwxMDYuODMsODZoNDIuMzRhMTgsMTgsMCwwLDEsMTMuNSw2LjA5TDIwOCwxNDMuNDhsLjI2LjI3QTYsNiwwLDAsMSwyMDguMjUsMTUyLjI0WiIvPjwvc3ZnPg==');}.icon-folder{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNzRIMTMwLjQ5bC0yNy45LTI3LjlhMTMuOTQsMTMuOTQsMCwwLDAtOS45LTQuMUg0MEExNCwxNCwwLDAsMCwyNiw1NlYyMDAuNjJBMTMuMzksMTMuMzksMCwwLDAsMzkuMzgsMjE0SDIxNi44OUExMy4xMiwxMy4xMiwwLDAsMCwyMzAsMjAwLjg5Vjg4QTE0LDE0LDAsMCwwLDIxNiw3NFpNNDAsNTRIOTIuNjlhMiwyLDAsMCwxLDEuNDEuNTlMMTEzLjUxLDc0SDM4VjU2QTIsMiwwLDAsMSw0MCw1NFpNMjE4LDIwMC44OWExLjExLDEuMTEsMCwwLDEtMS4xMSwxLjExSDM5LjM4QTEuNCwxLjQsMCwwLDEsMzgsMjAwLjYyVjg2SDIxNmEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-dots-six{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik03MCw5MkExMCwxMCwwLDEsMSw2MCw4MiwxMCwxMCwwLDAsMSw3MCw5MlptNTgtMTBhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsMTI4LDgyWm02OCwyMGExMCwxMCwwLDEsMC0xMC0xMEExMCwxMCwwLDAsMCwxOTYsMTAyWk02MCwxNTRhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsNjAsMTU0Wm02OCwwYTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDEyOCwxNTRabTY4LDBhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsMTk2LDE1NFoiLz48L3N2Zz4=');}.icon-cake{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzAsMTEyYTIyLDIyLDAsMCwwLTIyLTIySDEzNFY3Ny40QTMwLjA1LDMwLjA1LDAsMCwwLDE1OCw0OGMwLTI2LjktMjUuNTgtNDQuMjctMjYuNjctNDVhNiw2LDAsMCwwLTYuNjYsMEMxMjMuNTgsMy43Myw5OCwyMS4xLDk4LDQ4YTMwLjA1LDMwLjA1LDAsMCwwLDI0LDI5LjRWOTBINDhhMjIsMjIsMCwwLDAtMjIsMjJ2MjMuMzNhMzguODEsMzguODEsMCwwLDAsOCwyMy41N1YyMDBhMjIsMjIsMCwwLDAsMjIsMjJIMjAwYTIyLDIyLDAsMCwwLDIyLTIyVjE1OC45YTM4LjgxLDM4LjgxLDAsMCwwLDgtMjMuNTdaTTExMCw0OGMwLTE1LjQsMTItMjcuNDEsMTgtMzIuNDQsNiw1LDE4LDE3LDE4LDMyLjQ0YTE4LDE4LDAsMCwxLTM2LDBaTTM4LDExMmExMCwxMCwwLDAsMSwxMC0xMEgyMDhhMTAsMTAsMCwwLDEsMTAsMTB2MjMuMzNjMCwxNC4zMi0xMS4zMywyNi4yOC0yNS4yNiwyNi42NkEyNiwyNiwwLDAsMSwxNjYsMTM2YTYsNiwwLDAsMC0xMiwwLDI2LDI2LDAsMCwxLTUyLDAsNiw2LDAsMCwwLTEyLDAsMjYsMjYsMCwwLDEtMjYuNzQsMjZDNDkuMzMsMTYxLjYxLDM4LDE0OS42NSwzOCwxMzUuMzNabTE2Miw5OEg1NmExMCwxMCwwLDAsMS0xMC0xMFYxNjkuMzFBMzYuOCwzNi44LDAsMCwwLDYyLjk0LDE3NCwzNy44MiwzNy44MiwwLDAsMCw5MC41LDE2My4yNCwzOC44MywzOC44MywwLDAsMCw5NiwxNTYuNWEzOCwzOCwwLDAsMCw2NCwwLDM4LjgzLDM4LjgzLDAsMCwwLDUuNTIsNi43NEEzNy43MywzNy43MywwLDAsMCwxOTIsMTc0aDEuMDdBMzYuOCwzNi44LDAsMCwwLDIxMCwxNjkuMzFWMjAwQTEwLDEwLDAsMCwxLDIwMCwyMTBaIi8+PC9zdmc+');}.icon-hourglass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTgsNzUuNjRWNDBhMTQsMTQsMCwwLDAtMTQtMTRINzJBMTQsMTQsMCwwLDAsNTgsNDBWNzZhMTQuMDYsMTQuMDYsMCwwLDAsNS42LDExLjJMMTE4LDEyOCw2My42LDE2OC44QTE0LjA2LDE0LjA2LDAsMCwwLDU4LDE4MHYzNmExNCwxNCwwLDAsMCwxNCwxNEgxODRhMTQsMTQsMCwwLDAsMTQtMTRWMTgwLjM2YTE0LjA4LDE0LjA4LDAsMCwwLTUuNTYtMTEuMTdMMTM4LDEyOGw1NC40OS00MS4xOUExNC4wOCwxNC4wOCwwLDAsMCwxOTgsNzUuNjRaTTE4NiwxODAuMzZWMjE2YTIsMiwwLDAsMS0yLDJINzJhMiwyLDAsMCwxLTItMlYxODBhMiwyLDAsMCwxLC44LTEuNkwxMjgsMTM1LjUxbDU3LjIyLDQzLjI1QTIsMiwwLDAsMSwxODYsMTgwLjM2Wm0wLTEwNC43MmEyLDIsMCwwLDEtLjc5LDEuNkwxMjgsMTIwLjQ5LDcwLjgsNzcuNkEyLDIsMCwwLDEsNzAsNzZWNDBhMiwyLDAsMCwxLDItMkgxODRhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-hash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsOTBIMTczbDguODktNDguOTNhNiw2LDAsMSwwLTExLjgtMi4xNEwxNjAuODEsOTBIMTA5bDguODktNDguOTNhNiw2LDAsMCwwLTExLjgtMi4xNEw5Ni44MSw5MEg0OGE2LDYsMCwwLDAsMCwxMkg5NC42M2wtOS40Niw1MkgzMmE2LDYsMCwwLDAsMCwxMkg4M0w3NC4xLDIxNC45M2E2LDYsMCwwLDAsNC44Myw3QTUuNjQsNS42NCwwLDAsMCw4MCwyMjJhNiw2LDAsMCwwLDUuODktNC45M0w5NS4xOSwxNjZIMTQ3bC04Ljg5LDQ4LjkzYTYsNiwwLDAsMCw0LjgzLDcsNS42NCw1LjY0LDAsMCwwLDEuMDguMSw2LDYsMCwwLDAsNS44OS00LjkzTDE1OS4xOSwxNjZIMjA4YTYsNiwwLDAsMCwwLTEySDE2MS4zN2w5LjQ2LTUySDIyNGE2LDYsMCwwLDAsMC0xMlptLTc0LjgzLDY0SDk3LjM3bDkuNDYtNTJoNTEuOFoiLz48L3N2Zz4=');}.icon-pencil-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjUuOSw3NC43OCwxODEuMjEsMzAuMDlhMTQsMTQsMCwwLDAtMTkuOCwwTDM4LjEsMTUzLjQxYTEzLjk0LDEzLjk0LDAsMCwwLTQuMSw5LjlWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDkyLjY5YTEzLjk0LDEzLjk0LDAsMCwwLDkuOS00LjFMMjI1LjksOTQuNThhMTQsMTQsMCwwLDAsMC0xOS44Wk05NC4xLDIwOS40MWEyLDIsMCwwLDEtMS40MS41OUg0OGEyLDIsMCwwLDEtMi0yVjE2My4zMWEyLDIsMCwwLDEsLjU5LTEuNDFMMTM2LDcyLjQ4LDE4My41MSwxMjBaTTIxNy40MSw4Ni4xLDE5MiwxMTEuNTEsMTQ0LjQ5LDY0LDE2OS45LDM4LjU4YTIsMiwwLDAsMSwyLjgzLDBsNDQuNjgsNDQuNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-caret-double-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTMxLjc2YTYsNiwwLDAsMSwwLDguNDhsLTgwLDgwYTYsNiwwLDAsMS04LjQ4LDBsLTgwLTgwYTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDIwNy41MWw3NS43Ni03NS43NUE2LDYsMCwwLDEsMjEyLjI0LDEzMS43NlptLTg4LjQ4LDguNDhhNiw2LDAsMCwwLDguNDgsMGw4MC04MGE2LDYsMCwwLDAtOC40OC04LjQ4TDEyOCwxMjcuNTEsNTIuMjQsNTEuNzZhNiw2LDAsMCwwLTguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-caret-double-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDEyNy41MSwxMjgsNTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTQwLjI0LDEzMi4yNFptODAtOC40OC04MC04MGE2LDYsMCwwLDAtOC40OCw4LjQ4TDIwNy41MSwxMjhsLTc1Ljc1LDc1Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsODAtODBBNiw2LDAsMCwwLDIyMC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-dots-six-vertical{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDIsNjBBMTAsMTAsMCwxLDEsOTIsNTAsMTAsMTAsMCwwLDEsMTAyLDYwWm02MiwxMGExMCwxMCwwLDEsMC0xMC0xMEExMCwxMCwwLDAsMCwxNjQsNzBaTTkyLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw5MiwxMThabTcyLDBhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsMTY0LDExOFpNOTIsMTg2YTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDkyLDE4NlptNzIsMGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCwxNjQsMTg2WiIvPjwvc3ZnPg==');}.icon-drop-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuNTMsNDkuMDZhMjUxLjQyLDI1MS40MiwwLDAsMC00MS4wOS0zOCw2LDYsMCwwLDAtNi44OCwwLDI1MS40MiwyNTEuNDIsMCwwLDAtNDEuMDksMzhDNTYuMzQsODAuMjYsNDIsMTEzLjA5LDQyLDE0NGE4Niw4NiwwLDAsMCwxNzIsMEMyMTQsMTEzLjA5LDE5OS42Niw4MC4yNiwxNzIuNTMsNDkuMDZaTTEyOCwyMThhNzQuMDksNzQuMDksMCwwLDEtNzQtNzRjMC01OS42Miw1OS0xMDguOTMsNzQtMTIwLjUxQzE0MywzNS4wNywyMDIsODQuMzgsMjAyLDE0NEE3NC4wOSw3NC4wOSwwLDAsMSwxMjgsMjE4WiIvPjwvc3ZnPg==');}.icon-door{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIsMjE4SDIwNlY0MGExNCwxNCwwLDAsMC0xNC0xNEg2NEExNCwxNCwwLDAsMCw1MCw0MFYyMThIMjRhNiw2LDAsMCwwLDAsMTJIMjMyYTYsNiwwLDAsMCwwLTEyWk02Miw0MGEyLDIsMCwwLDEsMi0ySDE5MmEyLDIsMCwwLDEsMiwyVjIxOEg2MlptMTA0LDkyYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE2NiwxMzJaIi8+PC9zdmc+');}.icon-hand-heart{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjkuMTIsMTQyLjY1YTIyLjQzLDIyLjQzLDAsMCwwLTE5LjU1LTMuODhsLTQuMzIsMUMyMjcsMTE5LjU1LDIzOCw5OS41MSwyMzgsODBjMC0yNS4zNi0yMC4zOS00Ni00NS40Ni00NkE0NS41MSw0NS41MSwwLDAsMCwxNTYsNTJhNDUuNTEsNDUuNTEsMCwwLDAtMzYuNTQtMThDOTQuMzksMzQsNzQsNTQuNjQsNzQsODBjMCwxMS4zOCwzLjYzLDIyLjQ5LDExLjI5LDM0LjM2YTI5LjczLDI5LjczLDAsMCwwLTE2LjU2LDguNDNMNDUuNTIsMTQ2SDE2QTE0LDE0LDAsMCwwLDIsMTYwdjQwYTE0LDE0LDAsMCwwLDE0LDE0SDEyMGE2LDYsMCwwLDAsMS40Ni0uMThsNjQtMTZhNy4xNiw3LjE2LDAsMCwwLC44OS0uM0wyMjUuMTcsMTgxbC4zMy0uMTVhMjIuNiwyMi42LDAsMCwwLDMuNjItMzguMThaTTExOS40Niw0NmEzMy4xNiwzMy4xNiwwLDAsMSwzMSwyMC4yOCw2LDYsMCwwLDAsMTEuMSwwLDMzLjE2LDMzLjE2LDAsMCwxLDMxLTIwLjI4QzIxMC42OCw0NiwyMjYsNjEuNTcsMjI2LDgwYzAsMjAuMjQtMTYuMTgsNDMtNDYuOCw2NS43NWwtMTQuODcsMy40MkEyNiwyNiwwLDAsMCwxNDAsMTE0SDk5LjY3QzkwLjM2LDEwMS42Nyw4Niw5MC44MSw4Niw4MCw4Niw2MS41NywxMDEuMzIsNDYsMTE5LjQ2LDQ2Wk0xNCwyMDBWMTYwYTIsMiwwLDAsMSwyLTJINDJ2NDRIMTZBMiwyLDAsMCwxLDE0LDIwMFptMjA2LjI4LTMwLTM4LjIsMTYuMjdMMTE5LjI2LDIwMkg1NFYxNTQuNDlsMjMuMjEtMjMuMjJBMTcuODgsMTcuODgsMCwwLDEsODkuOTQsMTI2SDE0MGExNCwxNCwwLDAsMSwwLDI4SDExMmE2LDYsMCwwLDAsMCwxMmgzMmE2LDYsMCwwLDAsMS4zNC0uMTVsNjctMTUuNDEuMjQtLjA2QTEwLjYsMTAuNiwwLDAsMSwyMjAuMjgsMTcwWiIvPjwvc3ZnPg==');}.icon-plugs-connected{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTkuNzZhNiw2LDAsMCwwLTguNDgsMEwxNzMuOTQsNzMuNTdsLTYuNzktNi43OGEzMCwzMCwwLDAsMC00Mi40MiwwTDEwMCw5MS41MWwtNy43Ni03Ljc1YTYsNiwwLDAsMC04LjQ4LDguNDhMOTEuNTEsMTAwLDY2Ljc5LDEyNC43M2EzMCwzMCwwLDAsMCwwLDQyLjQybDYuNzgsNi43OUwxOS43NiwyMjcuNzZhNiw2LDAsMSwwLDguNDgsOC40OGw1My44Mi01My44MSw2Ljc5LDYuNzhhMzAsMzAsMCwwLDAsNDIuNDIsMEwxNTYsMTY0LjQ5bDcuNzYsNy43NWE2LDYsMCwwLDAsOC40OC04LjQ4TDE2NC40OSwxNTZsMjQuNzItMjQuNzNhMzAsMzAsMCwwLDAsMC00Mi40MmwtNi43OC02Ljc5LDUzLjgxLTUzLjgyQTYsNiwwLDAsMCwyMzYuMjQsMTkuNzZabS0xMTMuNDUsMTYxYTE4LDE4LDAsMCwxLTI1LjQ2LDBMNzUuMjcsMTU4LjY3YTE4LDE4LDAsMCwxLDAtMjUuNDZMMTAwLDEwOC40OSwxNDcuNTEsMTU2Wm01Ny45NC01Ny45NEwxNTYsMTQ3LjUxLDEwOC40OSwxMDBsMjQuNzItMjQuNzNhMTgsMTgsMCwwLDEsMjUuNDYsMGwyMi4wNiwyMi4wNmExOCwxOCwwLDAsMSwwLDI1LjQ2Wk05MC40MywzNC4yM2E2LDYsMCwwLDEsMTEuMTQtNC40Nmw4LDIwYTYsNiwwLDEsMS0xMS4xNCw0LjQ2Wm0tNjQsNTkuNTRhNiw2LDAsMCwxLDcuOC0zLjM0bDIwLDhhNiw2LDAsMSwxLTQuNDYsMTEuMTRsLTIwLThBNiw2LDAsMCwxLDI2LjQzLDkzLjc3Wm0yMDMuMTQsNjguNDZhNiw2LDAsMCwxLTcuOCwzLjM0bC0yMC04YTYsNiwwLDAsMSw0LjQ2LTExLjE0bDIwLDhBNiw2LDAsMCwxLDIyOS41NywxNjIuMjNabS02NCw1OS41NGE2LDYsMCwxLDEtMTEuMTQsNC40NmwtOC0yMGE2LDYsMCwwLDEsMTEuMTQtNC40NloiLz48L3N2Zz4=');}.icon-question{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTgwYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxODBaTTEyOCw3NGMtMjEsMC0zOCwxNS4yNS0zOCwzNHY0YTYsNiwwLDAsMCwxMiwwdi00YzAtMTIuMTMsMTEuNjYtMjIsMjYtMjJzMjYsOS44NywyNiwyMi0xMS42NiwyMi0yNiwyMmE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LTIuNDJjMTguMTEtMi41OCwzMi0xNi42NiwzMi0zMy41OEMxNjYsODkuMjUsMTQ5LDc0LDEyOCw3NFptMTAyLDU0QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-asterisk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTMuMTQsMTc5LjA5YTYsNiwwLDAsMS04LjIzLDIuMDZMMTM0LDEzOC42VjIxNmE2LDYsMCwwLDEtMTIsMFYxMzguNkw1MS4wOSwxODEuMTVBNi4wNyw2LjA3LDAsMCwxLDQ4LDE4MmE2LDYsMCwwLDEtMy4xLTExLjE1TDExNi4zNCwxMjgsNDQuOTEsODUuMTVhNiw2LDAsMCwxLDYuMTgtMTAuM0wxMjIsMTE3LjRWNDBhNiw2LDAsMCwxLDEyLDB2NzcuNGw3MC45MS00Mi41NWE2LDYsMCwwLDEsNi4xOCwxMC4zTDEzOS42NiwxMjhsNzEuNDMsNDIuODVBNiw2LDAsMCwxLDIxMy4xNCwxNzkuMDlaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-clock{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm0wLDE5MmE5MCw5MCwwLDEsMSw5MC05MEE5MC4xLDkwLjEsMCwwLDEsMTI4LDIxOFptNjItOTBhNiw2LDAsMCwxLTYsNkgxMjhhNiw2LDAsMCwxLTYtNlY3MmE2LDYsMCwwLDEsMTIsMHY1MGg1MEE2LDYsMCwwLDEsMTkwLDEyOFoiLz48L3N2Zz4=');}.icon-envelope{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBabS05Niw4NS44Nkw0Ny40Miw2MkgyMDguNThaTTEwMS42NywxMjgsMzgsMTg2LjM2VjY5LjY0Wm04Ljg4LDguMTRMMTI0LDE0OC40MmE2LDYsMCwwLDAsOC4xLDBsMTMuNC0xMi4yOEwyMDguNTgsMTk0SDQ3LjQzWk0xNTQuMzMsMTI4LDIxOCw2OS42NFYxODYuMzZaIi8+PC9zdmc+');}.icon-facebook-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm02LDE5MS44VjE1MGgyNmE2LDYsMCwwLDAsMC0xMkgxMzRWMTEyYTE4LDE4LDAsMCwxLDE4LTE4aDE2YTYsNiwwLDAsMCwwLTEySDE1MmEzMCwzMCwwLDAsMC0zMCwzMHYyNkg5NmE2LDYsMCwwLDAsMCwxMmgyNnY2Ny44YTkwLDkwLDAsMSwxLDEyLDBaIi8+PC9zdmc+');}.icon-twitter-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDUuNTQsNjkuNzFBNiw2LDAsMCwwLDI0MCw2NkgyMDguNGE0Ni42LDQ2LjYsMCwwLDAtNDAuMzMtMjQsNDQuOTMsNDQuOTMsMCwwLDAtMzIuMzEsMTMuMTJBNDUuOTIsNDUuOTIsMCwwLDAsMTIyLDg4djguNjZjLTQyLTEwLTc2LjYtNDQuNTItNzctNDQuODhBNiw2LDAsMCwwLDM0LjgsNTUuNDZjLTQuMjUsNDcuMjIsOS40Miw3OC43NSwyMS42NCw5Ni44OWExMDcuNzEsMTA3LjcxLDAsMCwwLDIzLjA3LDI1Yy0xNS40OSwxOS00MS4zNCwyOC44OS00MS42MiwyOWE2LDYsMCwwLDAtMi44OCw5QzM2LDIxNi44Myw0NS43OCwyMzAsODAsMjMwYzY5Ljg4LDAsMTI4LjIzLTU0LDEzMy44Mi0xMjMuMzRsMzAuNDItMzAuNDFBNiw2LDAsMCwwLDI0NS41NCw2OS43MVpNMjAzLjc2LDk5Ljc2YTYsNiwwLDAsMC0xLjc1LDMuODZDMTk3LjkzLDE2Ny43NiwxNDQuMzMsMjE4LDgwLDIxOGMtMTQsMC0yMi43Ni0yLjQxLTI4LjA2LTQuOCwxMS4zLTUuNjgsMjkuNzItMTYuODgsNDEuMDUtMzMuODdhNiw2LDAsMCwwLC44NS00LjY3QTYsNiwwLDAsMCw5MSwxNzAuODJjLS4xMy0uMDgtMTMuMTMtNy44Ni0yNS0yNS43MkM1MiwxMjQsNDUuMzEsOTguNDMsNDYsNjguODhjMTQuODgsMTIuNiw0NS41NywzNS4wOSw4MSw0MWE2LDYsMCwwLDAsNy01LjkyVjg4YTM0LDM0LDAsMCwxLDEwLjE5LTI0LjM0QTMzLjA1LDMzLjA1LDAsMCwxLDE2Ny45Myw1NGMxMy40My4xNywyNiw4LjM3LDMxLjI0LDIwLjRhNiw2LDAsMCwwLDUuNSwzLjZoMjAuODRaIi8+PC9zdmc+');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-list{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTYsNiwwLDAsMS02LDZINDBhNiw2LDAsMCwxLDAtMTJIMjE2QTYsNiwwLDAsMSwyMjIsMTI4Wk00MCw3MEgyMTZhNiw2LDAsMCwwLDAtMTJINDBhNiw2LDAsMCwwLDAsMTJaTTIxNiwxODZINDBhNiw2LDAsMCwwLDAsMTJIMjE2YTYsNiwwLDAsMCwwLTEyWiIvPjwvc3ZnPg==');}.icon-instagram-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsODJhNDYsNDYsMCwxLDAsNDYsNDZBNDYuMDYsNDYuMDYsMCwwLDAsMTI4LDgyWm0wLDgwYTM0LDM0LDAsMSwxLDM0LTM0QTM0LDM0LDAsMCwxLDEyOCwxNjJaTTE3NiwyNkg4MEE1NC4wNiw1NC4wNiwwLDAsMCwyNiw4MHY5NmE1NC4wNiw1NC4wNiwwLDAsMCw1NCw1NGg5NmE1NC4wNiw1NC4wNiwwLDAsMCw1NC01NFY4MEE1NC4wNiw1NC4wNiwwLDAsMCwxNzYsMjZabTQyLDE1MGE0Miw0MiwwLDAsMS00Miw0Mkg4MGE0Miw0MiwwLDAsMS00Mi00MlY4MEE0Miw0MiwwLDAsMSw4MCwzOGg5NmE0Miw0MiwwLDAsMSw0Miw0MlpNMTkwLDc2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE5MCw3NloiLz48L3N2Zz4=');}.icon-butterfly{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzEuMSw1MS43MUMyMjYuMDksNDUuMjcsMjE4LjY0LDQyLDIwOSw0MmMtMTYuMzMsMC0zNy40MSwxMS4wNi01Ni40LDI5LjU5QTEzMiwxMzIsMCwwLDAsMTM0LDkzLjk0VjU2YTYsNiwwLDAsMC0xMiwwVjkzLjk0YTEzMiwxMzIsMCwwLDAtMTguNTQtMjIuMzVDODQuNDksNTMuMDYsNjMuNCw0Miw0Ny4wNyw0MmMtOS42OSwwLTE3LjE0LDMuMjctMjIuMTUsOS43MS01LjUzLDcuMTEtNy43MSwxNy42OS02LjY2LDMyLjM0LjkxLDEyLjczLDQuMTIsMjYuNTMsNi44MSwzNy4xMyw2LjI4LDI0Ljc0LDIwLjc3LDMzLDMxLjc4LDM1LjY4QTQyLDQyLDAsMSwwLDEyOCwyMDEuNjJhNDIsNDIsMCwxLDAsNzEuMTYtNDQuNzZjMTEtMi42MywyNS41LTEwLjk0LDMxLjc4LTM1LjY4QzIzNy41NSw5NS4yMSwyNDIuNzIsNjYuNjUsMjMxLjEsNTEuNzFaTTkyLDIxMGEzMC4xMiwzMC4xMiwwLDAsMS0zLjM0LTYwQTYsNiwwLDAsMCw4Ny4zNSwxMzhhNDEuNzEsNDEuNzEsMCwwLDAtMjAuMjgsOGMtOSwuMzEtMjQuMTItMy4xNi0zMC4zNy0yNy43Ni0zLjI1LTEyLjgxLTExLjg5LTQ2LjgzLTIuMzEtNTkuMTVDMzcuMDUsNTUuNjYsNDEuMiw1NCw0Ny4wNyw1NGMxMi44OCwwLDMxLjcyLDEwLjI4LDQ4LDI2LjE4QzExMS42OSw5Ni4zOSwxMjIsMTE0LjU5LDEyMiwxMjcuNjdWMTgwQTMwLDMwLDAsMCwxLDkyLDIxMFptMTI3LjMxLTkxLjc3QzIxMy4wNywxNDIuODMsMTk4LDE0Ni4yOSwxODksMTQ2YTQxLjYyLDQxLjYyLDAsMCwwLTIwLjI4LThBNiw2LDAsMSwwLDE2Ny4zNiwxNTAsMzAuMTEsMzAuMTEsMCwxLDEsMTM0LDE4MFYxMjcuNjdjMC0xMy4wOCwxMC4zMi0zMS4yOCwyNi45My00Ny40OUMxNzcuMjMsNjQuMjgsMTk2LjA3LDU0LDIwOSw1NGM1Ljg3LDAsMTAsMS42NiwxMi42OCw1LjA4QzIzMS4yMSw3MS40LDIyMi41NywxMDUuNDIsMjE5LjMyLDExOC4yM1oiLz48L3N2Zz4=');}.icon-phone{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjEuNTksMTYwLjNsLTQ3LjI0LTIxLjE3YTE0LDE0LDAsMCwwLTEzLjI4LDEuMjIsNC44MSw0LjgxLDAsMCwwLS41Ni40MmwtMjQuNjksMjFhMS44OCwxLjg4LDAsMCwxLTEuNjguMDZjLTE1Ljg3LTcuNjYtMzIuMzEtMjQtNDAtMzkuNjVhMS45MSwxLjkxLDAsMCwxLDAtMS42OGwyMS4wNy0yNWE2LjEzLDYuMTMsMCwwLDAsLjQyLS41OCwxNCwxNCwwLDAsMCwxLjEyLTEzLjI3TDk1LjczLDM0LjQ5YTE0LDE0LDAsMCwwLTE0LjU2LTguMzhBNTQuMjQsNTQuMjQsMCwwLDAsMzQsODBjMCw3OC4zLDYzLjcsMTQyLDE0MiwxNDJhNTQuMjUsNTQuMjUsMCwwLDAsNTMuODktNDcuMTdBMTQsMTQsMCwwLDAsMjIxLjU5LDE2MC4zWk0xNzYsMjEwQzEwNC4zMiwyMTAsNDYsMTUxLjY4LDQ2LDgwQTQyLjIzLDQyLjIzLDAsMCwxLDgyLjY3LDM4aC4yM2EyLDIsMCwwLDEsMS44NCwxLjMxbDIxLjEsNDcuMTFhMiwyLDAsMCwxLDAsMS42N0w4NC43MywxMTMuMTVhNC43Myw0LjczLDAsMCwwLS40My41NywxNCwxNCwwLDAsMC0uOTEsMTMuNzNjOC44NywxOC4xNiwyNy4xNywzNi4zMiw0NS41Myw0NS4xOWExNCwxNCwwLDAsMCwxMy43Ny0xYy4xOS0uMTMuMzgtLjI3LjU2LS40MmwyNC42OC0yMWExLjkyLDEuOTIsMCwwLDEsMS42LS4xbDQ3LjI1LDIxLjE3YTIsMiwwLDAsMSwxLjIxLDJBNDIuMjQsNDIuMjQsMCwwLDEsMTc2LDIxMFoiLz48L3N2Zz4=');}.icon-chat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBINDBBMTQsMTQsMCwwLDAsMjYsNjRWMjI0YTEzLjg4LDEzLjg4LDAsMCwwLDguMDksMTIuNjlBMTQuMTEsMTQuMTEsMCwwLDAsNDAsMjM4YTEzLjg3LDEzLjg3LDAsMCwwLDktMy4zMWwuMDYtLjA1TDgyLjIzLDIwNkgyMTZhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjE2LDUwWm0yLDE0MmEyLDIsMCwwLDEtMiwySDgwYTYsNiwwLDAsMC0zLjkyLDEuNDZMNDEuMjYsMjI1LjUzQTIsMiwwLDAsMSwzOCwyMjRWNjRhMiwyLDAsMCwxLDItMkgyMTZhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-tiktok-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNzRhNTAuMDYsNTAuMDYsMCwwLDEtNTAtNTAsNiw2LDAsMCwwLTYtNkgxMjhhNiw2LDAsMCwwLTYsNlYxNTZhMjIsMjIsMCwxLDEtMzEuNDMtMTkuODlBNiw2LDAsMCwwLDk0LDEzMC42OVY4OGE2LDYsMCwwLDAtNy01LjkxQzUyLjIsODguMjgsMjYsMTIwLjA1LDI2LDE1NmE3NCw3NCwwLDAsMCwxNDgsMFYxMTIuOTNBMTAxLjI4LDEwMS4yOCwwLDAsMCwyMjQsMTI2YTYsNiwwLDAsMCw2LTZWODBBNiw2LDAsMCwwLDIyNCw3NFptLTYsMzkuOGE4OS4xMyw4OS4xMywwLDAsMS00Ni41LTE2LjY5QTYsNiwwLDAsMCwxNjIsMTAydjU0YTYyLDYyLDAsMCwxLTEyNCwwYzAtMjcuNzIsMTguNDctNTIuNDgsNDQtNjAuMzh2MzEuNTNBMzQsMzQsMCwxLDAsMTM0LDE1NlYzMGgyOC4yOUE2Mi4wOSw2Mi4wOSwwLDAsMCwyMTgsODUuNzFaIi8+PC9zdmc+');}.icon-link{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsODguMThhNTIuNDIsNTIuNDIsMCwwLDEtMTUuNCwzNS42NmwtMzQuNzUsMzQuNzVBNTIuMjgsNTIuMjgsMCwwLDEsMTUwLjYyLDE3NGgtLjA1QTUyLjYzLDUyLjYzLDAsMCwxLDk4LDExOS45YTYsNiwwLDAsMSw2LTUuODRoLjE3YTYsNiwwLDAsMSw1LjgzLDYuMTZBNDAuNjIsNDAuNjIsMCwwLDAsMTUwLjU4LDE2MmgwYTQwLjQsNDAuNCwwLDAsMCwyOC43My0xMS45bDM0Ljc1LTM0Ljc0QTQwLjYzLDQwLjYzLDAsMCwwLDE1Ni42Myw1Ny45bC0xMSwxMWE2LDYsMCwwLDEtOC40OS04LjQ5bDExLTExYTUyLjYyLDUyLjYyLDAsMCwxLDc0LjQzLDBBNTIuODMsNTIuODMsMCwwLDEsMjM4LDg4LjE4Wm0tMTI3LjYyLDk4LjktMTEsMTFBNDAuMzYsNDAuMzYsMCwwLDEsNzAuNiwyMTBoMGE0MC42Myw0MC42MywwLDAsMS0yOC43LTY5LjM2TDc2LjYyLDEwNS45QTQwLjYzLDQwLjYzLDAsMCwxLDE0NiwxMzUuNzdhNiw2LDAsMCwwLDUuODMsNi4xNkgxNTJhNiw2LDAsMCwwLDYtNS44NEE1Mi42Myw1Mi42MywwLDAsMCw2OC4xNCw5Ny40MkwzMy4zOCwxMzIuMTZBNTIuNjMsNTIuNjMsMCwwLDAsNzAuNTYsMjIyaDBhNTIuMjYsNTIuMjYsMCwwLDAsMzcuMjItMTUuNDJsMTEtMTFhNiw2LDAsMSwwLTguNDktOC40OFoiLz48L3N2Zz4=');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}
\ No newline at end of file
diff --git a/assets/js/Gallery.js b/assets/js/Gallery.js
index dfc8069..9c596c4 100644
--- a/assets/js/Gallery.js
+++ b/assets/js/Gallery.js
@@ -165,6 +165,8 @@
newIndex = this.items.length - 1;
} else if (newIndex >= this.items.length) {
newIndex = 0;
+ } else if (this.items.length - newIndex === 3) {
+ this.notify('load-more');
}
this.index = newIndex;
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index fe9b22e..47f72e8 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -1,314 +1,986 @@
/**
- * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching
+ * DataStore - Singleton pattern managing multiple store namespaces
*
- * Configuration-based approach for different storage needs:
- * - Configurable endpoint, keyPath, and indexes
- * - Built-in ETag and If-Modified-Since support
- * - Automatic DOM reference stripping
- * - TTL-based cache invalidation
- *
- * All notifications:
- *
- this.store.subscribe((event, data) => {
- switch (event) {
- case 'data-loaded':
- break;
- case 'item-saved':
- break;
- case 'items-saved':
- break;
- case 'item-deleted':
- break;
- case 'data-cleared':
- break;
- case 'filters-changed':
- break;
- case 'filters-cleared':
- break;
- case 'cache-cleared':
- break;
- }
- });
+ * Usage:
+ * window.jvbStore = new DataStore();
+ * this.store = window.jvbStore.register('feed', { config });
*/
class DataStore {
- constructor(config = {}) {
- // Core configuration with sensible defaults
- this.config = {
- // Storage configuration
- name: 'default',
- version: 1,
- storeName: 'items',
- keyPath: 'id',
- indexes: [], // Array of {name, keyPath, unique}
+ constructor() {
+ // Singleton pattern
+ if (DataStore.instance) {
+ return DataStore.instance;
+ }
+ DataStore.instance = this;
- // API configuration
- endpoint: null,
- saveToServer: false,
- apiBase: jvbSettings.api,
- headers: {},
- filters: {},
- required: null, //any required filters before fetching
- icon: null,
- getBlobs: null,
+ // Shared resources
+ 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 = [];
- // Cache configuration
- TTL: 3600000, // 1 hour default
- useHttpCaching: true, // ETag and If-Modified-Since
- cacheKeyStrategy: 'filters', // How to generate cache keys
-
- // UI configuration
- showLoading: true,
-
- // Features
- stripDOMReferences: true,
- storeBlobs: false,
-
- ...config
- };
-
- // Initialize base properties
- this.db = null;
- this.data = new Map();
- this.cache = new Map();
- this.isFetching = false;
- this.pendingFetch = null;
- this.httpHeaders = new Map();
- this.subscribers = new Set();
- this.currentRequest = null;
- this.filters = this.config.filters??{};
-
- // Set up headers
- this.headers = {
- 'X-WP-Nonce': jvbSettings?.nonce,
- ...this.config.headers
- };
-
+ // Global state
+ this._initialized = false;
this.body = document.body;
this.loading = document.querySelector('dialog.loading');
- // Auto-initialize
- this.initDB();
+ this.init();
- // Cleanup on page unload
window.addEventListener('beforeunload', () => this.destroy());
}
- /**
- * Initialize IndexedDB with configurable schema
- */
- async initDB() {
+ async init() {
+ if (this._initialized) return;
+ this._initialized = true;
+
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
- return;
}
-
- const dbName = `jvb_${this.config.name}_db`;
- const request = indexedDB.open(dbName, this.config.version);
-
- request.onupgradeneeded = (e) => {
- const db = e.target.result;
-
- // Create main store with configurable keyPath
- if (!db.objectStoreNames.contains(this.config.storeName)) {
- const store = db.createObjectStore(this.config.storeName, {
- keyPath: this.config.keyPath
- });
-
- // Add configured indexes
- this.config.indexes.forEach(index => {
- store.createIndex(
- index.name,
- index.keyPath || index.name,
- { unique: index.unique || false }
- );
- });
- }
-
- // Cache store for HTTP responses
- if (this.config.endpoint && !db.objectStoreNames.contains('cache')) {
- const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
- cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
- cacheStore.createIndex('endpoint', 'endpoint', { unique: false });
- cacheStore.createIndex('filters', 'filters', { unique: false });
- }
-
- // HTTP headers store for ETag/If-Modified-Since
- if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
- db.createObjectStore('headers', { keyPath: 'key' });
- }
-
- if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) {
- db.createObjectStore('blobs', { keyPath: 'uploadId' });
- }
-
- // Call optional schema extension
- if (this.config.onUpgrade) {
- this.config.onUpgrade(db, e.oldVersion, e.newVersion);
- }
- };
-
- request.onsuccess = async (e) => {
- this.db = e.target.result;
-
- // Load cache and headers BEFORE fetching (only if stores exist)
- const loadTasks = [this.loadFromDB()];
-
- if (this.db.objectStoreNames.contains('cache')) {
- loadTasks.push(this.loadCache());
- }
-
- if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) {
- loadTasks.push(this.loadHeaders());
- }
-
- await Promise.all(loadTasks);
-
- this.notify('db-init');
-
- // Now fetch if needed (cache might already have data)
- if (this.config.endpoint) {
- this.fetch();
- }
- };
-
- request.onerror = (e) => {
- console.error(`IndexedDB error for ${dbName}:`, e);
- if (this.config.onError) {
- this.config.onError(e);
- }
- };
}
/**
- * Load all data from IndexedDB
+ * Register a new store namespace
*/
- async loadFromDB() {
- if (!this.db) return;
+ register(name, config = {}) {
+ if (this.stores.has(name)) {
+ console.warn(`Store "${name}" already registered`);
+ return this.getStoreAPI(name);
+ }
- return new Promise(async (resolve, reject) => {
- const tx = this.db.transaction([this.config.storeName], 'readonly');
- const store = tx.objectStore(this.config.storeName);
- const request = store.getAll();
+ if (!config.keyPath) {
+ throw new Error(`Store "${name}" requires a keyPath`);
+ }
- request.onsuccess = async (e) => {
- const items = e.target.result;
+ const store = {
+ name,
+ config: {
+ // Storage
+ dbName: `jvb_${name}_db`,
+ version: 1,
+ storeName: 'items',
+ keyPath: 'id',
+ indexes: [],
- // Restore FormData for ALL items on startup
- for (const item of items) {
- if (item.data?._isFormData && this.config.getBlobs) {
- item.data = await this.objectToFormData(item.data);
- }
- const key = this.getItemKey(item);
- this.data.set(key, item);
+ // 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
+ },
+
+ // State
+ db: null,
+ data: new Map(),
+ cache: new Map(),
+ httpHeaders: new Map(),
+ filters: { ...config.filters },
+ isFetching: false,
+ currentRequest: null,
+ lastResponse: null,
+ _initialized: false
+ };
+
+ store.config.headers = {
+ 'X-WP-Nonce': jvbSettings?.nonce,
+ ...store.config.headers
+ };
+
+ this.stores.set(name, store);
+ this.subscribers.set(name, new Set());
+
+ // Initialize database asynchronously
+ this.initStoreDB(name).catch(error => {
+ console.error(`Failed to initialize store "${name}":`, error);
+ });
+
+ return this.getStoreAPI(name);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Normalize data before saving - convert Sets/Maps automatically
+ */
+ normalizeForStorage(obj) {
+ if (obj === null || obj === undefined) return obj;
+
+ // Convert Set to Array
+ if (obj instanceof Set) {
+ return Array.from(obj);
+ }
+
+ // Convert Map to Object
+ if (obj instanceof Map) {
+ return Object.fromEntries(obj);
+ }
+
+ // Handle Arrays
+ if (Array.isArray(obj)) {
+ return obj.map(item => this.normalizeForStorage(item));
+ }
+
+ // Handle Objects
+ if (typeof obj === 'object') {
+ const normalized = {};
+ for (const [key, value] of Object.entries(obj)) {
+ normalized[key] = this.normalizeForStorage(value);
+ }
+ return normalized;
+ }
+
+ return obj;
+ }
+
+ /**
+ * Strip DOM references from object
+ */
+ stripDOMReferences(obj, visited = new WeakSet()) {
+ if (obj === null || obj === undefined) return obj;
+
+ const type = typeof obj;
+ if (type === 'string' || type === 'number' || type === 'boolean') {
+ return obj;
+ }
+
+ // Prevent circular references
+ if (type === 'object' && visited.has(obj)) {
+ return '[Circular]';
+ }
+
+ // Remove DOM elements
+ if (obj instanceof HTMLElement ||
+ obj instanceof NodeList ||
+ obj instanceof HTMLCollection ||
+ obj.nodeType !== undefined) {
+ return null;
+ }
+
+ // Handle Date
+ if (obj instanceof Date) {
+ return obj;
+ }
+
+ // Handle Arrays
+ if (Array.isArray(obj)) {
+ visited.add(obj);
+ return obj.map(item => this.stripDOMReferences(item, visited)).filter(v => v !== null);
+ }
+
+ // Handle Objects
+ if (type === 'object') {
+ visited.add(obj);
+ const cleaned = {};
+ for (const [key, value] of Object.entries(obj)) {
+ const cleanedValue = this.stripDOMReferences(value, visited);
+ if (cleanedValue !== null) {
+ cleaned[key] = cleanedValue;
}
+ }
+ return cleaned;
+ }
- this.notify('data-loaded', { count: items.length });
- resolve(items);
+ return obj;
+ }
+
+ /**
+ * Initialize database for a specific store
+ */
+ async initStoreDB(name) {
+ const store = this.stores.get(name);
+ if (!store || store._initialized) return;
+
+ if (this.pendingInits.has(name)) {
+ return this.pendingInits.get(name);
+ }
+
+ const initPromise = this._performStoreInit(name);
+ this.pendingInits.set(name, initPromise);
+
+ try {
+ await initPromise;
+ store._initialized = true;
+ } finally {
+ this.pendingInits.delete(name);
+ }
+ }
+
+ async _performStoreInit(name) {
+ const store = this.stores.get(name);
+ const { dbName, version } = store.config;
+
+ try {
+ if (!this.databases.has(dbName)) {
+ const db = await this.openDatabase(dbName, version, (db) => {
+ this.setupStores(db, store.config);
+ });
+ this.databases.set(dbName, db);
+ }
+
+ store.db = this.databases.get(dbName);
+ this.loadStoreDataInBackground(name);
+ this.notify(name, '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.onerror = (e) => reject(e);
+ 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 }
+ );
+ });
+ }
- /**
- * Load main data from IndexedDB
- */
- async loadData() {
- if (!this.db) return;
+ // Cache store
+ if (config.endpoint && !db.objectStoreNames.contains('cache')) {
+ const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
+ cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
+ }
- return new Promise((resolve, reject) => {
- const tx = this.db.transaction([this.config.storeName], 'readonly');
- const store = tx.objectStore(this.config.storeName);
- const request = store.getAll();
+ // 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) => {
- e.target.result.forEach(item => {
- // Strip DOM references if needed
- const cleaned = this.config.stripDOMReferences
- ? this.stripDOMReferences(item)
- : item;
+ 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);
+ };
- const key = this.getItemKey(cleaned);
- this.data.set(key, cleaned);
+ 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 = (e) => reject(e);
+ request.onerror = () => resolve();
});
}
- /**
- * Strip DOM references from an object (recursive)
- */
- stripDOMReferences(obj) {
- if (!obj || typeof obj !== 'object') return obj;
+ async loadStoreHeaders(name) {
+ const store = this.stores.get(name);
+ if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
- // Handle arrays
+ 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.initStoreDB(name);
+ }
+ }
+
+ 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 && cached) {
+ this.notify(name, 'data-loaded', {
+ cached: true,
+ notModified: true,
+ items: cached.items || []
+ });
+ return cached;
+ }
+
+ 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;
+ }
+
+ async processFetchedData(name, data, cacheKey) {
+ const store = this.stores.get(name);
+ const items = data.items || [];
+
+ for (const item of items) {
+ await this.save(name, item);
+ }
+
+ 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);
+
+ store.lastResponse = {
+ has_more: data.has_more || false,
+ total: data.total || items.length,
+ pages: data.pages || 1
+ };
+ }
+
+ /**
+ * Save item to store
+ * IMPORTANT: Item must be serializable (no DOM, FormData, Blobs)
+ */
+ async save(name, item) {
+ const store = this.stores.get(name);
+
+ // Auto-normalize Sets/Maps
+ let processed = this.normalizeForStorage(item);
+ processed = this.stripDOMReferences(processed);
+
+ // Validate data is serializable
+ if (store.config.validateData) {
+ const validation = this.validateSerializable(processed);
+ if (!validation.valid) {
+ console.error(`Cannot save non-serializable data to store "${name}":`, validation.error);
+ throw new Error(`Non-serializable data: ${validation.error}`);
+ }
+ }
+
+ const key = this.getItemKey(processed, store.config.keyPath);
+
+ // Store in memory
+ store.data.set(key, item);
+
+ // Store 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);
+ }
+
+ this.notify(name, 'item-saved', { item, key });
+ return key;
+ }
+
+ /**
+ * Validate that data is IndexedDB-serializable
+ * Rejects: DOM elements, FormData, Blobs, Functions, etc.
+ */
+ validateSerializable(obj, path = 'root') {
+ // Primitives are fine
+ if (obj === null || obj === undefined) {
+ return { valid: true };
+ }
+
+ const type = typeof obj;
+ if (type === 'string' || type === 'number' || type === 'boolean') {
+ return { valid: true };
+ }
+
+ // Functions cannot be serialized
+ if (type === 'function') {
+ return {
+ valid: false,
+ error: `Function at ${path}`
+ };
+ }
+
+ // Date is serializable
+ if (obj instanceof Date) {
+ return { valid: true };
+ }
+
+ // Reject DOM elements
+ if (obj instanceof HTMLElement ||
+ obj instanceof NodeList ||
+ obj instanceof HTMLCollection ||
+ (obj.nodeType !== undefined)) {
+ return {
+ valid: false,
+ error: `DOM element at ${path}`
+ };
+ }
+
+ // Reject FormData
+ if (obj instanceof FormData) {
+ return {
+ valid: false,
+ error: `FormData at ${path}. Convert to object first.`
+ };
+ }
+
+ // Reject Blobs/Files
+ if (obj instanceof Blob || obj instanceof File) {
+ return {
+ valid: false,
+ error: `Blob/File at ${path}. Handle file uploads separately.`
+ };
+ }
+
+ // Arrays
if (Array.isArray(obj)) {
- return obj.map(item => this.stripDOMReferences(item));
+ for (let i = 0; i < obj.length; i++) {
+ const result = this.validateSerializable(obj[i], `${path}[${i}]`);
+ if (!result.valid) return result;
+ }
+ return { valid: true };
}
- // Handle objects
- const cleaned = {};
- for (const [key, value] of Object.entries(obj)) {
- // Skip DOM-related properties
- if (this.isDOMReference(key, value)) {
- continue;
+ // Plain objects
+ if (type === 'object') {
+ // Check for Sets/Maps (IndexedDB doesn't support them)
+ if (obj instanceof Set) {
+ return {
+ valid: false,
+ error: `Set at ${path}. Convert to Array first: Array.from(set)`
+ };
+ }
+ if (obj instanceof Map) {
+ return {
+ valid: false,
+ error: `Map at ${path}. Convert to Object first: Object.fromEntries(map)`
+ };
}
- // Handle Set/Map collections
- if (value instanceof Set) {
- cleaned[key] = Array.from(value);
- } else if (value instanceof Map) {
- cleaned[key] = Object.fromEntries(value);
- } else if (typeof value === 'object' && value !== null) {
- cleaned[key] = this.stripDOMReferences(value);
- } else {
- cleaned[key] = value;
+ // Check all properties
+ for (const [key, value] of Object.entries(obj)) {
+ const result = this.validateSerializable(value, `${path}.${key}`);
+ if (!result.valid) return result;
+ }
+ return { valid: true };
+ }
+
+ return {
+ valid: false,
+ error: `Unknown type at ${path}: ${type}`
+ };
+ }
+
+ 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);
}
}
-
- return cleaned;
}
- /**
- * Check if a property is a DOM reference
- */
- isDOMReference(key, value) {
- // Check value types
- if (value instanceof HTMLElement ||
- value instanceof NodeList ||
- value instanceof HTMLCollection ||
- (value && value.nodeType !== undefined)) {
- return true;
+ 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);
}
-
- // Check key names - use exact match or word boundaries
- const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
- const lowerKey = key.toLowerCase();
-
- // Only match if it's the exact key OR starts/ends with the pattern
- if (domKeys.includes(lowerKey) ||
- domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + k))) {
- return true;
- }
-
- return false;
}
- /**
- * Get the key for an item based on configured keyPath
- */
- getItemKey(item) {
- if (typeof this.config.keyPath === 'function') {
- return this.config.keyPath(item);
+ 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();
}
- // Support nested keypaths like 'meta.id'
- const keys = this.config.keyPath.split('.');
+ 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) {
+ 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) {
@@ -318,789 +990,31 @@
return value;
}
- /**
- * Save a single item
- */
- /**
- * Save a single item
- */
- async save(item) {
- const key = this.getItemKey(item);
-
- // Keep ORIGINAL item in memory (with FormData intact)
- this.data.set(key, item); // ← Store original
-
- // Create cleaned version ONLY for IndexedDB
- let cleaned = { ...item };
- if (cleaned.data instanceof FormData) {
- cleaned.data = this.formDataToObject(cleaned.data);
- }
-
- if (this.config.stripDOMReferences) {
- cleaned = this.stripDOMReferences(cleaned);
- }
-
- // Persist cleaned version to IndexedDB
- await this.saveToDB(cleaned);
-
- if(this.config.endpoint){
- this.saveToServer(item);
- }
-
- this.notify('item-saved', { item: cleaned, key });
-
- return cleaned;
- }
-
- /**
- * Convert FormData to plain object for storage
- */
- formDataToObject(formData) {
- const obj = {
- _isFormData: true, // Flag to reconstruct later
- entries: {}
- };
-
- for (const [key, value] of formData.entries()) {
- // Skip File/Blob objects - they're stored separately
- 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();
-
- 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);
- }
- }
- // Restore files from external blob store (UploadManager)
- if (this.config.getBlobs && obj.entries.upload_ids) {
- const uploadIds = JSON.parse(obj.entries.upload_ids);
- const blobs = await this.config.getBlobs(uploadIds); // ← Await here
-
- for (const blobData of blobs) {
- if (blobData) {
- const file = new File(
- [blobData.data],
- blobData.name,
- { type: blobData.type, lastModified: blobData.lastModified }
- );
- formData.append('files[]', file);
- }
- }
- }
-
- return formData;
- }
-
- /**
- * Save item to IndexedDB
- */
- async saveToDB(item) {
- if (!this.db) return;
-
- return new Promise((resolve, reject) => {
- const tx = this.db.transaction([this.config.storeName], 'readwrite');
- const store = tx.objectStore(this.config.storeName);
- const request = store.put(item);
-
- request.onsuccess = () => resolve();
- request.onerror = (e) => reject(e);
- });
- }
-
- /**
- * Batch save multiple items
- */
- async saveMany(items) {
- if (!this.db) return;
-
- const tx = this.db.transaction([this.config.storeName], 'readwrite');
- const store = tx.objectStore(this.config.storeName);
-
- const promises = items.map(item => {
- const cleaned = this.config.stripDOMReferences
- ? this.stripDOMReferences(item)
- : item;
-
- const key = this.getItemKey(cleaned);
- this.data.set(key, cleaned);
-
- return store.put(cleaned);
- });
-
- await Promise.all(promises);
- this.notify('items-saved', { count: items.length });
- }
-
- /**
- * Get a single item
- */
- get(key) {
- return this.data.get(key); // ← Returns original with FormData
- }
-
- /**
- * Get all items
- */
- getAll() {
- return Array.from(this.data.values());
- }
-
- /**
- * Delete an item
- */
- async delete(key, storeName = null) {
- this.data.delete(key);
-
- if (!storeName) {
- storeName = this.config.storeName;
- }
- if (this.db) {
- const tx = this.db.transaction([storeName], 'readwrite');
- const store = tx.objectStore(storeName);
- await store.delete(key);
- }
-
- this.notify('item-deleted', { key });
- }
-
- async saveBlob(key, blob) {
- if (!this.db) return;
-
- const tx = this.db.transaction(['blobs'], 'readwrite');
- const store = tx.objectStore('blobs');
- await store.put({
- uploadId: key, // Match keyPath
- data: blob,
- type: blob.type,
- name: blob.name,
- lastModified: blob.lastModified || Date.now()
- });
- }
-
- async getBlob(key) {
- if (!this.db) return null;
-
- return new Promise(resolve => {
- const tx = this.db.transaction(['blobs'], 'readonly');
- const request = tx.objectStore('blobs').get(key);
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => resolve(null);
- });
- }
-
- /**
- * Clear all data
- */
- async clear() {
- this.data.clear();
- this.cache.clear();
- this.httpHeaders.clear();
-
- if (this.domCache) {
- this.domCache.clear();
- }
-
- if (this.db) {
- const stores = [this.config.storeName];
- if (this.config.endpoint) stores.push('cache');
- if (this.config.useHttpCaching) stores.push('headers');
-
- const tx = this.db.transaction(stores, 'readwrite');
- stores.forEach(storeName => {
- if (this.db.objectStoreNames.contains(storeName)) {
- tx.objectStore(storeName).clear();
- }
- });
- }
-
- this.notify('data-cleared');
- }
-
- /**
- * Fetch data from server with HTTP caching
- */
- async fetch(options = {}) {
- if (!this.config.endpoint) {
- throw new Error('No endpoint configured for fetch');
- }
- const {
- filters = this.filters,
- headers = {},
- } = options;
-
- if (this.config.required && this.filters[this.config.required] === ''){
- console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items');
- return;
- }
-
- // PREVENT CONCURRENT FETCHES FOR SAME DATA
- const cacheKey = this.generateCacheKey(filters);
- console.log('CacheKey: ', cacheKey);
-
- // If already fetching this exact query, return a promise that resolves when done
- if (this.isFetching && this.currentCacheKey === cacheKey) {
- return new Promise((resolve) => {
- // Store multiple waiting promises if needed
- if (!this.pendingFetches) {
- this.pendingFetches = [];
- }
- this.pendingFetches.push(resolve);
- });
- }
-
- this.isFetching = true;
- this.currentCacheKey = cacheKey;
- let fetchResult = null; // Capture result for pending fetches
-
- if (this.config.showLoading) {
- this.setLoading(true);
- }
-
- //Check Cached data
- const cachedData = this.cache.get(cacheKey);
- console.log('Cached Data: ', cachedData);
- if (cachedData && this.isCacheValid(cachedData)) {
- console.log('Returning cached data: ');
- this.isFetching = false;
- this.currentCacheKey = null;
- if (this.config.showLoading) {
- this.setLoading(false);
- }
- return cachedData.data;
- }
-
- // Build request headers with HTTP caching
- const requestHeaders = {
- ...this.headers,
- ...headers
- };
-
- if (this.config.useHttpCaching) {
- const httpCache = this.httpHeaders.get(cacheKey);
- if (httpCache) {
- if (httpCache.etag) {
- requestHeaders['If-None-Match'] = httpCache.etag;
- }
- if (httpCache.lastModified) {
- requestHeaders['If-Modified-Since'] = httpCache.lastModified;
- }
- }
- }
-
- // Build URL with filters
- const cleanedFilters = this.cleanFilters(filters);
- const params = new URLSearchParams(cleanedFilters);
- const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
-
- try {
- const response = await fetch(url, {
- method: 'GET',
- headers: requestHeaders
- });
-
- // Handle 304 Not Modified
- if (response.status === 304 && cachedData) {
- // Update timestamp but keep existing data
- cachedData.timestamp = Date.now();
- cachedData.fromCache = true;
- cachedData.isError = false;
- this.saveCache(cacheKey, cachedData);
- console.log(this.config.storeName+' Data loaded from cache');
- this.notify('data-loaded', cachedData);
- fetchResult = cachedData.data;
- return cachedData.data;
- }
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
-
- const data = await response.json();
-
- // Store HTTP caching headers
- if (this.config.useHttpCaching) {
- this.storeResponseHeaders(cacheKey, response);
- }
-
- // Cache the response
- const cacheEntry = {
- key: cacheKey,
- data: data,
- timestamp: Date.now(),
- endpoint: this.config.endpoint,
- filters: filters
- };
- console.log(this.config.storeName + 'Fetched fresh from server');
-
- this.cache.set(cacheKey, cacheEntry);
- this.saveCache(cacheKey, cacheEntry);
-
- let items = (Array.isArray(data)) ? data : data.items;
- await this.saveMany(items);
-
- this.notify('data-loaded', {
- data: {
- items: items,
- ...data
- },
- count: items.length,
- filters: filters,
- fromCache: false,
- isError: false
- });
-
- fetchResult = data;
- return data;
-
- } catch (error) {
- console.error('Fetch error:', error);
-
- // Return cached data if available, even if expired
- if (cachedData) {
- console.warn('Using stale cache due to fetch error');
- cachedData.isError = true;
- this.notify('data-loaded', cachedData);
- fetchResult = cachedData.data;
- return cachedData.data;
- }
-
- throw error;
- } finally {
- if (this.config.showLoading) {
- this.setLoading(false);
- }
-
- this.isFetching = false;
- this.currentCacheKey = null;
-
- // Resolve any pending fetches that were waiting
- if (this.pendingFetches && this.pendingFetches.length > 0) {
- this.pendingFetches.forEach(resolve => resolve(fetchResult));
- this.pendingFetches = [];
- }
- }
- }
-
- /**
- * Fetch data from server with HTTP caching
- */
- async saveToServer(item) {
- if (!this.config.saveToServer || !jvbSettings.currentUser) {
- return;
- }
- if (!this.config.endpoint && this.config.saveToServer) {
- throw new Error('No endpoint configured for saving to server');
- }
-
- let requestBody;
- let headers = this.config.headers;
- headers['X-WP-Nonce'] = jvbSettings.nonce;
- if (item instanceof FormData) {
- item.append('user', jvbSettings.currentUser);
- requestBody = item;
-
- // console.log('Sending formData: ');
- // for (const pair of requestBody.entries()) {
- // console.log(pair[0], pair[1]);
- // }
- } else {
- requestBody = JSON.stringify({
- ...item,
- user: jvbSettings.currentUser
- });
- // console.log('Sending data: ', {
- // ...operation.data,
- // id: operation.id,
- // user: this.user
- // });
-
- headers['Content-Type'] = 'application/json';
- }
-
- const response = await fetch(
- `${this.config.apiBase}${this.config.endpoint}`,
- {
- method: 'POST',
- headers: headers,
- body: requestBody
- }
- );
-
- const result = await response.json();
- this.notify(
- 'saved-to-server',
- {
- success: result.ok && result.success
- }
- );
- }
-
- cleanFilters(filters) {
- const cleaned = {};
- Object.entries(filters).forEach(([key, value]) => {
- if (value !== null && value !== undefined && value !== '') {
- // Handle special cases based on existing patterns
- if (key === 'taxonomies' && typeof value === 'object') {
- Object.entries(value).forEach(([taxName, terms]) => {
- if (Array.isArray(terms) && terms.length > 0) {
- cleaned[`tax_${taxName}`] = terms.join(',');
- } else if (terms) {
- cleaned[`tax_${taxName}`] = terms;
- }
- });
- } else if (key === 'date' && typeof value === 'object') {
- if (value.after) cleaned.after = value.after;
- if (value.before) cleaned.before = value.before;
- } else {
- cleaned[key] = value;
- }
- }
- });
- return cleaned;
- }
-
- /**
- * Generate cache key from filters
- */
- generateCacheKey(filters) {
- if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
- return this.config.generateCacheKey(filters);
- }
-
- // Default strategy: sort keys and create string
- const sorted = Object.keys(filters)
- .sort()
- .reduce((acc, key) => {
- acc[key] = filters[key];
- return acc;
- }, {});
-
- return JSON.stringify(sorted);
- }
-
- setFilter(key, value) {
- if (!this.filters) {
- this.filters = {};
- }
- const oldValue = this.filters[key];
- if (oldValue === value) {
- return;
- }else if (value === '' || value === null || value === undefined) {
- delete this.filters[key];
- } else {
- this.filters[key] = value;
- }
-
- this.notify('filters-changed', {
- filters: this.filters,
- changed: { key, oldValue, newValue: value }
- });
-
- // Auto-fetch if endpoint is configured
- if (this.config.endpoint) {
- window.debouncer.schedule(
- this.config.endpoint,
- this.fetch.bind(this),
- 100
- );
- }
- }
-
-
- /**
- * Remove a filter
- */
- removeFilter(key) {
- const oldValue = this.filters[key];
-
- if (oldValue !== undefined) {
- delete this.filters[key];
- this.notify('filters-changed', {
- filters: this.filters,
- removed: { key, oldValue }
- });
-
- // Auto-fetch if endpoint is configured
- if (this.config.endpoint) {
- window.debouncer.schedule(
- this.config.endpoint,
- this.fetch.bind(this),
- 100
- );
- }
- }
- }
-
- /**
- * Clear all filters
- */
- clearFilters() {
- const oldFilters = { ...this.filters };
- //Restore baseline filters
- this.filters = this.config.filters;
-
- this.notify('filters-cleared', {
- oldFilters,
- filters: this.filters
- });
-
- // Auto-fetch if endpoint is configured
- if (this.config.endpoint) {
- this.fetch();
- }
- }
-
- /**
- * Set multiple filters at once
- */
- async setFilters(filters) {
- const hasChanges = Object.keys(filters).some(
- key => this.filters[key] !== filters[key]
- );
-
- if (!hasChanges) {
- return;
- }
-
- this.filters = { ...this.filters, ...filters };
-
- this.notify('filters-changed', {
- filters: this.filters,
- changed: filters,
- });
-
- // Only fetch if endpoint configured
- if (this.config.endpoint) {
- window.debouncer.schedule(
- this.config.endpoint,
- this.fetch.bind(this),
- 100
- );
- }
- }
-
- /**
- * Check if cache entry is still valid
- */
- isCacheValid(cacheEntry) {
- if (!cacheEntry || !cacheEntry.timestamp) return false;
-
- const age = Date.now() - cacheEntry.timestamp;
- return age < this.config.TTL;
- }
-
- /**
- * Store HTTP response headers for caching
- */
- storeResponseHeaders(key, response) {
- const headers = {
- key,
- etag: response.headers.get('ETag'),
- lastModified: response.headers.get('Last-Modified'),
- timestamp: Date.now()
- };
-
- this.httpHeaders.set(key, headers);
-
- if (this.db && this.db.objectStoreNames.contains('headers')) {
- const tx = this.db.transaction(['headers'], 'readwrite');
- const store = tx.objectStore('headers');
- store.put(headers);
- }
- }
-
- /**
- * Save cache entry to IndexedDB
- */
- async saveCache(key, data) {
- if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
-
- const tx = this.db.transaction(['cache'], 'readwrite');
- const store = tx.objectStore('cache');
- await store.put(data);
- }
-
- /**
- * Load cache from IndexedDB
- */
- async loadCache() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['cache'], 'readonly');
- const store = tx.objectStore('cache');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(item => {
- if (this.isCacheValid(item)) {
- this.cache.set(item.key, item);
- }
- });
- resolve();
- };
- });
- }
-
- /**
- * Load HTTP headers from IndexedDB
- */
- async loadHeaders() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['headers'], 'readonly');
- const store = tx.objectStore('headers');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(header => {
- this.httpHeaders.set(header.key, header);
- });
- resolve();
- };
- });
- }
-
-
- /**
- * Subscribe to store events
- */
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- /**
- * Notify subscribers of events
- */
- notify(event, data = {}) {
- this.subscribers.forEach(callback => {
- try {
- callback(event, data);
- } catch (error) {
- console.error('Subscriber error:', error);
- }
- });
- }
-
- /**
- * Query items using an index
- */
- async query(indexName, value) {
- if (!this.db) return [];
-
- return new Promise((resolve, reject) => {
- const tx = this.db.transaction([this.config.storeName], 'readonly');
- const store = tx.objectStore(this.config.storeName);
-
- if (!store.indexNames.contains(indexName)) {
- reject(new Error(`Index ${indexName} does not exist`));
- return;
- }
-
- const index = store.index(indexName);
- const request = value !== undefined
- ? index.getAll(value)
- : index.getAll();
-
- request.onsuccess = (e) => {
- const results = e.target.result.map(item => {
- return this.config.stripDOMReferences
- ? this.stripDOMReferences(item)
- : item;
- });
- resolve(results);
- };
-
- request.onerror = (e) => reject(e);
- });
- }
-
- /**
- * Count items in store
- */
- async count() {
- if (!this.db) return this.data.size;
-
- return new Promise((resolve, reject) => {
- const tx = this.db.transaction([this.config.storeName], 'readonly');
- const store = tx.objectStore(this.config.storeName);
- const request = store.count();
-
- request.onsuccess = (e) => resolve(e.target.result);
- request.onerror = (e) => reject(e);
- });
- }
-
-
setLoading(on) {
- console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
this.body.classList.toggle('loading', on);
if (on) {
- this.loading.showModal();
+ this.loading?.showModal();
} else {
- this.loading.close();
+ this.loading?.close();
}
-
}
- /**
- * Cleanup and destroy
- */
destroy() {
- if (this.currentRequest) {
- this.currentRequest.abort();
- }
+ this.stores.forEach(store => {
+ if (store.currentRequest) {
+ store.currentRequest.abort();
+ }
+ });
+ this.databases.forEach(db => db.close());
+ this.stores.clear();
this.subscribers.clear();
- this.data.clear();
- this.cache.clear();
- this.httpHeaders.clear();
-
- if (this.db) {
- this.db.close();
- this.db = null;
- }
- }
-
- clearCache() {
- this.cache.clear();
-
- if (this.db) {
- const tx = this.db.transaction(['cache'], 'readwrite');
- const store = tx.objectStore('cache');
- store.clear();
- }
-
- this.notify('cache-cleared');
+ this.databases.clear();
+ this.pendingInits.clear();
}
}
-// Export for use
-window.jvbStore = DataStore;
+// Initialize singleton on DOMContentLoaded
+document.addEventListener('DOMContentLoaded', function() {
+ window.jvbStore = new DataStore();
+});
diff --git a/assets/js/concise/DataStoreOld.js b/assets/js/concise/DataStoreOld.js
index 3686ac2..dfb1f8b 100644
--- a/assets/js/concise/DataStoreOld.js
+++ b/assets/js/concise/DataStoreOld.js
@@ -1,578 +1,773 @@
/**
- * Handles GET Requests, storing responses by a key of filters, set with setFilter method
- * Stores:
- * - Items: the individual item data, mapped by postID/termID
- * - Cache: the cacheKey generated by filters, and the results in the value
- * - httpHeaders: If Modified Since tracking
- * - domCache: rendered DOM elements, to reduce on-page rendering
+ * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching
+ *
+ * Configuration-based approach for different storage needs:
+ * - Configurable endpoint, keyPath, and indexes
+ * - Built-in ETag and If-Modified-Since support
+ * - Automatic DOM reference stripping
+ * - TTL-based cache invalidation
+ *
+ * All notifications:
+ *
+ this.store.subscribe((event, data) => {
+ switch (event) {
+ case 'data-loaded':
+ break;
+ case 'item-saved':
+ break;
+ case 'items-saved':
+ break;
+ case 'item-deleted':
+ break;
+ case 'data-cleared':
+ break;
+ case 'filters-changed':
+ break;
+ case 'filters-cleared':
+ break;
+ case 'cache-cleared':
+ break;
+ }
+ });
*/
class DataStore {
constructor(config = {}) {
+ // Core configuration with sensible defaults
this.config = {
+ // Storage configuration
name: 'default',
- endpoint: false,
+ version: 1,
+ storeName: 'items',
+ keyPath: 'id',
+ indexes: [], // Array of {name, keyPath, unique}
+
+ // API configuration
+ endpoint: null,
+ saveToServer: false,
apiBase: jvbSettings.api,
- TTL: 3600000, // 1 hour default
- showLoading: true,
headers: {},
filters: {},
+ required: null, //any required filters before fetching
+ icon: null,
+ getBlobs: null,
+
+ // Cache configuration
+ TTL: 3600000, // 1 hour default
+ useHttpCaching: true, // ETag and If-Modified-Since
+ cacheKeyStrategy: 'filters', // How to generate cache keys
+
+ // UI configuration
+ showLoading: true,
+
+ // Features
+ stripDOMReferences: true,
+ storeBlobs: false,
+ delayFetch: false,
+
...config
};
- if (!this.config.endpoint) {
- console.warn('No endpoint set. Only saving locally');
- }
+
+ // Initialize base properties
+ this.db = null;
+ this.data = new Map();
+ this.cache = new Map();
+ this.isFetching = false;
+ this.pendingFetch = null;
+ this.httpHeaders = new Map();
+ this.subscribers = new Set();
+ this.currentRequest = null;
+ this.filters = this.config.filters??{};
+
+ // Set up headers
+ this.headers = {
+ 'X-WP-Nonce': jvbSettings?.nonce,
+ ...this.config.headers
+ };
this.body = document.body;
this.loading = document.querySelector('dialog.loading');
- this.headers = {
- 'X-WP-Nonce': jvbSettings.nonce,
- ...this.config.headers
- };
-
- // Data stores
- this.items = new Map();
- this.cache = new Map(); //TODO: call this resultsCache
- this.httpHeaders = new Map();
- this.domCache = new Map();
- this.forms = new Map();
-
- // State management
- this.filters = config.filters ?? {};
- this.subscribers = new Set();
- this.db = null;
- this.currentRequest = null;
-
- // Server Timestamps - needed?
- this.cachedContent = JSON.parse(cacheJVB.cache) || {};
- this.lastTimestampUpdate = Date.now();
-
- this.initDB();
- document.addEventListener('beforeUnload', () =>this.destroy());
+ this._initialized = false;
+ // Cleanup on page unload
+ window.addEventListener('beforeunload', () => this.destroy());
}
- async initDB() {
- if (!('indexedDB' in window)) return;
+ async init() {
+ if (this._initialized) return;
+ await this.initDB();
+ this._initialized = true;
+ }
- const request = indexedDB.open(`jvb_${this.config.name}_db`, 1);
+ /**
+ * Initialize IndexedDB with configurable schema
+ */
+ async initDB() {
+ if (!('indexedDB' in window)) {
+ console.warn('IndexedDB not supported');
+ return;
+ }
+
+ const dbName = `jvb_${this.config.name}_db`;
+ const request = indexedDB.open(dbName, this.config.version);
request.onupgradeneeded = (e) => {
const db = e.target.result;
- // Items store
- if (!db.objectStoreNames.contains('items')) {
- db.createObjectStore('items', { keyPath: 'id' });
- }
- // DOM cache for rendered elements
- if (!db.objectStoreNames.contains('dom')) {
- db.createObjectStore('dom', { keyPath: 'id' });
- }
+ // Create main store with configurable keyPath
- if (!db.objectStoreNames.contains('forms')) {
- let forms = db.createObjectStore('forms', {
- keyPath: 'formId',
+ if (!db.objectStoreNames.contains(this.config.storeName)) {
+ const store = db.createObjectStore(this.config.storeName, {
+ keyPath: this.config.keyPath
});
- forms.createIndex('status', 'status', {unique:false});
- forms.createIndex('operationId', 'operationId', {unique:false});
- forms.createIndex('timestamp', 'timestamp', {unique:false});
+
+ // Add configured indexes
+ this.config.indexes.forEach(index => {
+ store.createIndex(
+ index.name,
+ index.keyPath || index.name,
+ { unique: index.unique || false }
+ );
+ });
}
- // Cache store for GET requests with endpoint index
- if (!db.objectStoreNames.contains('cache')) {
+ // Cache store for HTTP responses
+ if (this.config.endpoint && !db.objectStoreNames.contains('cache')) {
const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
cacheStore.createIndex('endpoint', 'endpoint', { unique: false });
cacheStore.createIndex('filters', 'filters', { unique: false });
}
- // HTTP headers store
- if (!db.objectStoreNames.contains('headers')) {
+ // HTTP headers store for ETag/If-Modified-Since
+ if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
db.createObjectStore('headers', { keyPath: 'key' });
}
+
+ if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) {
+ db.createObjectStore('blobs', { keyPath: 'uploadId' });
+ }
+
+ // Call optional schema extension
+ if (this.config.onUpgrade) {
+ this.config.onUpgrade(db, e.oldVersion, e.newVersion);
+ }
+
+ };
+
+ request.onsuccess = async (e) => {
+ this.db = e.target.result;
+
+ // Load in background without blocking
+ this.loadInBackground();
+
+ this.notify('db-init');
+
+ // Only fetch if explicitly needed
+ if (this.config.endpoint && !this.config.delayFetch) {
+ requestIdleCallback(() => this.fetch(), { timeout: 2000 });
+ }
};
- request.onsuccess = (e) => {
- this.db = e.target.result;
- this.loadFromDB();
- };
-
request.onerror = (e) => {
- console.error('IndexedDB error:', e);
+ console.error(`IndexedDB error for ${dbName}:`, e);
+ if (this.config.onError) {
+ this.config.onError(e);
+ }
};
}
- async loadFromDB() {
- if (!this.db) return;
-
- try {
- await Promise.all([
- this.loadItems(),
- this.loadCache(),
- this.loadHeaders(),
- this.loadDOMCache(),
- this.loadForms()
- ]);
- } catch (error) {
- console.error('Error loading from DB:', error);
- }
- }
-
- async loadItems() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['items'], 'readonly');
- const store = tx.objectStore('items');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(item => {
- this.items.set(item.id, item);
- });
- this.notify('items-loaded', { items: Array.from(this.items.values()) });
- resolve();
- };
- });
- }
-
- async loadCache() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['cache'], 'readonly');
- const store = tx.objectStore('cache');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(item => {
- if (this.isCacheValid(item)) {
- this.cache.set(item.key, item);
- }
- });
- resolve();
- };
- });
- }
-
- async loadHeaders() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['headers'], 'readonly');
- const store = tx.objectStore('headers');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(header => {
- this.httpHeaders.set(header.key, header);
- });
- resolve();
- };
- });
- }
-
- async loadDOMCache() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['dom'], 'readonly');
- const store = tx.objectStore('dom');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(domEntry => {
- // Convert stored HTML back to DOM elements
- const reconstructed = {};
- Object.entries(domEntry.views).forEach(([viewName, html]) => {
- const temp = document.createElement('div');
- temp.innerHTML = html;
- reconstructed[viewName] = temp.firstElementChild;
- });
- this.domCache.set(domEntry.id, reconstructed);
- });
- resolve();
- };
- });
- }
-
- async loadForms() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['forms'], 'readonly');
- const store = tx.objectStore('forms');
- const request = store.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(form => {
- this.forms.set(form.key, form);
- });
- resolve();
- };
- });
- }
-
- setLoading(on) {
- this.body.classList.toggle('loading', on);
- if (on) {
- this.loading.showModal();
- } else {
- this.loading.close();
- }
-
+ loadInBackground() {
+ // Non-blocking background load
+ Promise.all([
+ this.loadFromDB(),
+ this.loadCache(),
+ this.loadHeaders()
+ ]).then(() => {
+ this.notify('data-ready');
+ }).catch(console.error);
}
/**
- * Main fetch method with caching and conditional requests
+ * Load all data from IndexedDB
*/
- async fetch(endpoint = null, options = {}) {
+ async loadFromDB() {
+ if (!this.db) return;
+
+ return new Promise(async (resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readonly');
+ const store = tx.objectStore(this.config.storeName);
+ const request = store.getAll();
+
+ request.onsuccess = async (e) => {
+ const items = e.target.result;
+ console.log('fetched from cache');
+ for (const item of items) {
+ if (item.data?._isFormData && this.config.getBlobs) {
+ item.data = await this.objectToFormData(item.data);
+ }
+ const key = this.getItemKey(item);
+ this.data.set(key, item);
+ }
+
+ this.notify('data-loaded', { count: items.length });
+ resolve(items);
+ };
+
+ request.onerror = (e) => reject(e);
+ });
+ }
+
+
+
+ /**
+ * Load main data from IndexedDB
+ */
+ async loadData() {
+ if (!this.db) return;
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readonly');
+ const store = tx.objectStore(this.config.storeName);
+ const request = store.getAll();
+
+ request.onsuccess = (e) => {
+ e.target.result.forEach(item => {
+ const key = this.getItemKey(item);
+ this.data.set(key, item);
+ });
+ resolve();
+ };
+
+ request.onerror = (e) => reject(e);
+ });
+ }
+
+ /**
+ * Strip DOM references from an object (recursive)
+ */
+ stripDOMReferences(obj) {
+ if (!obj || typeof obj !== 'object') return obj;
+
+ // Handle arrays
+ if (Array.isArray(obj)) {
+ return obj.map(item => this.stripDOMReferences(item));
+ }
+
+ // Handle objects
+ const cleaned = {};
+ for (const [key, value] of Object.entries(obj)) {
+ // Skip DOM-related properties
+ if (this.isDOMReference(key, value)) {
+ continue;
+ }
+
+ // Handle Set/Map collections
+ if (value instanceof Set) {
+ cleaned[key] = Array.from(value);
+ } else if (value instanceof Map) {
+ cleaned[key] = Object.fromEntries(value);
+ } else if (typeof value === 'object' && value !== null) {
+ cleaned[key] = this.stripDOMReferences(value);
+ } else {
+ cleaned[key] = value;
+ }
+ }
+
+ return cleaned;
+ }
+
+ /**
+ * Check if a property is a DOM reference
+ */
+ isDOMReference(key, value) {
+ // Check value types
+ if (value instanceof HTMLElement ||
+ value instanceof NodeList ||
+ value instanceof HTMLCollection ||
+ (value && value.nodeType !== undefined)) {
+ return true;
+ }
+
+ // Check key names - use exact match or word boundaries
+ const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
+ const lowerKey = key.toLowerCase();
+
+ // Only match if it's the exact key OR starts/ends with the pattern
+ if (domKeys.includes(lowerKey) ||
+ domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + k))) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the key for an item based on configured keyPath
+ */
+ getItemKey(item) {
+ if (typeof this.config.keyPath === 'function') {
+ return this.config.keyPath(item);
+ }
+
+ // Support nested keypaths like 'meta.id'
+ const keys = this.config.keyPath.split('.');
+ let value = item;
+
+ for (const key of keys) {
+ value = value?.[key];
+ }
+
+ return value;
+ }
+
+ /**
+ * Save a single item
+ */
+ /**
+ * Save a single item
+ */
+ async save(item) {
+ const key = this.getItemKey(item);
+
+ // Keep ORIGINAL item in memory (with FormData intact)
+ this.data.set(key, item); // ← Store original
+
+ // Create cleaned version ONLY for IndexedDB
+ let cleaned = { ...item };
+ if (cleaned.data instanceof FormData) {
+ cleaned.data = this.formDataToObject(cleaned.data);
+ }
+
+ if (this.config.stripDOMReferences) {
+ cleaned = this.stripDOMReferences(cleaned);
+ }
+
+ // Persist cleaned version to IndexedDB
+ await this.saveToDB(cleaned);
+
+ if(this.config.saveToServer && this.config.endpoint){
+ this.saveToServer(item);
+ }
+
+ this.notify('item-saved', { item: cleaned, key });
+
+ return cleaned;
+ }
+
+ /**
+ * Convert FormData to plain object for storage
+ */
+ formDataToObject(formData) {
+ const obj = {
+ _isFormData: true, // Flag to reconstruct later
+ entries: {}
+ };
+
+ for (const [key, value] of formData.entries()) {
+ // Skip File/Blob objects - they're stored separately
+ 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();
+
+ 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);
+ }
+ }
+ // Restore files from external blob store (UploadManager)
+ if (this.config.getBlobs && obj.entries.upload_ids) {
+ const uploadIds = JSON.parse(obj.entries.upload_ids);
+ const blobs = await this.config.getBlobs(uploadIds); // ← Await here
+
+ for (const blobData of blobs) {
+ if (blobData) {
+ const file = new File(
+ [blobData.data],
+ blobData.name,
+ { type: blobData.type, lastModified: blobData.lastModified }
+ );
+ formData.append('files[]', file);
+ }
+ }
+ }
+
+ return formData;
+ }
+
+ /**
+ * Save item to IndexedDB
+ */
+ async saveToDB(item) {
+ if (!this.db) return;
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readwrite');
+ const store = tx.objectStore(this.config.storeName);
+ const request = store.put(item);
+
+ request.onsuccess = () => resolve();
+ request.onerror = (e) => reject(e);
+ });
+ }
+
+ /**
+ * Batch save multiple items
+ */
+ async saveMany(items) {
+ if (!this.db) return;
+
+ const tx = this.db.transaction([this.config.storeName], 'readwrite');
+ const store = tx.objectStore(this.config.storeName);
+
+ const promises = items.map(item => {
+ const cleaned = this.config.stripDOMReferences
+ ? this.stripDOMReferences(item)
+ : item;
+
+ const key = this.getItemKey(cleaned);
+ this.data.set(key, cleaned);
+
+ return store.put(cleaned);
+ });
+
+ await Promise.all(promises);
+ this.notify('items-saved', { count: items.length });
+ }
+
+ /**
+ * Get a single item
+ */
+ get(key) {
+ return this.data.get(key); // ← Returns original with FormData
+ }
+
+ /**
+ * Get all items
+ */
+ getAll() {
+ return Array.from(this.data.values());
+ }
+
+ /**
+ * Delete an item
+ */
+ async delete(key, storeName = null) {
+ this.data.delete(key);
+
+ if (!storeName) {
+ storeName = this.config.storeName;
+ }
+ if (this.db) {
+ const tx = this.db.transaction([storeName], 'readwrite');
+ const store = tx.objectStore(storeName);
+ await store.delete(key);
+ }
+
+ this.notify('item-deleted', { key });
+ }
+
+ async saveBlob(key, blob) {
+ if (!this.db) return;
+
+ const tx = this.db.transaction(['blobs'], 'readwrite');
+ const store = tx.objectStore('blobs');
+ await store.put({
+ uploadId: key, // Match keyPath
+ data: blob,
+ type: blob.type,
+ name: blob.name,
+ lastModified: blob.lastModified || Date.now()
+ });
+ }
+
+ async getBlob(key) {
+ if (!this.db) return null;
+
+ return new Promise(resolve => {
+ const tx = this.db.transaction(['blobs'], 'readonly');
+ const request = tx.objectStore('blobs').get(key);
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => resolve(null);
+ });
+ }
+
+ /**
+ * Clear all data
+ */
+ async clear() {
+ this.data.clear();
+ this.cache.clear();
+ this.httpHeaders.clear();
+
+ if (this.domCache) {
+ this.domCache.clear();
+ }
+
+ if (this.db) {
+ const stores = [this.config.storeName];
+ if (this.config.endpoint) stores.push('cache');
+ if (this.config.useHttpCaching) stores.push('headers');
+
+ const tx = this.db.transaction(stores, 'readwrite');
+ stores.forEach(storeName => {
+ if (this.db.objectStoreNames.contains(storeName)) {
+ tx.objectStore(storeName).clear();
+ }
+ });
+ }
+
+ this.notify('data-cleared');
+ }
+
+ /**
+ * Fetch data from server with HTTP caching
+ */
+ async fetch(options = {}) {
+ if (!this.config.endpoint) {
+ throw new Error('No endpoint configured for fetch');
+ }
+ await this.init(); // Lazy init
const {
filters = this.filters,
headers = {},
} = options;
+ if (this.config.required && this.filters[this.config.required] === ''){
+ console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items');
+ return;
+ }
+
+ // PREVENT CONCURRENT FETCHES FOR SAME DATA
+ const cacheKey = this.generateCacheKey(filters);
+
+ // If already fetching this exact query, return a promise that resolves when done
+ if (this.isFetching && this.currentCacheKey === cacheKey) {
+ return new Promise((resolve) => {
+ // Store multiple waiting promises if needed
+ if (!this.pendingFetches) {
+ this.pendingFetches = [];
+ }
+ this.pendingFetches.push(resolve);
+ });
+ }
+
+
+ this.isFetching = true;
+ this.currentCacheKey = cacheKey;
+ let fetchResult = null; // Capture result for pending fetches
+
if (this.config.showLoading) {
this.setLoading(true);
}
+ //Check Cached data
+ const cachedData = this.cache.get(cacheKey);
+ if (cachedData && this.isCacheValid(cachedData)) {
+ this.isFetching = false;
- // Use provided endpoint or config endpoint
- const apiEndpoint = endpoint || this.config.endpoint;
- if (!apiEndpoint) {
- throw new Error('No endpoint specified');
+ if (this.config.showLoading) {
+ this.setLoading(false);
+ }
+ this.notify('data-loaded', cachedData.data);
+ return cachedData.data;
}
- // Generate cache key from endpoint and filters
- const cacheKey = this.generateCacheKey(apiEndpoint, filters);
- const cleanedFilters = this.cleanFilters(filters);
-
- // Build request URL
- const params = new URLSearchParams(cleanedFilters);
- const url = `${this.config.apiBase}${apiEndpoint}${params.toString() ? '?' + params : ''}`;
-
- // Prepare headers with conditional requests
+ // Build request headers with HTTP caching
const requestHeaders = {
...this.headers,
...headers
};
- // Add conditional headers from stored data
- const headerKey = this.generateHeaderKey(url);
- const storedHeaders = this.httpHeaders.get(headerKey);
- const cachedData = this.cache.get(cacheKey);
-
- if (storedHeaders && cachedData) {
- if (storedHeaders.etag) {
- requestHeaders['If-None-Match'] = storedHeaders.etag;
- }
- if (storedHeaders.lastModified) {
- requestHeaders['If-Modified-Since'] = storedHeaders.lastModified;
+ if (this.config.useHttpCaching) {
+ const httpCache = this.httpHeaders.get(cacheKey);
+ if (httpCache) {
+ if (httpCache.etag) {
+ requestHeaders['If-None-Match'] = httpCache.etag;
+ }
+ if (httpCache.lastModified) {
+ requestHeaders['If-Modified-Since'] = httpCache.lastModified;
+ }
}
}
+ // Build URL with filters
+ const cleanedFilters = this.cleanFilters(filters);
+ const params = new URLSearchParams(cleanedFilters);
+ const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
+
try {
const response = await fetch(url, {
method: 'GET',
headers: requestHeaders
});
- // Handle 304 Not Modified - return cached data
- if (response.status === 304) {
- console.debug(`304 Not Modified for ${url}`);
- if (cachedData) {
- // Update timestamp but keep data
- cachedData.timestamp = Date.now();
- this.cache.set(cacheKey, cachedData);
- await this.saveCacheToDB(cacheKey, cachedData);
+ // Handle 304 Not Modified
+ if (response.status === 304 && cachedData) {
+ console.log('304 response');
+ // Update timestamp but keep existing data
+ cachedData.timestamp = Date.now();
+ cachedData.fromCache = true;
+ cachedData.isError = false;
+ this.saveCache(cacheKey, cachedData);
- // Store current request info
- this.currentRequest = {
- filters: cleanedFilters,
- data: cachedData.data,
- cached: true
- };
-
- //TODO: should this be items-loaded?
- this.notify('data-cached', {
- data: cachedData.data,
- filters: cleanedFilters,
- cached: true
- });
- return cachedData.data;
- }
+ this.lastResponse = cachedData;
+ this.notify('data-loaded', cachedData);
+ fetchResult = cachedData.data;
+ return cachedData.data;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
- // Store response headers for future conditional requests
- this.storeResponseHeaders(headerKey, response);
-
const data = await response.json();
+ //Store full response for accesss to metadata, like stats
+ this.lastResponse = data;
+
+ // Store HTTP caching headers
+ if (this.config.useHttpCaching) {
+ this.storeResponseHeaders(cacheKey, response);
+ }
+
// Cache the response
const cacheEntry = {
key: cacheKey,
- endpoint: apiEndpoint,
- data: data,
+ items: data.items.map(item => item.id),
+ total: data.total,
+ maxPages: data['total_pages'],
timestamp: Date.now(),
- filters: cleanedFilters
+ endpoint: this.config.endpoint,
+ filters: filters
};
this.cache.set(cacheKey, cacheEntry);
- await this.saveCacheToDB(cacheKey, cacheEntry);
+ this.saveCache(cacheKey, cacheEntry);
- // Update items if data contains them
- if (data.items && this.config.endpoint === apiEndpoint) {
- this.updateItems(data.items);
- }
+ let items = (Array.isArray(data)) ? data : data.items;
+ await this.saveMany(items);
- // Store current request info
- this.currentRequest = {
- filters: cleanedFilters,
- data: data,
- cached: false
- };
-
- this.notify('data-fetched', {
- endpoint: apiEndpoint,
- data: data,
- filters: cleanedFilters
+ this.notify('data-loaded', {
+ data: {
+ items: items,
+ ...data
+ },
+ count: items.length,
+ filters: filters,
+ fromCache: false,
+ isError: false
});
+
+ fetchResult = data;
return data;
} catch (error) {
console.error('Fetch error:', error);
- // Try to return stale cache on error
+ // Return cached data if available, even if expired
if (cachedData) {
- console.warn('Returning stale cache due to fetch error');
- this.currentRequest = {
- filters: cleanedFilters,
- data: cachedData.data,
- cached: true,
- stale: true
- };
- this.notify('stale-cache-used', {
- data: cachedData.data,
- filters: cleanedFilters
- });
+ console.warn('Using stale cache due to fetch error');
+ cachedData.isError = true;
+ this.notify('data-loaded', cachedData);
+ fetchResult = cachedData.data;
return cachedData.data;
}
- this.notify('fetch-error', { error, filters: cleanedFilters });
throw error;
} finally {
if (this.config.showLoading) {
this.setLoading(false);
}
+
+ this.isFetching = false;
+ this.currentCacheKey = null;
+
+ // Resolve any pending fetches that were waiting
+ if (this.pendingFetches && this.pendingFetches.length > 0) {
+ this.pendingFetches.forEach(resolve => resolve(fetchResult));
+ this.pendingFetches = [];
+ }
}
}
/**
- * Update items in local store
+ * Fetch data from server with HTTP caching
*/
- updateItems(items) {
- this.items.clear();
- items.forEach(item => {
- this.items.set(item.id, item);
- });
- this.saveItemsToDB();
- this.notify('items-updated', { items });
- }
-
- /**
- * Get current request data and state
- */
- getCurrentRequest() {
- return this.currentRequest;
- }
-
- /**
- * Get a specific item by ID
- */
- getItem(id) {
- let check = parseInt(id);
- id = isNaN(check) ? id : check;
- const item = this.items.get(id);
- return item ? this.unserializeData(item) : null;
- }
-
- setItem(id, data, mergeExisting = true) {
- if (mergeExisting && this.items.has(id)) {
- let existing = this.getItem(id); // Get unserialized version
- data = window.deepMerge(existing, data);
+ async saveToServer(item) {
+ if (!this.config.saveToServer || !jvbSettings.currentUser) {
+ return;
+ }
+ if (!this.config.endpoint && this.config.saveToServer) {
+ throw new Error('No endpoint configured for saving to server');
}
- const serialized = this.serializeData(data);
- this.items.set(id, serialized); // Store serialized version
- this.saveItemsToDB();
- this.notify('item-stored', data); // Notify with original data
- return data;
- }
+ let requestBody;
+ let headers = this.config.headers;
+ headers['X-WP-Nonce'] = jvbSettings.nonce;
+ if (item instanceof FormData) {
+ item.append('user', jvbSettings.currentUser);
+ requestBody = item;
- hasUnrecoverableFiles(data) {
- if (!data || typeof data !== 'object') return false;
+ // console.log('Sending formData: ');
+ // for (const pair of requestBody.entries()) {
+ // console.log(pair[0], pair[1]);
+ // }
+ } else {
+ requestBody = JSON.stringify({
+ ...item,
+ user: jvbSettings.currentUser
+ });
+ // console.log('Sending data: ', {
+ // ...operation.data,
+ // id: operation.id,
+ // user: this.user
+ // });
- if (data._wasFile || data._wasBlob) return true;
-
- if (Array.isArray(data)) {
- return data.some(item => this.hasUnrecoverableFiles(item));
+ headers['Content-Type'] = 'application/json';
}
- if (data instanceof FormData) {
- for (const [key, value] of data.entries()) {
- if (value instanceof File || value instanceof Blob) return true;
+ const response = await fetch(
+ `${this.config.apiBase}${this.config.endpoint}`,
+ {
+ method: 'POST',
+ headers: headers,
+ body: requestBody
}
- return false;
- }
+ );
- return Object.values(data).some(value => this.hasUnrecoverableFiles(value));
- }
-
- serializeFormData(formData) {
- const obj = {};
-
- for (const [key, value] of formData.entries()) {
- // Handle file metadata (can't store actual file)
- if (value instanceof File) {
- continue;
+ const result = await response.json();
+ this.notify(
+ 'saved-to-server',
+ {
+ success: result.ok && result.success
}
- // Check if key already exists (for multiple values)
- if (key in obj) {
- // Convert to array if not already
- if (!Array.isArray(obj[key])) {
- obj[key] = [obj[key]];
- }
- obj[key].push(value);
- } else {
- obj[key] = value;
- }
- }
- return obj;
+ );
}
- serializeData(data) {
- if (!data) return null;
-
- if (data instanceof HTMLElement) {
- return null;
- }
- if (typeof data !== 'object') return data;
-
- if (data === null) return null;
-
- if (data instanceof FormData) {
- return {
- _type: 'FormData',
- ... this.serializeFormData(data)
- };
- }
-
- // Handle Arrays
- if (Array.isArray(data)) {
- return data.map(item => this.serializeData(item));
- }
-
- // Handle Date objects
- if (data instanceof Date) {
- return {
- _type: 'Date',
- value: data.toISOString()
- };
- }
-
- // Handle plain objects
- const output = {};
- for (const [key, value] of Object.entries(data)) {
- output[key] = this.serializeData(value);
- }
- return output;
- }
-
- unserializeData(data) {
- if (!data || typeof data !== 'object') return data;
- if (data === null) return null;
-
- // Check for special types
- if (data._type) {
- switch (data._type) {
- case 'FormData':
- return this.unserializeFormData(data);
- case 'File':
- // Can't reconstruct File, return metadata with warning flag
- return {
- _wasFile: true,
- _fileMetadata: data,
- name: data.name,
- type: data.type,
- size: data.size
- };
- case 'Blob':
- // Can't reconstruct Blob
- return {
- _wasBlob: true,
- _blobMetadata: data,
- type: data.type,
- size: data.size
- };
- case 'Date':
- return new Date(data.value);
- }
- }
-
- // Handle Arrays
- if (Array.isArray(data)) {
- return data.map(item => this.unserializeData(item));
- }
-
- // Handle plain objects
- const output = {};
- for (const [key, value] of Object.entries(data)) { // Fixed: 'of' not 'in'
- output[key] = this.unserializeData(value);
- }
- return output;
- }
- unserializeFormData(data) {
- const formData = new FormData();
-
- for (const [key, value] of Object.entries(data)) {
- if (Array.isArray(value)) {
- value.forEach(item => {
- if (item?._isFile) {
- console.warn(`Cannot restore file "${item.name}" from stored data`);
- // Optionally append metadata as JSON string for reference
- formData.append(key + '_was_file', JSON.stringify(item));
- } else {
- formData.append(key, item);
- }
- });
- } else if (value?._isFile) {
- console.warn(`Cannot restore file "${value.name}" from stored data`);
- // Optionally append metadata as JSON string for reference
- formData.append(key + '_was_file', JSON.stringify(value));
- } else if (value !== null && value !== undefined) {
- formData.append(key, value);
- }
- }
-
- return formData;
- }
-
-
- clearItem(key) {
- this.items.delete(key);
- if (this.db) {
- const tx = this.db.transaction(['items'], 'readwrite');
- const store = tx.objectStore('items');
- store.delete(key);
- }
- }
-
- /**
- * Filter helpers
- */
cleanFilters(filters) {
const cleaned = {};
Object.entries(filters).forEach(([key, value]) => {
@@ -597,10 +792,33 @@
return cleaned;
}
- setFilter(key, value) {
- const oldValue = this.filters[key];
+ /**
+ * Generate cache key from filters
+ */
+ generateCacheKey(filters) {
+ if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
+ return this.config.generateCacheKey(filters);
+ }
- if (value === '' || value === null || value === undefined) {
+ // Default strategy: sort keys and create string
+ const sorted = Object.keys(filters)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = filters[key];
+ return acc;
+ }, {});
+
+ return JSON.stringify(sorted);
+ }
+
+ setFilter(key, value) {
+ if (!this.filters) {
+ this.filters = {};
+ }
+ const oldValue = this.filters[key];
+ if (oldValue === value) {
+ return;
+ }else if (value === '' || value === null || value === undefined) {
delete this.filters[key];
} else {
this.filters[key] = value;
@@ -612,11 +830,12 @@
});
// Auto-fetch if endpoint is configured
- if (this.config.endpoint) {
+ if (this.config.endpoint !== null) {
this.fetch();
}
}
+
/**
* Remove a filter
*/
@@ -657,25 +876,58 @@
}
/**
- * Cache management
+ * Set multiple filters at once
*/
- generateCacheKey(endpoint, filters) {
- const sorted = Object.keys(filters).sort().reduce((obj, key) => {
- obj[key] = filters[key];
- return obj;
- }, {});
- return `${endpoint}_${JSON.stringify(sorted)}`;
+ async setFilters(filters) {
+ const hasChanges = Object.keys(filters).some(
+ key => this.filters[key] !== filters[key]
+ );
+
+ if (!hasChanges) {
+ return;
+ }
+
+ this.filters = { ...this.filters, ...filters };
+
+ this.notify('filters-changed', {
+ filters: this.filters,
+ changed: filters,
+ });
+
+ // Only fetch if endpoint configured
+ if (this.config.endpoint) {
+ this.fetch();
+ }
}
- generateHeaderKey(url) {
- return `headers_${url}`;
+ getFiltered() {
+ const cacheKey = this.generateCacheKey(this.filters);
+ const cacheEntry = this.cache.get(cacheKey);
+
+ if (cacheEntry && cacheEntry.items) {
+ return cacheEntry.items.reduce((acc, id) => {
+ const item = this.data.get(id);
+ if (item) acc.push(item);
+ return acc;
+ }, []);
+ }
+
+ return Array.from(this.data.values());
}
- isCacheValid(cacheEntry, maxAge = this.config.TTL) {
+ /**
+ * Check if cache entry is still valid
+ */
+ isCacheValid(cacheEntry) {
if (!cacheEntry || !cacheEntry.timestamp) return false;
- return (Date.now() - cacheEntry.timestamp) < maxAge;
+
+ const age = Date.now() - cacheEntry.timestamp;
+ return age < this.config.TTL;
}
+ /**
+ * Store HTTP response headers for caching
+ */
storeResponseHeaders(key, response) {
const headers = {
key,
@@ -685,10 +937,209 @@
};
this.httpHeaders.set(key, headers);
- this.saveHeadersToDB(key, headers);
+
+ if (this.db && this.db.objectStoreNames.contains('headers')) {
+ const tx = this.db.transaction(['headers'], 'readwrite');
+ const store = tx.objectStore('headers');
+ store.put(headers);
+ }
+ }
+
+ /**
+ * Clear HTTP cache headers for a specific cache key or all
+ */
+ clearHttpHeaders(cacheKey = null) {
+ if (cacheKey) {
+ this.httpHeaders.delete(cacheKey);
+
+ if (this.db && this.db.objectStoreNames.contains('headers')) {
+ const tx = this.db.transaction(['headers'], 'readwrite');
+ const store = tx.objectStore('headers');
+ store.delete(cacheKey);
+ }
+ } else {
+ // Clear all
+ this.httpHeaders.clear();
+
+ if (this.db && this.db.objectStoreNames.contains('headers')) {
+ const tx = this.db.transaction(['headers'], 'readwrite');
+ const store = tx.objectStore('headers');
+ store.clear();
+ }
+ }
+ }
+
+ /**
+ * Save cache entry to IndexedDB
+ */
+ async saveCache(key, data) {
+ if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
+
+ const tx = this.db.transaction(['cache'], 'readwrite');
+ const store = tx.objectStore('cache');
+ await store.put(data);
+ }
+
+ getCurrentRequest() {
+ return this.lastResponse;
+ }
+
+ /**
+ * Load cache from IndexedDB
+ */
+ async loadCache() {
+ if (!this.db) return;
+
+ return new Promise((resolve) => {
+ const tx = this.db.transaction(['cache'], 'readonly');
+ const store = tx.objectStore('cache');
+ const request = store.getAll();
+
+ request.onsuccess = (e) => {
+ e.target.result.forEach(item => {
+ if (this.isCacheValid(item)) {
+ this.cache.set(item.key, item);
+ }
+ });
+ resolve();
+ };
+ });
+ }
+
+ /**
+ * Load HTTP headers from IndexedDB
+ */
+ async loadHeaders() {
+ if (!this.db) return;
+
+ return new Promise((resolve) => {
+ const tx = this.db.transaction(['headers'], 'readonly');
+ const store = tx.objectStore('headers');
+ const request = store.getAll();
+
+ request.onsuccess = (e) => {
+ e.target.result.forEach(header => {
+ this.httpHeaders.set(header.key, header);
+ });
+ resolve();
+ };
+ });
}
+ /**
+ * Subscribe to store events
+ */
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ /**
+ * Notify subscribers of events
+ */
+ notify(event, data = {}) {
+ this.subscribers.forEach(callback => {
+ try {
+ callback(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
+
+ /**
+ * Check if store has items matching a specific filter
+ * @param {string} filterName - The filter to check
+ * @param {*} filterValue - The value to match
+ * @returns {boolean}
+ */
+ hasItemsForFilter(filterName, filterValue) {
+ if (!this.data || this.data.size === 0) return false;
+
+ return Array.from(this.data.values()).some(item => {
+ return item[filterName] === filterValue;
+ });
+ }
+
+ /**
+ * Query items using an index
+ */
+ async query(indexName, value) {
+ if (!this.db) return [];
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readonly');
+ const store = tx.objectStore(this.config.storeName);
+
+ if (!store.indexNames.contains(indexName)) {
+ reject(new Error(`Index ${indexName} does not exist`));
+ return;
+ }
+
+ const index = store.index(indexName);
+ const request = value !== undefined
+ ? index.getAll(value)
+ : index.getAll();
+
+ request.onsuccess = (e) => {
+ const results = e.target.result.map(item => {
+ return this.config.stripDOMReferences
+ ? this.stripDOMReferences(item)
+ : item;
+ });
+ resolve(results);
+ };
+
+ request.onerror = (e) => reject(e);
+ });
+ }
+
+ /**
+ * Count items in store
+ */
+ async count() {
+ if (!this.db) return this.data.size;
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readonly');
+ const store = tx.objectStore(this.config.storeName);
+ const request = store.count();
+
+ request.onsuccess = (e) => resolve(e.target.result);
+ request.onerror = (e) => reject(e);
+ });
+ }
+
+
+ setLoading(on) {
+ this.body.classList.toggle('loading', on);
+ if (on) {
+ this.loading.showModal();
+ } else {
+ this.loading.close();
+ }
+
+ }
+
+ /**
+ * Cleanup and destroy
+ */
+ destroy() {
+ if (this.currentRequest) {
+ this.currentRequest.abort();
+ }
+
+ this.subscribers.clear();
+ this.data.clear();
+ this.cache.clear();
+ this.httpHeaders.clear();
+
+ if (this.db) {
+ this.db.close();
+ this.db = null;
+ }
+ }
clearCache() {
this.cache.clear();
@@ -701,231 +1152,7 @@
this.notify('cache-cleared');
}
-
- invalidateCache(pattern) {
- const keysToDelete = [];
-
- this.cache.forEach((value, key) => {
- if (typeof pattern === 'string' && key.includes(pattern)) {
- keysToDelete.push(key);
- } else if (pattern instanceof RegExp && pattern.test(key)) {
- keysToDelete.push(key);
- }
- });
-
- keysToDelete.forEach(key => {
- this.cache.delete(key);
- if (this.db) {
- const tx = this.db.transaction(['cache'], 'readwrite');
- const store = tx.objectStore('cache');
- store.delete(key);
- }
- });
-
- this.notify('cache-invalidated', { count: keysToDelete.length });
- }
-
- /**
- * DOM Cache Management
- */
-
- /**
- * Store rendered DOM element for a specific item and view
- */
- storeDOMElement(itemId, viewName, element) {
- if (!this.domCache.has(itemId)) {
- this.domCache.set(itemId, {});
- }
-
- const itemCache = this.domCache.get(itemId);
- itemCache[viewName] = element.cloneNode(true);
- this.domCache.set(itemId, itemCache);
-
- // Save to IndexedDB
- this.saveDOMCacheToDB(itemId, itemCache);
- }
-
-
- /**
- * Retrieve cached DOM element for a specific item and view
- */
- getDOMElement(itemId, viewName) {
- const itemCache = this.domCache.get(itemId);
- if (itemCache && itemCache[viewName]) {
- return itemCache[viewName].cloneNode(true);
- }
- return null;
- }
-
- /**
- * Check if DOM element exists in cache
- */
- hasDOMElement(itemId, viewName) {
- const itemCache = this.domCache.get(itemId);
- return itemCache && itemCache[viewName];
- }
-
- /**
- * Clear DOM cache for a specific item
- */
- clearDOMCache(itemId) {
- this.domCache.delete(itemId);
-
- if (this.db) {
- const tx = this.db.transaction(['dom'], 'readwrite');
- const store = tx.objectStore('dom');
- store.delete(itemId);
- }
- }
-
- /**
- * Clear all DOM cache
- */
- clearAllDOMCache() {
- this.domCache.clear();
-
- if (this.db) {
- const tx = this.db.transaction(['dom'], 'readwrite');
- const store = tx.objectStore('dom');
- store.clear();
- }
- }
-
- /**
- * Helper method to render or retrieve cached DOM elements
- */
- renderOrRetrieve(item, viewName, renderFunction) {
- // Check cache first
- const cached = this.getDOMElement(item.id, viewName);
- if (cached) {
- return cached;
- }
-
- // Render new element
- const element = renderFunction(item);
-
- // Cache the rendered element
- this.storeDOMElement(item.id, viewName, element);
-
- return element;
- }
- /**
- * Database operations
- */
- async saveItemsToDB() {
- if (!this.db) return;
-
- const tx = this.db.transaction(['items'], 'readwrite');
- const store = tx.objectStore('items');
-
- store.clear();
- this.items.forEach(item => {
- if (!item._deleted) {
- store.put(item);
- }
- });
- }
-
- async saveCacheToDB(key, data) {
- if (!this.db) return;
-
- const tx = this.db.transaction(['cache'], 'readwrite');
- const store = tx.objectStore('cache');
- store.put(data);
- }
-
- async saveHeadersToDB(key, headers) {
- if (!this.db) return;
-
- const tx = this.db.transaction(['headers'], 'readwrite');
- const store = tx.objectStore('headers');
- store.put(headers);
- }
-
- async saveDOMCacheToDB(itemId, domCache) {
- if (!this.db) return;
-
- // Convert DOM elements to HTML strings for storage
- const serialized = {
- id: itemId,
- views: {}
- };
-
- Object.entries(domCache).forEach(([viewName, element]) => {
- if (element && element.outerHTML) {
- serialized.views[viewName] = element.outerHTML;
- }
- });
-
- const tx = this.db.transaction(['dom'], 'readwrite');
- const store = tx.objectStore('dom');
- store.put(serialized);
- }
-
- async saveFormsToDB(key, form) {
- if (!this.db) return;
-
- const tx = this.db.transaction(['forms'], 'readwrite');
- const store = tx.objectStore('forms');
- store.put(form);
- }
-
- storeForm(key, form) {
- this.forms.set(key, form);
- this.saveFormsToDB(key, form);
- }
-
- getForm(key) {
- return this.forms.has(key) ? this.forms.get(key) : null;
- }
-
- getAllForms() {
- return this.forms;
- }
-
- clearForm(key) {
- this.forms.delete(key);
- if (this.db) {
- const tx = this.db.transaction(['forms'], 'readwrite');
- const store = tx.objectStore('forms');
- store.delete(key);
- }
- }
-
- clearAllForms() {
- this.forms.clear();
- if (this.db) {
- const tx = this.db.transaction(['forms'], 'readwrite');
- const store = tx.objectStore('dom');
- store.clear();
- }
- }
-
- /**
- * Event system
- */
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- notify(event, data) {
- this.subscribers.forEach(cb => cb(event, data));
- }
-
- /**
- * Cleanup
- */
- destroy() {
- if (this.db) {
- this.db.close();
- }
- this.subscribers.clear();
- this.items.clear();
- this.cache.clear();
- this.domCache.clear();
- this.httpHeaders.clear();
- }
}
+// Export for use
window.jvbStore = DataStore;
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 73a2756..80be7b3 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -3,19 +3,26 @@
* Works with DataStore for CRUD operations and standalone for front-end forms
*/
class FormController {
- constructor() {
- this.store = new window.jvbStore({
- name:'forms',
- storeName: 'forms',
- keyPath: 'formId',
- indexes: [
- { name: 'status', keyPath: 'status' },
- { name: 'operationId', keyPath: 'operationId' },
- { name: 'timestamp', keyPath: 'timestamp' },
- { name: 'formType', keyPath: 'type' }
- ],
- TTL: 604800000, //7 days
- });
+ constructor(config = {}) {
+ this.config = {
+ collectFormData: false,
+ ... config
+ }
+ this.store = 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: 7 * 24 * 60 * 1000, //7 days
+ validateData: true,
+ delayFetch: true
+ });
this.debouncer = window.debouncer;
@@ -49,6 +56,10 @@
remove: 800,
reorder: 1000
};
+ this.isTimeline = false;
+ if (window.crudManager && window.crudManager.isTimeline) {
+ this.isTimeline = true;
+ }
// Bind handlers
this.clickHandler = this.handleClick.bind(this);
@@ -57,18 +68,49 @@
this.inputHandler = this.handleInput.bind(this);
this.focusHandler = this.handleFocus.bind(this);
this.blurHandler = this.handleBlur.bind(this);
+ //Processors
+ 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() {
- // Check for pending operations on page load
- await this.checkPendingOperations();
-
this.store.subscribe(this.handleStoreEvent.bind(this));
// Set up global form handlers for standalone forms
this.initListeners();
+ if (window.jvbQueue) {
+ window.jvbQueue.subscribe((event, data) => {
+ if (event === 'operation-completed' && data.type === 'form') {
+ this.handleOperationComplete(data);
+ }
+ });
+ }
+ }
+
+ /**
+ * Handle operation completion - clear related form cache
+ */
+ async handleOperationComplete(operation) {
+ // Clear the form data from store
+ if (operation.formId) {
+ try {
+ await this.store.delete(operation.formId);
+ } catch (error) {
+ console.warn('Failed to clear form cache:', error);
+ }
+ }
+
+ // Clear any related form state
+ const form = this.forms.get(operation.formId);
+ if (form) {
+ form.isDirty = false;
+ form.lastSaved = Date.now();
+ form.data = {};
+ }
}
handleStoreEvent(event, data) {
@@ -84,16 +126,21 @@
}
}
- async checkPendingForms() {
- let items = await this.store.query('status', 'draft');
- items.forEach(item => {
- let form = this.forms.get(item.formId);
- if (form && form.element) {
- form.element.querySelector('.restore-form').hidden = false;
+ checkPendingForms() {
+ // No async needed - data is already loaded in memory
+ const allForms = this.store.getAll();
+ const pendingForms = allForms.filter(form => form.status === 'draft');
+
+ pendingForms.forEach(item => {
+ const form = this.forms.get(item.formId);
+ if (form?.element) {
+ const restoreBtn = form.element.querySelector('.restore-form');
+ if (restoreBtn) {
+ restoreBtn.hidden = false;
+ }
new this.populateForm(form.element, item.data);
}
});
-
}
/**
* Check for pending operations from previous session
@@ -113,28 +160,31 @@
/**
* Show notification for pending changes
*/
- showPendingNotification(pendingData) {
- const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
+ /**
+ * Show notification for pending changes
+ */
+ showPendingNotification(formId, formData) {
+ const formElement = document.querySelector(`[data-form-id="${formId}"]`);
if (!formElement) return;
const notification = document.createElement('div');
notification.className = 'pending-changes-notification';
notification.innerHTML = `
- <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
- <button class="restore-changes" data-form-id="${pendingData.formId}">Restore</button>
- <button class="discard-changes" data-form-id="${pendingData.formId}">Discard</button>
- `;
+ <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
+ <button class="restore-changes" data-form-id="${formId}">Restore</button>
+ <button class="discard-changes" data-form-id="${formId}">Discard</button>
+ `;
formElement.insertBefore(notification, formElement.firstChild);
// Add handlers
- notification.querySelector('.restore-changes').addEventListener('click', () => {
- this.restorePendingForm(pendingData);
+ notification.querySelector('.restore-changes').addEventListener('click', async () => {
+ await this.restorePendingForm(formId, formData);
notification.remove();
});
- notification.querySelector('.discard-changes').addEventListener('click', () => {
- this.discardPendingForm(pendingData.formId);
+ notification.querySelector('.discard-changes').addEventListener('click', async () => {
+ await this.discardPendingForm(formId);
notification.remove();
});
}
@@ -142,16 +192,20 @@
/**
* Restore pending form data
*/
- restorePendingForm(pendingData) {
- const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
+ async restorePendingForm(formId, formData) {
+ const form = document.querySelector(`[data-form-id="${formId}"]`);
if (!form) return;
// Populate form with cached data
- new this.populateForm(form, pendingData.formData);
+ new this.populateForm(form, formData);
- // Mark as restored
- pendingData.status = 'restored';
- this.pendingForms.set(pendingData.formId, pendingData);
+ // Update status in store (mark as restored, not draft)
+ await this.store.save({
+ formId: formId,
+ data: formData,
+ status: 'restored',
+ timestamp: Date.now()
+ });
if (window.jvbA11y) {
window.jvbA11y.announce('Previous changes restored');
@@ -162,10 +216,14 @@
* Discard pending form data
*/
async discardPendingForm(formId) {
- this.store.delete(formId);
+ try {
+ await this.store.delete(formId);
- if (window.jvbA11y) {
- window.jvbA11y.announce('Previous changes discarded');
+ if (window.jvbA11y) {
+ window.jvbA11y.announce('Previous changes discarded');
+ }
+ } catch (error) {
+ console.error('Failed to discard pending form:', error);
}
}
@@ -207,7 +265,7 @@
...options
},
dependencies: new Map(),
- data: this.collectFormData(formElement),
+ data: this.collectFormData(formElement, true),
};
// Initialize special fields
@@ -661,12 +719,21 @@
if (this.subscribers.size > 0) {
event.preventDefault();
const formData = this.collectFormData(form);
+
+ // Notify subscribers (they'll handle actual submission)
this.notify('form-submit', {
- formId: formConfig.id,
- data: formData,
+ formId: form.dataset.formId,
+ 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) {
@@ -686,7 +753,6 @@
success.textContent = data.message;
form.insertBefore(success, form.firstChild);
- // Optionally add icon
const icon = window.getIcon?.('check-circle');
if (icon) {
icon.classList.add('success-icon');
@@ -706,7 +772,6 @@
}
if (data.description) {
- // Handle both string and array descriptions
const descriptions = Array.isArray(data.description)
? data.description
: [data.description];
@@ -721,6 +786,21 @@
form.insertBefore(successBox, form.firstChild);
}
+ // ✅ DELETE CACHED FORM DATA ON SUCCESS
+ if (form.dataset.formId) {
+ this.store.delete(form.dataset.formId).catch(err => {
+ console.warn('Failed to clear form cache:', err);
+ });
+
+ // Clear form config dirty state
+ const formConfig = this.forms.get(form.dataset.formId);
+ if (formConfig) {
+ formConfig.isDirty = false;
+ formConfig.lastSaved = Date.now();
+ formConfig.data = {}; // Clear cached data
+ }
+ }
+
// Announce success for accessibility
if (window.jvbA11y) {
window.jvbA11y.announce(data.message || 'Form submitted successfully');
@@ -881,7 +961,7 @@
const formConfig = this.forms?.get(form.dataset.formId);
if (!formConfig) return;
- console.log(formConfig.options);
+
if (formConfig.options.autosave || this.subscribers.size > 0) {
// Check conditional fields
const dependencies = formConfig.dependencies.get(target.name);
@@ -1409,33 +1489,40 @@
const formData = this.collectFormData(formConfig.element);
this.showFormStatus(formConfig.id, 'saving');
+
+ // DataStore will now automatically:
+ // - Convert Sets/Maps to Arrays/Objects
+ // - Strip DOM references
+ // - Validate serializability
await this.store.save({
formId: formConfig.id,
data: formData,
status: 'draft',
timestamp: Date.now()
- }).then(()=> {
+ }).then(() => {
this.showFormStatus(formConfig.id, 'autosaved');
+ }).catch(error => {
+ console.error('Autosave failed:', error);
+ this.showFormStatus(formConfig.id, 'error', 'Failed to save changes');
});
// Get only changed fields
const changes = this.getChangedFields(formConfig.data, formData);
- console.log('Changes:', changes);
if (Object.keys(changes).length === 0) return;
- console.log('Continuing on:');
// Update stored data
formConfig.data = formData;
this.forms.set(formConfig.id, formConfig);
document.removeEventListener('input', this.handleInput);
- for (let [key, value] of Object.entries(formData)) {
- //We want all data for complex fields, like group, repeater, or location
+ for (let [key, value] of Object.entries(formData)) {
+ // Complex fields need full data
if (typeof value === 'object') {
changes[key] = value;
}
}
- // Notify instead of callback
+
+ // Notify
this.notify('form-autosave', {
formId: formConfig.id,
changes: changes,
@@ -1474,8 +1561,6 @@
form.status = status;
- console.log('Setting status: ', status);
-
// Add new status
const statusWrap = form.element.querySelector('.fstatus');
statusWrap.hidden = false;
@@ -1534,10 +1619,15 @@
/* ========== Form Data Methods ========== */
- collectFormData(form) {
+ collectFormData(form, isInit = false) {
if (Object.hasOwn(form.dataset, 'timeline')) {
return this.collectTimeline(form);
}
+ //Table forms are handled separately
+ if (form.classList.contains('table') && form.tagName === 'FORM') {
+ return {};
+ }
+
const formData = new FormData(form);
let data = {};
const repeaterData = {};
@@ -1557,7 +1647,6 @@
}
collectTimeline(form) {
- console.log('Collecting Timeline data:');
let data = {};
let posts = {}; // Temporary object keyed by post ID
let postOrder = []; // Track order as encountered (preserves DOM/drag order)
@@ -1572,11 +1661,18 @@
// Timeline-specific field: [postId]fieldName
const [, postId, fieldName] = match;
if (!posts[postId]) {
- posts[postId] = { id: parseInt(postId) };
+ posts[postId] = {
+ id: parseInt(postId),
+ };
postOrder.push(postId); // Track first occurrence
}
- const processor = this.getFieldProcessor(fieldName);
- processor(fieldName, value, posts[postId], {}, {}, form);
+ if (fieldName === 'post_thumbnail') {
+ posts[postId]['post_thumbnail'] = parseInt(form.querySelector(`[name="${key}"]`).closest('.item')?.dataset.id);
+ } else {
+ const processor = this.getFieldProcessor(fieldName);
+ processor(fieldName, value, posts[postId], {}, {}, form);
+ }
+
} else {
// Shared field (post_title, taxonomies, etc.)
const processor = this.getFieldProcessor(key);
@@ -1592,12 +1688,10 @@
delete data['timeline_temp'];
delete data['']; // Empty key
- console.log('Data: ', data);
return data;
}
getFieldProcessor(key) {
- if (key.includes('|')) return this.processTableField;
if (key.includes('::')) return this.processGroupField;
if (key.includes(':')) return this.processRepeaterField;
if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
@@ -1622,42 +1716,12 @@
}
mergePostData(data, postData) {
- for (let [postId, postData] in Object.entries(postData)) {
- data[postId] = postData;
+ for (let [postId, fields] of Object.entries(postData)) {
+ data[postId] = fields;
}
return data;
}
- processTableField(key, value, data, repeaterData, postData, form) {
- /***
- * Table forms are a huge form containing multiple posts and their data
- * Field names are prepended with `${postID}|`
- * Goal:
- * 1) Separate out the post id from the field name
- * 2) store the original data in a temporary 'original' variable
- * 3) Process the field as normal
- * 4) return the original data, as PostID: {$field data}
- * Final format:
- * {
- * id1: {
- * field1: "A title",
- * field3: 32
- * },
- * id2: {
- * field1: "Another title",
- * field2: "122,21,32"
- * }
- * }
- **/
- let [post, fieldKey] = key.split('|');
- if (!post in postData) {
- postData[post] = {};
- }
-
- const processor = this.getFieldProcessor(fieldKey);
- processor(fieldKey, value, postData, repeaterData, postData, form);
-
- }
processRepeaterField(key, value, data, repeaterData, postData, form) {
let [fieldName, index, subField] = key.split(':');
diff --git a/assets/js/concise/FrontendFavourites.js b/assets/js/concise/FrontendFavourites.js
index 769b4aa..b558f5f 100644
--- a/assets/js/concise/FrontendFavourites.js
+++ b/assets/js/concise/FrontendFavourites.js
@@ -1,33 +1,35 @@
class FrontendFavourites {
constructor() {
// Initialize DataStore for queue persistence
- this.store = new window.jvbStore({
- name: 'favourites',
- storeName: 'favourites',
- endpoint: 'favourites',
- indexes: [
- {name: 'content', keyPath: 'content'},
- {name: 'listId', keyPath: 'listId'},
- ],
- TTL: 86400000,
- showLoading: false,
- filters: {
- user: jvbSettings.currentUser,
- content: 'all',
- order: 'desc',
- orderby: 'date',
- page: 1,
- all: true,
- }
- });
+ this.store = window.jvbStore.register(
+ 'favourites',
+ {
+ storeName: 'favourites',
+ endpoint: 'favourites',
+ indexes: [
+ {name: 'content', keyPath: 'content'},
+ {name: 'listId', keyPath: 'listId'},
+ ],
+ TTL: 6 * 60 * 1000,
+ showLoading: false,
+ filters: {
+ user: jvbSettings.currentUser,
+ content: 'all',
+ order: 'desc',
+ orderby: 'date',
+ page: 1,
+ all: true,
+ }
+ });
- this.listStore = new window.jvbStore({
- name: 'favourites_lists',
- storeName: 'lists',
- keyPath: 'listId',
- endpoint: 'favourites/lists',
- TTL: 86400000,
- })
+ this.listStore = window.jvbStore.register(
+ 'favourites_lists',
+ {
+ storeName: 'lists',
+ keyPath: 'listId',
+ endpoint: 'favourites/lists',
+ TTL: 6 * 60 * 1000,
+ });
this.store.subscribe((event, data) => {
switch (event) {
diff --git a/assets/js/concise/FrontendVotes.js b/assets/js/concise/FrontendVotes.js
index 93b03ff..fefc95a 100644
--- a/assets/js/concise/FrontendVotes.js
+++ b/assets/js/concise/FrontendVotes.js
@@ -1,13 +1,13 @@
class FrontendVotes {
constructor() {
// Initialize DataStore for queue persistence
- this.store = new window.jvbStore({
- name: 'favourites',
- endpoint: 'favourites',
- useIndexedDB: true,
- TTL: Infinity,
- showLoading: false
- });
+ this.store = window.jvbStore.register('votes',
+ {
+ endpoint: 'votes',
+ useIndexedDB: true,
+ TTL: 6 * 60 * 1000,
+ showLoading: false
+ });
}
handleVote(button) {
diff --git a/assets/js/concise/Gallery.js b/assets/js/concise/Gallery.js
new file mode 100644
index 0000000..07eaef1
--- /dev/null
+++ b/assets/js/concise/Gallery.js
@@ -0,0 +1,463 @@
+class Gallery {
+ 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: false
+ };
+
+ this.swipe = this.resetSwipe();
+
+ this.activePointers = new Map();
+ this.lastTap = 0;
+
+ this.initElements();
+ this.initModal();
+ this.initListeners();
+ this.initSubscribers();
+
+ }
+ /*********************************************************************
+ ELEMENTS
+ *********************************************************************/
+ initElements() {
+ this.elements = {
+ imageSelector: 'a.open-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((event, data) => {
+ if (event === 'modal-close') {
+ this.toggleGallery(false);
+ }
+ });
+ }
+ buildGalleryItems(filtered = null) {
+ let selector = filtered ? `[data-opens="${filtered}"]` : this.elements.imageSelector;
+ this.items = Array.from(document.querySelectorAll(selector))
+ .map((img, index) => {
+ let image = img.querySelector('img');
+
+ return {
+ id: img.dataset.id||index,
+ small: image.dataset.small || img.src,
+ medium: image.dataset.medium || img.src,
+ full: image.dataset.full || img.src,
+ alt: image.alt || '',
+ element: image
+ };
+ });
+ }
+ /*********************************************************************
+ LISTENERS
+ *********************************************************************/
+ 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 target = window.targetCheck(e, this.elements.imageSelector);
+ if (target && !this.modal.isOpen) {
+ e.preventDefault();
+ this.buildGalleryItems((Object.hasOwn(target.dataset, 'opens')) ? target.dataset.opens : null);
+
+ this.index = this.items.findIndex(item => item.element === target.querySelector('img'));
+ this.toggleGallery(true);
+ } else if (this.modal.isOpen) {
+ if (window.targetCheck(e, this.elements.gallery.nextButton)) {
+ console.log('Next');
+ this.nextElement();
+ } else if (window.targetCheck(e, this.elements.gallery.prevButton)) {
+ console.log('Previous');
+ this.prevElement();
+ }
+ }
+ }
+
+ handleKeys(e) {
+ if (!this.modal.isOpen) return;
+ switch (e.key) {
+ case 'ArrowLeft':
+ e.preventDefault();
+ this.prevElement();
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ this.nextElement();
+ break;
+ }
+ if (!e.ctrlKey) return;
+ if (e.key === '+' || e.key === '=') {
+ e.preventDefault();
+ this.handleZoom(.2); // centered
+ }
+
+ if (e.key === '-') {
+ e.preventDefault();
+ this.handleZoom(-.2); // centered
+ }
+
+ if (e.key === '0') {
+ e.preventDefault();
+ this.resetZoom();
+ }
+ }
+
+ onPointerDown(e) {
+ 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 now = performance.now();
+
+ // DOUBLE-TAP / DOUBLE-CLICK
+ if (now - this.lastTap < 300 && this.activePointers.size === 1) {
+ if (this.zoom.scale > 1) this.resetZoom();
+ else this.handleZoom(+1, e.clientX, e.clientY);
+
+ this.lastTap = 0;
+ return;
+ }
+
+ this.lastTap = now;
+
+ // PINCH START
+ if (this.activePointers.size === 2) {
+ const pts = [...this.activePointers.values()];
+ this.pinchStartDist = Math.hypot(
+ pts[0].x - pts[1].x,
+ pts[0].y - pts[1].y
+ );
+ this.pinchStartScale = this.zoom.scale;
+ return;
+ }
+
+ // PAN START
+ if (this.zoom.scale > 1) {
+ this.zoom.panning = true;
+ this.zoom.startX = e.clientX - this.zoom.x;
+ this.zoom.startY = e.clientY - this.zoom.y;
+ }
+ }
+
+ onPointerMove(e) {
+ if (!this.activePointers.has(e.pointerId)) return;
+
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+ // PINCH (two pointers)
+ if (this.activePointers.size === 2) {
+ const pts = [...this.activePointers.values()];
+ const dist = Math.hypot(
+ pts[0].x - pts[1].x,
+ pts[0].y - pts[1].y
+ );
+
+ const newScale = this.pinchStartScale * (dist / this.pinchStartDist);
+ const increment = newScale - this.zoom.scale;
+
+ // Zoom centered (NO midpoint)
+ this.handleZoom(increment);
+
+ return;
+ }
+
+ // PAN (one pointer)
+ if (this.zoom.panning) {
+ this.zoom.x = e.clientX - this.zoom.startX;
+ this.zoom.y = e.clientY - this.zoom.startY;
+ this.applyTransform();
+ }
+ }
+
+ onPointerUp(e) {
+ this.activePointers.delete(e.pointerId);
+
+ if (this.activePointers.size < 2) {
+ this.pinchStartDist = 0;
+ }
+
+ if (!this.zoom.panning && this.activePointers.size === 0) {
+ // End of tap or swipe - detect swipe
+ this.swipe.endX = e.clientX;
+ this.swipe.endY = e.clientY;
+ const dx = this.swipe.endX - this.swipe.startX;
+ const dy = this.swipe.endY - this.swipe.startY;
+
+ if (Math.abs(dx) > this.zoom.threshold) {
+ if (dx > 0) {
+ console.log('Swipe right');
+ this.prevElement();
+ } else {
+ console.log('Swipe left');
+ this.nextElement();
+ }
+ }
+
+ this.zoom.panning = false;
+ }
+ }
+
+ /* --------------------------------------------------
+ WHEEL ZOOM (pointer-centered)
+ -------------------------------------------------- */
+ onWheel(e) {
+ if (!e.ctrlKey) return;
+ e.preventDefault();
+
+ const increment = (e.deltaY < 0) ? +0.2 : -0.2;
+ 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;
+
+ newScale = Math.min(this.zoom.max, Math.max(this.zoom.min, newScale));
+
+ if (newScale === oldScale) return;
+
+ const ratio = newScale / oldScale;
+
+ // default: center of image
+ let rect = this.ui.gallery.image.getBoundingClientRect();
+ if (clientX === null || clientY === null) {
+ clientX = rect.left + rect.width / 2;
+ clientY = rect.top + rect.height / 2;
+ }
+
+ const localX = clientX - rect.left;
+ const localY = clientY - rect.top;
+
+ this.zoom.x = (this.zoom.x - localX) * ratio + localX;
+ this.zoom.y = (this.zoom.y - localY) * ratio + localY;
+
+ this.zoom.scale = newScale;
+
+ this.applyTransform();
+ this.notify("zoom", { scale: this.zoom.scale });
+ }
+
+ applyTransform() {
+ // this.clampPan();
+ const img = this.ui.gallery.image;
+ img.style.transform = `translate(${this.zoom.x}px, ${this.zoom.y}px) scale(${this.zoom.scale})`;
+ }
+ resetZoom() {
+ this.zoom.scale = 1;
+ this.zoom.x = 0;
+ this.zoom.y = 0;
+ this.zoom.startX = 0;
+ this.zoom.startY = 0;
+ this.zoom.panning = false;
+ this.applyTransform();
+ }
+
+ resetSwipe() {
+ return {
+ startX: null,
+ startY: null,
+ endX: null,
+ endY: null
+ };
+ }
+
+ /**
+ *
+ * @param {boolean} open
+ * @param {null|number} index
+ */
+ toggleGallery(open, index= null) {
+ if (open) {
+ 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: false});
+ window.addEventListener("keydown", this.keyHandler);
+ this.moveIntoView();
+ } else {
+ 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;
+ }
+ if (open && !this.modal.isOpen) {
+ this.modal.handleOpen();
+ }
+ }
+ /*********************************************************************
+ GALLERY FUNCTIONALITY
+ *********************************************************************/
+ moveIntoView(index = 0) {
+ let newIndex = this.index + index;
+ //Wrap around
+ if (newIndex < 0) {
+ newIndex = this.items.length - 1;
+ } else if (newIndex >= this.items.length) {
+ newIndex = 0;
+ } else if (newIndex === this.items.length - 3) {
+ this.notify('load-more');
+ }
+
+ this.index = newIndex;
+ 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 item = this.items[this.index];
+ if (!item) return;
+ this.ui.gallery.image.src = item.full;
+ this.ui.gallery.image.alt = item.alt;
+ 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(offset => {
+ const index = this.index + offset;
+ if (index >0 && index < this.items.length) {
+ const item = this.items[index];
+ const img = offset < 0 ? this.ui.gallery.leftImage : this.ui.gallery.rightImage;
+ img.src = item.full;
+ }
+ });
+ }
+ /*********************************************************************
+ SUBSCRIBERS
+ *********************************************************************/
+ initSubscribers() {
+ this.subscribers = new Set();
+ }
+
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach( callback => {
+ try {
+ callback(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
+
+ /******************************************************************
+ CLEANUP
+ ******************************************************************/
+
+ destroy() {
+ this.subscribers.clear();
+ this.toggleGallery(false);
+ document.removeEventListener('click', this.clickHandler);
+ }
+}
+
+document.addEventListener('DOMContentLoaded', function() {
+ if (document.querySelector('dialog.gallery')) {
+ window.jvbGallery = new Gallery();
+ }
+});
diff --git a/assets/js/concise/Media.js b/assets/js/concise/Media.js
index ab20c7f..a9e1340 100644
--- a/assets/js/concise/Media.js
+++ b/assets/js/concise/Media.js
@@ -78,8 +78,8 @@
}
getTargetSize() {
- if (this.currentWidth < 768) return 'small';
- if (this.currentWidth < 1200) return 'medium';
+ if (this.currentWidth < 768) return 'medium';
+ if (this.currentWidth < 1200) return 'large';
return 'full';
}
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index a15d1fa..1f4273d 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -28,12 +28,10 @@
const fieldType = this.getFieldType(fieldWrapper);
switch (fieldType) {
- case 'image':
- this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData);
- break;
-
+ case 'upload':
case 'gallery':
- this.populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData);
+ case 'image':
+ this.populateUploadField(fieldWrapper, fieldName, fieldValue, imagesData);
break;
case 'repeater':
@@ -100,9 +98,18 @@
* @returns {string} Field type
*/
getFieldType(fieldWrapper) {
+ // Check dataset first (most reliable for our use case)
+ if (fieldWrapper.dataset.fieldType) {
+ return fieldWrapper.dataset.fieldType;
+ }
+
+ if (fieldWrapper.dataset.type) {
+ return fieldWrapper.dataset.type;
+ }
+
// Check for specific field classes
const typeClasses = [
- 'image', 'gallery', 'repeater', 'taxonomy', 'user', 'location',
+ 'upload', 'repeater', 'taxonomy', 'user', 'location',
'set', 'checkbox', 'select', 'radio', 'true_false', 'date',
'time', 'datetime', 'editor', 'number', 'text', 'textarea',
'email', 'url', 'tel', 'phone'
@@ -114,11 +121,6 @@
}
}
- // Check for data attribute
- if (fieldWrapper.dataset.type) {
- return fieldWrapper.dataset.type;
- }
-
// Check input type
const input = fieldWrapper.querySelector('input, select, textarea');
if (input) {
@@ -138,7 +140,12 @@
*/
populateTextField(fieldWrapper, fieldName, fieldValue) {
const input = fieldWrapper.querySelector(`[name="${fieldName}"], input, textarea`);
+ // Skip file inputs - browsers don't allow setting their values programmatically
+ if (input && input.type === 'file') {
+ return;
+ }
if (input) {
+
input.value = String(fieldValue || '');
// Update character counter if present
@@ -413,134 +420,176 @@
}
/**
- * Populate image fields
+ * Populate upload fields (images, videos, files)
*/
- populateImageField(fieldWrapper, fieldName, fieldValue, imagesData = {}) {
+ populateUploadField(fieldWrapper, fieldName, fieldValue, imagesData = {}) {
+ // Check if this is a timeline gallery
+ const isTimeline = fieldWrapper.dataset.subtype === 'timeline' || fieldName === 'timeline';
+
+ if (isTimeline) {
+ this.populateTimelineGallery(fieldWrapper, fieldName, fieldValue, imagesData);
+ return;
+ }
+
if (!fieldValue) {
return;
}
// Handle comma-separated IDs or single ID
- const imageIds = String(fieldValue).split(',').filter(id => parseInt(id.trim()));
- if (imageIds.length === 0) {
+ const itemIds = String(fieldValue).split(',').filter(id => parseInt(id.trim()));
+ if (itemIds.length === 0) {
return;
}
// Update hidden input
const hiddenInput = fieldWrapper.querySelector(`input[type="hidden"][name="${fieldName}"]`);
if (hiddenInput) {
- hiddenInput.value = imageIds.join(',');
+ hiddenInput.value = itemIds.join(',');
}
- // Update image display
+ // Update display grid
const grid = fieldWrapper.querySelector('.item-grid');
const uploadContainer = fieldWrapper.querySelector('.file-upload-container');
+
+
fieldWrapper.querySelector('.progress')?.remove();
+
if (grid) {
window.removeChildren(grid);
- imageIds.forEach(imageId => {
- let image = window.getTemplate('uploadItem');
- let img = image.querySelector('img');
+ itemIds.forEach(itemId => {
+ const template = window.getTemplate('uploadItem');
- let details = image.querySelector('details');
- let meta = window.getTemplate('uploadMeta')
- details.append(meta);
- [
- img.src,
- img.alt,
- image.querySelector('[name="image-title"]').value,
- image.querySelector('[name="image-alt-text"]').value,
- image.querySelector('[name="image-caption"]').value,
- ] = [
- imagesData[imageId].medium,
- imagesData[imageId].alt,
- imagesData[imageId].title,
- imagesData[imageId].alt,
- imagesData[imageId].caption,
- ];
+ if (!template) {
+ console.warn('uploadItem template not found');
+ return;
+ }
- details.querySelector('.upload-meta > .hint')?.remove();
+ let input = template.querySelector('input[name="select-item"]');
+ let label = template.querySelector('label[for="select-item"]');
+ template.dataset.id = itemId;
+ input.name = input.name + `-${itemId}`;
+ input.id = input.name;
+ label.htmlFor = input.name;
- grid.append(image);
+
+ const img = template.querySelector('img');
+
+ template.querySelector('video').remove();
+ const details = template.querySelector('details');
+
+ // Populate with data
+ if (imagesData[itemId]) {
+ const data = imagesData[itemId];
+ if (img) {
+ img.src = data.medium || data.small || data.large || '';
+ img.alt = data['image-alt-text'] || data.alt || '';
+ }
+
+ // Populate metadata fields
+ const titleInput = template.querySelector('[name="image-title"]');
+ const altInput = template.querySelector('[name="image-alt-text"]');
+ const captionInput = template.querySelector('[name="image-caption"]');
+
+ if (titleInput) titleInput.value = data['image-title'] || data.title || '';
+ if (altInput) altInput.value = data['image-alt-text'] || data.alt || '';
+ if (captionInput) captionInput.value = data['image-caption'] || data.caption || '';
+ } else {
+ console.warn('No image data found for ID:', itemId);
+ }
+
+ // Remove hint if present
+ details?.querySelector('.upload-meta > .hint')?.remove();
+
+ grid.append(template);
});
- if (imageIds.length > 0) {
- if (uploadContainer) {
- uploadContainer.hidden = true;
- }
+ // Hide upload container if items exist
+ if (itemIds.length > 0 && uploadContainer) {
+ uploadContainer.hidden = true;
}
}
}
- /**
- * Populate gallery fields
- */
- populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData = {}) {
- if (fieldName === 'timeline') {
- this.populateTimelineGallery(fieldWrapper, fieldName, fieldValue, imagesData);
- } else {
- this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData);
- }
- }
-
- populateTimelineGallery(fieldWrapper, fieldName, fieldValue, imagesData)
- {
- if (!fieldValue) {
+ populateTimelineGallery(fieldWrapper, fieldName, fieldValue, imagesData) {
+ if (!fieldValue || typeof fieldValue !== 'object') {
return;
}
- const imageIds = Object.keys(fieldValue);
+ const imageIds = Object.values(fieldValue);
if (imageIds.length === 0) {
return;
}
- // Update image display
+ // Update display
const grid = fieldWrapper.querySelector('.item-grid');
const uploadContainer = fieldWrapper.querySelector('.file-upload-container');
fieldWrapper.querySelector('.progress')?.remove();
+
if (grid) {
window.removeChildren(grid);
- imageIds.forEach(imageId => {
- let image = window.getTemplate('timelineItem');
- let img = image.querySelector('img');
+ console.log(imageIds);
+ for (let [index, data] of Object.entries(fieldValue)) {
+ let imageId = data['post_thumbnail'];
+ const template = window.getTemplate('timelineItem');
+ if (!template) return;
- image.querySelector('video')?.remove();
- image.querySelector('.select-item span')?.remove();
- let data = fieldValue[imageId];
+ const img = template.querySelector('img');
- let imgData = imagesData[imageId];
+ // Remove unnecessary elements
+ template.querySelector('video')?.remove();
+ template.querySelector('.select-item span')?.remove();
- img.src = imgData.medium;
+ let input = template.querySelector('input[name="select-item"]');
+ let label = template.querySelector('label[for="select-item"]');
+ template.dataset.id = imageId;
+ template.dataset.postId = data.id;
+ input.name = input.name + `-${imageId}`;
+ input.id = input.name;
+ label.htmlFor = input.name;
- data = { ... data, ... imgData};
+ const imgData = imagesData[imageId];
- let fields = image.querySelectorAll('.field');
+ // Set image source
+ if (img && imgData) {
+ img.src = imgData.medium || imgData.small || imgData.large || '';
+ img.title = imgData['image-title'];
+ }
+
+ // Merge data
+ const mergedData = { ...data, ...imgData };
+
+ // Populate fields
+ const fields = template.querySelectorAll('.field');
fields.forEach(field => {
- if (field.classList.contains('group')){
+ if (field.classList.contains('group')) {
return;
}
- let input = field.querySelector('input, textarea');
- let label = field.querySelector('label');
- let name = input.name.replace('upload_data::','');
+ const input = field.querySelector('input:not([type="file"]), textarea');
+ if (!input) return;
- let value = data[name];
+ const label = field.querySelector('label');
+ const name = input.name.replace('upload_data::', '');
+ const value = mergedData[name];
+
+ // Populate the field
this.populateField(field, name, value, imagesData);
- let id = data.id;
- let newName = `[${id}]${name}`;
- [
- input.name,
- input.id,
- label.htmlFor
- ] = [
- newName,
- newName,
- newName,
- ]
+ // Update field identifiers
+ const id = data.id;
+ const newName = `[${id}]${name}`;
+ input.name = newName;
+ input.id = newName;
+ if (label) label.htmlFor = newName;
});
- grid.append(image);
- });
+
+ grid.append(template);
+ }
+
+ // Hide upload container if items exist
+ if (imageIds.length > 0 && uploadContainer) {
+ uploadContainer.hidden = true;
+ }
}
}
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index 38d9105..56dcb97 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -5,7 +5,6 @@
class QueueManager {
constructor(config = {}) {
this.canUpdateUI = true;
- console.log('jvbSettings', jvbSettings);
this.config = {
apiBase: jvbSettings.api,
maxRetries: 3,
@@ -16,7 +15,6 @@
...config
};
this.user = jvbSettings.currentUser;
- console.log(this.user);
this.headers = {
@@ -28,9 +26,7 @@
this.errors = window.jvbError;
// Initialize DataStore for queue persistence
- this.store = new window.jvbStore({
- name: 'queue',
- storeName: 'operations',
+ this.store = window.jvbStore.register('queue', {
keyPath: 'id',
endpoint: this.config.endpoint,
TTL: Infinity,
@@ -39,19 +35,7 @@
{name: 'type', keyPath: 'type'},
],
showLoading: false,
- getBlobs: async (ids) => {
- if (window.jvbUploadBlobs) {
- if (!Array.isArray(ids) && typeof ids === 'string') {
- ids = [ids];
- }
- // Get individual blobs (not all items)
- const blobs = await Promise.all(
- ids.map(id => window.jvbUploadBlobs.getBlob(id))
- );
- return blobs.filter(Boolean); // Remove nulls
- }
- return null;
- }
+ delayFetch: false, // Queue should fetch immediately
});
this.classes = [
@@ -80,7 +64,6 @@
// Initialize
this.initUI();
this.initListeners();
- console.log(this.ui);
if (this.ui.panel) {
this.popup = new window.jvbPopup({
popup: this.ui.panel,
@@ -109,6 +92,7 @@
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) {
@@ -117,9 +101,17 @@
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);
+ }
+ }
if (this.hasQueuedOperations()) {
this.startPolling();
}
+ break;
default:
this.updateUI();
break;
@@ -128,10 +120,30 @@
});
this.notify('queue-initialized', {operations: incomplete});
}
+
+ /**
+ * Handle operation status changes and notify subscribers
+ */
+ handleOperationStatusChange(operation, oldStatus) {
+ if (!operation || oldStatus === operation.status) return;
+
+ // Notify based on new status
+ switch(operation.status) {
+ case 'completed':
+ this.notify('operation-completed', operation);
+ break;
+ case 'failed':
+ this.notify('operation-failed', operation);
+ break;
+ case 'failed_permanent':
+ this.notify('operation-failed-permanent', operation);
+ break;
+ }
+ }
/**
*
* @param {object} operation
- * @param {string} operatio n.endpoint The endpoint, excluding the apiBase
+ * @param {string} operation.endpoint The endpoint, excluding the apiBase
* @param {object} operation.data The data to save
* @param {boolean} operation.canMerge Whether data can merge
* @param {string} operation.title The title of the operation for the Queue Panel
@@ -186,6 +198,7 @@
}
console.log('Added to Queue: ', item);
+ this.store.clearCache();
//Add new operation to DataStore
this.setQueue(item);
@@ -312,10 +325,6 @@
// console.log(pair[0], pair[1]);
// }
- console.log('Sending to server:');
- for (var [key, value] of requestBody.entries()) {
- console.log(key, value);
- }
} else {
requestBody = JSON.stringify({
...operation.data,
@@ -409,6 +418,7 @@
this.pollTimer = setInterval(async () => {
try {
+ this.store.clearCache();
await this.store.fetch(); // Fetches from server, updates store.data
const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
@@ -474,7 +484,7 @@
'Content-Type': 'application/json',
...this.headers
},
- body: JSON.stringify({ids,action})
+ body: JSON.stringify({ids,action, user: jvbSettings.currentUser})
}
);
@@ -574,6 +584,8 @@
return;
}
if (e.target.closest(this.selectors.refreshButton)) {
+ this.store.clearCache();
+ this.store.clearHttpHeaders(); // Clear cached headers first
this.store.fetch();
} else if (e.target.closest(this.selectors.clearButton)) {
const completedOps = this.getOperationsByStatus('completed');
@@ -666,13 +678,27 @@
if (!this.canUpdateUI) {
return;
}
- const stats = this.getQueueStats();
+
+ // Get current operations from store
+ const operations = Array.from(this.store.data.values());
+
+ // Get stats from last fetch response (server-provided)
+ const stats = this.store.lastResponse?.queue_stats || {
+ queued: 0,
+ localProcessing: 0,
+ uploading: 0,
+ pending: 0,
+ processing: 0,
+ completed: 0,
+ failed: 0,
+ failed_permanent: 0
+ };
// Update count badge
if (this.ui.count) {
- const total = stats.total - stats.completed;
- this.ui.count.textContent = total > 0 ? total : '';
- this.ui.count.style.display = total > 0 ? '' : 'none';
+ const activeCount = operations.length - stats.completed;
+ this.ui.count.textContent = activeCount > 0 ? activeCount : '';
+ this.ui.count.style.display = activeCount > 0 ? '' : 'none';
}
// Update indicator
@@ -681,14 +707,16 @@
stats.pending > 0 || stats.processing > 0;
this.ui.indicator.classList.toggle('active', hasActive);
}
- let failed = this.getOperationsByStatus('failed');
- let completed = this.getOperationsByStatus('completed');
- this.ui.clearButton.disabled = completed.length === 0;
- this.ui.retryButton.disabled = failed.length === 0;
- // Update filter counts
+ // Update button states
+ this.ui.clearButton.disabled = this.getOperationsByStatus('completed').length === 0;
+ this.ui.retryButton.disabled = this.getOperationsByStatus('failed').length === 0 && this.getOperationsByStatus('failed_permanent').length === 0;
+
+ // Update filter counts (from server stats)
Object.entries(this.ui.filters).forEach(([status, button]) => {
- const count = status === 'all' ? stats.total : stats[status] || 0;
+ const count = status === 'all'
+ ? operations.length
+ : stats[status] || 0;
const countEl = button.querySelector('.count');
if (countEl) {
countEl.textContent = count > 0 ? count : '';
@@ -696,7 +724,7 @@
button.setAttribute('data-count', count);
});
- // Update operation list
+ // Render current operations
this.renderOperations();
}
@@ -755,46 +783,24 @@
return statusProgress[item.status] || 0;
}
- getQueueStats() {
- const stats = {};
- this.statuses.forEach(status => {
- stats[status] = 0;
- });
-
- Array.from(this.store.data.values()) // Change items to data
- .forEach(op => {
- if (stats.hasOwnProperty(op.status)) {
- stats[op.status]++;
- }
- });
-
- stats.total = Array.from(this.store.data.values()).length; // Change items to data
-
- return stats;
- }
renderOperations() {
if (!this.ui.itemsContainer) return;
- const activeFilter = this.getActiveFilter();
- const operations = this.getFilteredOperations(activeFilter);
+ const operations = this.store.getFiltered();
// Clear container
window.removeChildren(this.ui.itemsContainer);
- // Render each operation
+ // Render operations or empty state
if (operations.length === 0) {
let empty = window.getTemplate('emptyQueue');
this.ui.itemsContainer.append(empty);
this.a11y.announce('Nothing queued.');
} else {
- let empty = this.ui.itemsContainer.querySelector('.emptyQueue');
- if (empty) {
- empty.remove();
- }
operations.forEach(op => {
const element = this.createOperationUI(op);
- this.ui.itemsContainer.appendChild(element);
+ this.ui.itemsContainer.append(element);
});
}
}
@@ -941,29 +947,18 @@
FILTERS
**************************************************/
setFilter(filter) {
+ // Update active button
Object.values(this.ui.filters).forEach(button => {
if (button) {
button.classList.toggle('active', button.dataset.filter === filter);
}
});
- this.activeFilter = filter;
- this.renderOperations();
- }
-
- getActiveFilter() {
- const activeButton = this.ui.panel?.querySelector('.filter.active');
- return activeButton?.dataset.filter || 'all';
- }
-
- getFilteredOperations(filter) {
- const operations = Array.from(this.store.data.values()); // Change items to data
-
if (filter === 'all') {
- return operations;
+ this.store.clearFilters();
+ } else {
+ this.store.setFilter('status', filter);
}
-
- return operations.filter(op => op.status === filter);
}
/**************************************************************************
@@ -995,9 +990,8 @@
? Array.from(this.store.data.values()).filter((item) => status.includes(item.status))
: Array.from(this.store.data.values()).filter((item) => !status.includes(item.status));
}
- async hasQueuedOperations() {
- const queued = await this.store.query('status', 'queued');
- return queued.length > 0;
+ hasQueuedOperations() {
+ return this.getOperationsByStatus('queued').length > 0;
}
subscribe(callback) {
this.subscribers.add(callback);
diff --git a/assets/js/concise/Referral.js b/assets/js/concise/Referral.js
index 61adcc2..9fe722d 100644
--- a/assets/js/concise/Referral.js
+++ b/assets/js/concise/Referral.js
@@ -29,7 +29,6 @@
};
this.forms = this.container.querySelectorAll('form');
- console.log(this.forms);
this.popup = new window.jvbPopup({
toggle: this.toggle,
popup: this.container,
@@ -281,10 +280,8 @@
* Handle form submission
*/
async handleFormSubmit(event) {
- console.log('Form Submission!');
window.debouncer.cancel('check-referral');
event.preventDefault();
- console.log('Still working?');
const form = event.target;
@@ -299,8 +296,6 @@
try {
let result = {success: false, message: ''};
- console.log(form);
- console.log(form.id);
if (form.id === 'referral-code-form') {
if (!formData.get('name')) {
result.message += 'We need your name to know who you are.';
diff --git a/assets/js/concise/Swiper.js b/assets/js/concise/Swiper.js
new file mode 100644
index 0000000..5b2b3de
--- /dev/null
+++ b/assets/js/concise/Swiper.js
@@ -0,0 +1,154 @@
+class Swiper {
+ constructor() {
+ this.isInitialized = false;
+ this.initSubscribers();
+ this.initHandlers();
+ this.swipe = {
+ startX: null,
+ endX: null,
+ startY: null,
+ endY: null,
+ minSwipe: 50
+ };
+
+ this.pinch = {
+ active: false,
+ startDistance: 0,
+ lastDistance: 0,
+ scale: 1
+ };
+ }
+
+ /*********************************************************************
+ LISTENERS
+ *********************************************************************/
+ initHandlers() {
+ this.touchStartHandler = this.handleTouchStart.bind(this);
+ this.touchMoveHandler = this.handleTouchMove.bind(this);
+ this.touchEndHandler = this.handleTouchEnd.bind(this);
+ }
+ initListeners() {
+ if (this.isInitialized) {
+ return;
+ }
+ this.isInitialized = true;
+ document.addEventListener('touchstart', this.touchStartHandler);
+ document.addEventListener('touchmove', this.touchMoveHandler);
+ document.addEventListener('touchend', this.touchEndHandler);
+ }
+ cleanupListeners() {
+ if (this.subscribers.size > 0) return;
+ this.isInitialized = false;
+ document.removeEventListener('touchstart', this.touchStartHandler);
+ document.removeEventListener('touchmove', this.touchMoveHandler);
+ document.removeEventListener('touchend', this.touchEndHandler);
+ }
+ handleTouchStart(e) {
+ if (e.touches.length === 2) {
+ // Two-finger pinch start
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
+ const distance = Math.sqrt(dx*dx + dy*dy);
+
+ this.pinch.active = true;
+ this.pinch.startDistance = this.pinch.lastDistance = distance;
+ this.notify('pinch-start', { distance });
+
+ return; // Don't treat this as a swipe
+ }
+
+ this.swipe.startX = e.touches[0].clientX;
+ this.swipe.startY = e.touches[0].clientY;
+ }
+ handleTouchMove(e) {
+ if (this.pinch.active && e.touches.length === 2) {
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
+ const distance = Math.sqrt(dx*dx + dy*dy);
+
+ const scale = distance / this.pinch.startDistance;
+
+ this.pinch.lastDistance = distance;
+ this.pinch.scale = scale;
+
+ this.notify('pinch-move', { e, distance, scale });
+
+ // Direction
+ if (distance > this.pinch.startDistance) {
+ this.notify('pinch-out', { scale });
+ } else {
+ this.notify('pinch-in', { scale });
+ }
+
+ return; // Do not fire swipe logic
+ }
+ this.swipe.endX = e.touches[0].clientX;
+ this.swipe.endY = e.touches[0].clientY;
+ }
+ handleTouchEnd(e) {
+ // Finish pinch
+ if (this.pinch.active) {
+ this.notify('pinch-end', {
+ finalScale: this.pinch.scale
+ });
+ this.pinch.active = false;
+ return;
+ }
+
+ if ((!this.swipe.startX || !this.swipe.endX) ||(!this.swipe.startY || !this.swipe.endY)) return;
+
+ const distanceX = this.swipe.startX - this.swipe.endX;
+ const distanceY = this.swipe.startY - this.swipe.endY;
+
+ if (Math.abs(distanceX) > this.swipe.minSwipe) {
+ let direction = distanceX > 0 ? 'swipe-right' : 'swipe-left';
+ this.notify(direction);
+ }
+ if (Math.abs(distanceY) > this.swipe.minSwipe) {
+ let direction = distanceY > 0 ? 'swipe-up' : 'swipe-down';
+ this.notify(direction);
+ }
+ this.swipe.startX = this.swipe.startY = this.swipe.endX = this.swipe.endY = null;
+ }
+ /*********************************************************************
+ SUBSCRIBERS
+ *********************************************************************/
+ initSubscribers() {
+ this.subscribers = new Set();
+ }
+
+ subscribe(callback) {
+ if (!this.isInitialized) {
+ this.initListeners();
+ }
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+ unsubscribe(callback) {
+ this.subscribers.delete(callback);
+ if (this.subscribers.size === 0) {
+ this.cleanupListeners();
+ }
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach( callback => {
+ try {
+ callback(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
+
+ /******************************************************************
+ CLEANUP
+ ******************************************************************/
+
+ destroy() {
+ this.subscribers.clear();
+ this.cleanupListeners();
+ }
+}
+
+window.jvbSwiper = new Swiper();
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 1dcba38..7f27f13 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -12,27 +12,33 @@
this.isInitializing = true;
this.taxonomiesToFetch = new Set();
- this.store = new window.jvbStore({
- name: `taxonomies`,
- storeName: `terms`,
- keyPath: 'id',
- showLoading: false,
- indexes: [
- {name: 'taxonomy', keyPath: 'taxonomy'},
- {name: 'parent', keyPath: 'parent'},
- {name: 'slug', keyPath: 'slug', unique: true},
- {name: 'count', keyPath: 'count'},
- ],
- endpoint: 'terms',
- TTL: 7200000, //2 hours
- filters: {
- taxonomy: '',
- page: 1,
- search: '',
- parent: 0
- },
- required: 'taxonomy'
- });
+ this.triggers = new Set(['.taxonomy-toggle']);
+
+ this.subscribers = new Set();
+
+ this.store = window.jvbStore.register(
+ 'taxonomies',
+ {
+ storeName: `terms`,
+ keyPath: 'id',
+ showLoading: false,
+ indexes: [
+ {name: 'taxonomy', keyPath: 'taxonomy'},
+ {name: 'parent', keyPath: 'parent'},
+ {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'count', keyPath: 'count'},
+ ],
+ endpoint: 'terms',
+ TTL: 2 * 60 * 1000, //2 hours
+ filters: {
+ taxonomy: '',
+ page: 1,
+ search: '',
+ parent: 0
+ },
+ required: 'taxonomy',
+ delayFetch: true,
+ });
// Central field management
this.fields = new Map();
@@ -43,7 +49,6 @@
this.currentConfig = null;
this.currentSingular = null;
this.currentPlural = null;
- this.activeStore = null;
// Modal state
this.disabled = false;
@@ -64,6 +69,9 @@
this.scanExistingFields();
this.initGlobalListeners();
+ if (this.hasAutocomplete && window.jvbTaxCreator) {
+ this.creator = new window.jvbTaxCreator(this);
+ }
this.store.subscribe(this.handleStoreEvent.bind(this));
// Complete initialization
this.isInitializing = false;
@@ -76,11 +84,28 @@
handleStoreEvent(event, data) {
switch (event) {
case 'data-loaded':
- // Only render if modal is open OR if it's an autocomplete request
+ const taxonomy = this.store.filters.taxonomy;
+ // Handle batch taxonomy loading (comma-separated)
+ if (taxonomy?.includes(',')) {
+ this.handleBatchDataLoaded(taxonomy, data);
+ }
+ // Update button states for this taxonomy (or taxonomies)
+ if (taxonomy) {
+ // Handle comma-separated taxonomies from batch fetch
+ const taxonomies = taxonomy.includes(',')
+ ? taxonomy.split(',').map(t => t.trim())
+ : [taxonomy];
+
+ taxonomies.forEach(tax => {
+ this.updateFieldsForTaxonomy(tax);
+ });
+ }
+
+ // Only render if modal is open OR autocomplete active
if (this.modal?.open) {
this.handleTermsLoaded(data);
}
- // Handle autocomplete results
+
if (this.isAutocompleteActive && this.activeField) {
const field = this.fields.get(this.activeField);
const terms = data.data?.items || [];
@@ -91,7 +116,6 @@
break;
case 'filters-changed':
- // Modal UI updates happen here if needed
if (this.modal?.open) {
this.showLoading();
}
@@ -112,10 +136,12 @@
*/
handleTermsLoaded(data) {
this.hideLoading();
- const terms = data.data?.items || [];
- const pagination = data.data?.pagination || {};
+ const terms = this.store.getFiltered(); // Use getFiltered() instead of getFilteredItems()
+ const response = this.store.lastResponse?.page || {};
const isSearch = data.filters?.search && data.filters.search.length > 0;
- const append = data.filters?.page > 1;
+ const append = response.page > 1;
+
+ this.notify('terms-loaded', { terms, filters: data.filters });
if (terms.length === 0) {
if (!append) {
@@ -124,9 +150,9 @@
this.observer.unobserve(this.ui.sentinel);
} else {
this.renderTerms(terms, append, isSearch);
- this.currentTerms = terms;
+
// Handle pagination
- if (pagination.has_more) {
+ if (response.has_more) {
this.observer.observe(this.ui.sentinel);
} else {
this.observer.unobserve(this.ui.sentinel);
@@ -154,28 +180,45 @@
}
}
+
+ /**
+ * Check if taxonomy has terms and update button states
+ */
+ updateFieldButtonState(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ // Check store for items of this specific taxonomy
+ const hasTerms = Array.from(this.store.data.values())
+ .some(term => term.taxonomy === field.taxonomy);
+
+ if (field.toggle) {
+ field.toggle.disabled = !hasTerms && !field.canCreate;
+ field.toggle.title = !hasTerms
+ ? `No ${this.getSingular(field.taxonomy)} available`
+ : `Select ${this.getPlural(field.taxonomy)}`;
+ }
+ }
/**
* Update fields when taxonomy items are updated
*/
- updateFieldsForTaxonomy(taxonomy, items) {
- this.fields.forEach(field => {
- if (field.taxonomy === taxonomy && field.selectedTerms.size > 0) {
- // Update display with fresh term data
- field.selectedTerms.forEach(termId => {
- const term = items.find(item => item.id === termId);
- if (term) {
- const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`);
- if (selectedItem) {
- selectedItem.dataset.path = term.path;
- selectedItem.querySelector('span').textContent = term.path;
- }
- }
- });
- }
+ updateFieldsForTaxonomy(taxonomy) {
+ this.getFieldsForTaxonomy(taxonomy).forEach(field => {
+ this.updateFieldButtonState(field.id);
});
}
/**
+ * Get fields for a specific taxonomy
+ */
+ getFieldsForTaxonomy(taxonomy) {
+ return Array.from(this.fields.values())
+ .filter(field => field.taxonomy === taxonomy);
+ }
+
+
+
+ /**
* Scan page for existing taxonomy fields and register them
*/
scanExistingFields(container = null) {
@@ -203,14 +246,18 @@
registerField(field, options = {}) {
let input = field.querySelector('input[type=hidden]');
if (!input) {
- return;
+ return false;
}
-
if (!('fieldId' in field.dataset)) {
field.dataset.fieldId = this.createFieldId(field);
}
let fieldId = field.dataset.fieldId;
- let button = field.querySelector('button.taxonomy-toggle');
+
+ let button = (Object.hasOwn(options, 'button')) ? options.button : field.querySelector('button.taxonomy-toggle');
+
+ if (Object.hasOwn(options, 'buttonSelector')) {
+ this.triggers.add(options.buttonSelector);
+ }
let config = {
id: fieldId,
@@ -226,7 +273,7 @@
isRequired: 'required' in button.dataset,
selectedTerms: new Set(),
toggle: button,
- selectedContainer: field.querySelector('.selected-items'),
+ selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'),
...options
};
@@ -244,13 +291,19 @@
selectedIds.forEach(id => config.selectedTerms.add(id));
}
+ if (Object.hasOwn(options, 'selectedItems')) {
+ options.selectedItems.forEach(id => {
+ config.selectedTerms.add(id);
+ });
+ }
+
this.fields.set(fieldId, config);
// Ensure store exists for this taxonomy
if (this.isInitializing) {
this.taxonomiesToFetch.add(config.taxonomy);
} else {
- this.store.setFilter('taxonomy', config.taxonomy);
+ // this.store.setFilter('taxonomy', config.taxonomy);
}
// Initialize display for any pre-selected values
@@ -262,32 +315,43 @@
}
/**
- * Batch fetch all unique taxonomies collected during init
+ * Register a filter button (simplified registration for feed blocks)
*/
- async batchFetchTaxonomies() {
- if (this.taxonomiesToFetch.size === 0) return;
+ registerFilterButton(button, options = {}) {
+ const fieldId = this.createFieldId(button);
+ button.dataset.fieldId = fieldId;
- const taxonomies = Array.from(this.taxonomiesToFetch);
- this.taxonomiesToFetch.clear();
-
- console.log(`Batch fetching ${taxonomies.length} unique taxonomies:`, taxonomies);
-
- // Fetch each taxonomy sequentially (cache will prevent duplicates)
- for (const taxonomy of taxonomies) {
- await this.store.setFilters({
- taxonomy: taxonomy,
- page: 1,
- search: '',
- parent: 0
- });
+ if (options.buttonSelector) {
+ this.triggers.add(options.buttonSelector);
}
- // Now initialize field displays
- this.fields.forEach((config, fieldId) => {
- if (config.selectedTerms.size > 0) {
- this.initFieldDisplay(fieldId);
- }
- });
+ const config = {
+ id: fieldId,
+ input: null,
+ container: options.container || button.closest('.filters') || button.parentElement,
+ taxonomy: button.dataset.taxonomy,
+ name: `filter_${button.dataset.taxonomy}`,
+ maxSelection: parseInt(button.dataset.max) || 0,
+ canSearch: 'search' in button.dataset,
+ hasAutocomplete: false,
+ canCreate: false,
+ isRequired: false,
+ selectedTerms: new Set(options.selectedItems || []),
+ toggle: button,
+ selectedContainer: options.selected || null,
+ isFilterMode: true,
+ ...options
+ };
+
+ this.fields.set(fieldId, config);
+
+ if (this.isInitializing) {
+ this.taxonomiesToFetch.add(config.taxonomy);
+ } else {
+ this.store.setFilter('taxonomy', config.taxonomy);
+ }
+
+ return fieldId;
}
/**
@@ -306,21 +370,13 @@
if (!field || field.selectedTerms.size === 0) return;
const selectedIds = Array.from(field.selectedTerms);
- const cachedTerms = [];
selectedIds.forEach(termId => {
- const term = this.store.data.get(termId);
+ const term = this.store.get(termId); // Changed from getItem
if (term) {
- cachedTerms.push(term);
+ this.addTermToDisplay(fieldId, term.id, term.name, term.path);
}
});
-
- // Display all found terms
- cachedTerms.forEach(term => {
- this.addTermToDisplay(fieldId, term.id, term.name, term.path);
- });
-
- // Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
}
/**
@@ -346,7 +402,6 @@
this.modalInstance.subscribe((event, data) => {
switch (event) {
case 'modal-open':
- console.log(data);
this.openModal(data);
break;
case 'modal-close':
@@ -401,7 +456,7 @@
// Initialize intersection observer for infinite scroll
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
- if (entry.isIntersecting && this.activeStore) {
+ if (entry.isIntersecting) {
this.loadMoreTerms();
}
});
@@ -424,10 +479,23 @@
initAutocomplete()
{
- console.log('Autocomplete init');
this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
document.addEventListener('input', this.autocompleteHandler);
document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
+ // Preload taxonomy data on focus
+ document.addEventListener('focus', (e) => {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ // Preload this taxonomy's data
+ this.preloadTaxonomy(field.taxonomy);
+ }, true); // Use capture phase
}
/**
@@ -435,7 +503,8 @@
*/
handleClick(e) {
// Handle taxonomy toggle buttons
- const toggleButton = window.targetCheck(e, '.taxonomy-toggle');
+ const toggleButton = window.targetCheck(e, Array.from(this.triggers));
+
if (toggleButton) {
e.preventDefault();
this.handleToggleClick(toggleButton);
@@ -496,65 +565,53 @@
return;
}
- this.setActiveField(fieldId);
- this.modalInstance.handleOpen();
+
+ this.setActiveField(fieldId, true);
} catch (error) {
console.error('Error handling toggle click:', error);
- this.error?.handleError(error, {
- component: 'TaxonomySelector',
- action: 'handleToggleClick'
- });
+ if (this.error?.log) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'handleToggleClick'
+ });
+ }
}
}
/**
* Set the active field for modal operations
*/
- setActiveField(fieldId) {
+ setActiveField(fieldId, openModal = false) {
this.activeField = fieldId;
this.currentConfig = this.fields.get(fieldId);
- this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
- this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
+ this.currentSingular = this.getSingular(this.currentConfig.taxonomy);
+ this.currentPlural = this.getPlural(this.currentConfig.taxonomy);
- // Get or create store for this taxonomy
+ if (openModal) {
+ this.modalInstance.handleOpen();
+ }
+
+ // Set taxonomy filter - store handles the rest
this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
// Clear modal selection state
this.selectedTerms.clear();
// Copy field's current selections to modal state
- if (this.currentConfig.selectedTerms) {
- let termsToFetch = [];
- this.currentConfig.selectedTerms.forEach(termId => {
- const term = this.store.getItem(termId);
- if (term) {
- this.selectedTerms.set(termId, {
- id: termId,
- name: term.name,
- path: term.path
- });
- } else {
- termsToFetch.push(termId);
- }
- });
- if (termsToFetch.length > 0) {
- let terms = this.fetchSpecificTerms(termsToFetch);
- terms.forEach(term => {
- this.selectedTerms.set(term.id, {
- id: term.id,
- name: term.name,
- path: term.path
- });
+ this.currentConfig.selectedTerms.forEach(termId => {
+ const term = this.store.get(termId);
+ if (term) {
+ this.selectedTerms.set(termId, {
+ id: termId,
+ name: term.name,
+ path: term.path
});
}
- }
+ });
}
- fetchSpecificTerms(terms) {
- return [];
- }
/**
* Handle clicks within modal
@@ -627,43 +684,70 @@
filterCallback: callback // Store the callback
});
- this.setActiveField(virtualFieldId);
+ this.setActiveField(virtualFieldId, true);
this.modalInstance.handleOpen();
}
/**
* Open modal and initialize
*/
- openModal(config) {
- this.activeField = config.fieldId;
- this.currentConfig = config;
+ openModal() {
+ if (!this.currentConfig) {
+ console.error('No active field set');
+ return;
+ }
+
// Initialize creator if available
- if (config.canCreate && 'jvbTaxCreator' in window) {
+ if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
this.creator = new window.jvbTaxCreator(this);
- } else if (this.creator) {
- delete this.creator;
}
- // Load selected terms into modal state
- this.selectedTerms = new Set(config.selectedTerms);
+ // Update modal UI
+ this.updateModalForTaxonomy();
- // Only fetch if taxonomy changed
- const currentTaxonomy = this.store.filters.taxonomy;
- if (currentTaxonomy !== config.taxonomy) {
- this.store.setFilters({
- taxonomy: config.taxonomy,
- page: 1,
- search: '',
- parent: 0
- });
- }
-
- // Reset UI
- window.removeChildren(this.ui.termsList);
- this.ui.search.value = '';
+ // Load selected terms display
+ this.updateModalSelections();
this.updateSelectionCount();
- this.modalInstance.open();
+ // Clear terms list and show loading
+ window.removeChildren(this.ui.termsList);
+ this.showLoading();
+ }
+
+ /**
+ * Update selection count display in modal
+ */
+ updateSelectionCount() {
+ if (!this.currentConfig) return;
+
+ const count = this.selectedTerms.size;
+ const max = this.currentConfig.maxSelection;
+
+ // Update any count display elements
+ const countElement = this.modal?.querySelector('.selection-count');
+ if (countElement) {
+ if (max > 0) {
+ countElement.textContent = `${count} of ${max} selected`;
+ } else {
+ countElement.textContent = `${count} selected`;
+ }
+ }
+ }
+
+
+
+ /**
+ * Get singular label for taxonomy
+ */
+ getSingular(taxonomy) {
+ return jvbSettings.labels[taxonomy]?.single || taxonomy;
+ }
+
+ /**
+ * Get plural label for taxonomy
+ */
+ getPlural(taxonomy) {
+ return jvbSettings.labels[taxonomy]?.plural || taxonomy;
}
/**
@@ -673,12 +757,17 @@
this.observer.unobserve(this.ui.sentinel);
window.removeChildren(this.ui.termsList);
+ this.notify('selected-terms', {
+ terms: this.selectedTerms,
+ taxonomy: this.currentConfig.taxonomy
+ });
+
if (this.currentConfig?.isFilterMode) {
if (this.currentConfig.filterCallback) {
const selectedIds = Array.from(this.selectedTerms.keys());
this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
}
- this.fields.delete(this.activeField);
+ // this.fields.delete(this.activeField);
} else if (this.activeField) {
this.saveSelectionsToField(this.activeField);
}
@@ -688,7 +777,7 @@
this.ui.search.input.removeEventListener('input', this.searchHandler);
}
- if (this.creator) {
+ if (!this.hasAutocomplete && this.creator) {
delete this.creator;
}
@@ -1087,7 +1176,7 @@
* Navigate to parent term
*/
navigateToParent() {
- // Single call instead of two setFilter + manual fetch
+ // Store handles fetch automatically
this.store.setFilters({
parent: 0,
page: 1
@@ -1101,7 +1190,7 @@
* Navigate to child term
*/
navigateToChild(termId, termName) {
- // Single call - auto-fetches
+ // Store handles fetch automatically
this.store.setFilters({
parent: termId,
page: 1
@@ -1118,7 +1207,7 @@
navigateToPath(pathLevel) {
const parentId = parseInt(pathLevel.dataset.id) || 0;
- // Single call - auto-fetches
+ // Store handles fetch automatically
this.store.setFilters({
parent: parentId,
page: 1
@@ -1132,17 +1221,19 @@
* Load more terms (pagination)
*/
loadMoreTerms() {
- if (!this.activeStore) return;
-
- const currentPage = this.activeStore.filters.page || 1;
+ const currentPage = this.store.filters.page || 1;
this.store.setFilter('page', currentPage + 1);
-
}
/**
* Render terms list
*/
- renderTerms(terms, append = false, showPath = false) {
+ renderTerms(terms = null, append = false, showPath = false) {
+ // If no terms provided, get from store
+ if (!terms) {
+ terms = this.store.getFiltered();
+ }
+
if (!append) {
window.removeChildren(this.ui.termsList);
}
@@ -1154,10 +1245,10 @@
return;
}
- // Use this.store instead of this.activeStore
const currentParent = this.store.filters.parent || 0;
this.ui.breadcrumbs.back.hidden = currentParent === 0;
+ const fragment = document.createDocumentFragment();
terms.forEach(term => {
const element = this.createTermElement({
id: parseInt(term.id),
@@ -1168,9 +1259,11 @@
});
if (element) {
- this.ui.termsList.appendChild(element);
+ fragment.appendChild(element);
}
});
+
+ this.ui.termsList.appendChild(fragment);
}
/**
@@ -1314,6 +1407,117 @@
return null;
}
+ /********************************************
+ BATCH FETCH: fetches first page for all taxonomies in one call
+ ********************************************/
+ async batchFetchTaxonomies() {
+ if (this.taxonomiesToFetch.size === 0) return;
+
+ const taxonomies = Array.from(this.taxonomiesToFetch);
+ this.taxonomiesToFetch.clear();
+
+ // Single fetch - the data-loaded event will handle cache splitting
+ this.store.setFilters({
+ taxonomy: taxonomies.join(','),
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+ handleBatchDataLoaded(taxonomyString, data) {
+ const taxonomies = taxonomyString.split(',').map(t => t.trim());
+ const storeInstance = this.store.getStore(); // Access actual store instance
+
+ taxonomies.forEach(taxonomy => {
+ const filters = {
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ };
+
+ // Use the internal generateCacheKey method via store instance
+ const cacheKey = this.generateCacheKeyForFilters(filters);
+
+ // Filter items for this specific taxonomy
+ const items = Array.from(this.store.data.values())
+ .filter(item => item.taxonomy === taxonomy)
+ .map(item => item.id);
+
+ const cacheEntry = {
+ key: cacheKey,
+ items: items,
+ timestamp: Date.now(),
+ endpoint: storeInstance.config.endpoint,
+ filters: filters
+ };
+
+ // Set in both memory and IndexedDB cache
+ storeInstance.cache.set(cacheKey, cacheEntry);
+
+ // Persist to IndexedDB (if available)
+ if (storeInstance.db?.objectStoreNames.contains('cache')) {
+ const tx = storeInstance.db.transaction(['cache'], 'readwrite');
+ const objectStore = tx.objectStore('cache');
+ objectStore.put(cacheEntry);
+ }
+
+ // Update button states for this taxonomy
+ this.updateFieldsForTaxonomy(taxonomy);
+ });
+
+ // Initialize field displays
+ this.fields.forEach((config, fieldId) => {
+ if (config.selectedTerms.size > 0) {
+ this.initFieldDisplay(fieldId);
+ }
+ });
+ }
+
+ /**
+ * Generate cache key for given filters (matching DataStore's internal logic)
+ */
+ generateCacheKeyForFilters(filters) {
+ const normalized = Object.keys(filters)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = filters[key];
+ return acc;
+ }, {});
+
+ return JSON.stringify(normalized);
+ }
+
+ /**
+ * Preload taxonomy data on hover
+ */
+ async preloadTaxonomy(taxonomy) {
+ // Trigger fetch for this taxonomy
+ this.store.setFilters({
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+ /*****************************************
+ SUBSCRIBERS
+ *****************************************/
+
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach( callback => {
+ try {
+ callback(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
/**
* Clean up
@@ -1329,6 +1533,7 @@
// Destroy all stores
this.store.destroy();
+ this.subscribers.clear();
// Clear all maps
this.fields.clear();
this.selectedTerms.clear();
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index b59101c..59a0d5f 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -1,42 +1,51 @@
+/**
+ * UploadManager - Refactored for clarity
+ *
+ * Architecture:
+ * - DataStores (fieldStore, uploadStore) = Recovery cache only, cleared after successful upload
+ * - Maps (uploadElements, fieldElements) = Runtime DOM references
+ * - Upload data flows: File → Process → Queue → Server → Clean up stores
+ */
class UploadManager {
constructor() {
- //Load dependencies
+ // Load dependencies
this.queue = window.jvbQueue;
this.a11y = window.jvbA11y;
this.error = window.jvbError;
- //Load Datastore
- this.fieldStore = new window.jvbStore({
- name: 'upload_fields',
- storeName: 'fieldStates',
- keyPath: 'id',
- version: 2,
+ // RECOVERY STORES - Cleared after successful upload
+ this.fieldStore = window.jvbStore.register(
+ 'upload_fields',
+ {
+ storeName: 'fieldStates',
+ keyPath: 'id',
+ version: 2,
+ indexes: [
+ { name: 'fieldId', keyPath: 'fieldId' },
+ { name: 'timestamp', keyPath: 'timestamp' },
+ { name: 'content', keyPath: 'content' },
+ { name: 'itemId', keyPath: 'itemId' },
+ { name: 'status', keyPath: 'status' }
+ ],
+ TTL: 86400000 * 7, // 1 week
+ delayFetch: true
+ });
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'timestamp', keyPath: 'timestamp' },
- { name: 'content', keyPath: 'content' },
- { name: 'itemId', keyPath: 'itemId' },
- { name: 'status', keyPath: 'status' }
- ],
-
- stripDOMReferences: true,
- TTL: 86400000*7 // 24 hours -> 1 week
- });
-
- this.uploadStore = new window.jvbStore({
- name: 'uploads',
- storeName: 'uploads',
- keyPath: 'id',
- storeBlobs: true,
-
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'status', keyPath: 'status' },
- { name: 'groupId', keyPath: 'groupId' },
- { name: 'attachmentId', keyPath: 'attachmentId' }
- ],
- });
+ this.uploadStore = window.jvbStore.register(
+ 'uploads',
+ {
+ name: 'uploads',
+ storeName: 'uploads',
+ keyPath: 'id',
+ storeBlobs: true,
+ indexes: [
+ { name: 'fieldId', keyPath: 'fieldId' },
+ { name: 'status', keyPath: 'status' },
+ { name: 'groupId', keyPath: 'groupId' },
+ { name: 'attachmentId', keyPath: 'attachmentId' }
+ ],
+ delayFetch: true
+ });
window.jvbUploadBlobs = this.uploadStore;
@@ -44,20 +53,24 @@
this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
- //Load Worker
- this.initWorker();
+ // RUNTIME DATA - DOM references and ephemeral state
+ this.uploadElements = new Map(); // uploadId → { element, preview, location }
+ this.fieldElements = new Map(); // fieldId → { element, ui, config }
+ this.groupElements = new Map(); // groupId → { element, grid, fieldId }
- // Core data structures
- this.fields = new Map();
- this.uploads = new Map();
- this.groups = new Map();
+ // Selection and UI state
this.selected = new Map();
this.selectionHandlers = new Map();
this.previewUrls = new Set();
- //Notification and Subscribers
+ this.sortableInstances = new Map();
+
+ // Worker for image processing
+ this.initWorker();
+
+ // Notification subscribers
this.subscribers = new Set();
- // Controllers (will be initialized based on features)
+ // Controllers
this.dragController = null;
// Selectors
@@ -65,7 +78,6 @@
field: {
field: '[data-upload-field]',
input: 'input[type="file"]',
- hiddenValue: 'input[type="hidden"]',
dropZone: '.file-upload-container',
preview: '.item-grid.preview',
progress: '.image-progress'
@@ -86,7 +98,6 @@
}
};
-
this.statusMapping = {
'received': 'Image Received',
'local_processing': 'Processing Image...',
@@ -100,66 +111,48 @@
};
// Sortable configuration
- this.sortableInstances = new Map();
this.sortableConfig = {
animation: 150,
draggable: '.item',
- handle: '.select-item-label, img', // Can drag by image or checkbox label
+ handle: '.select-item, img',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
- onEnd: (evt) => {
- this.handleReorder(evt);
- }
+ onEnd: (evt) => this.handleReorder(evt)
};
this.init();
}
async init() {
- // Load existing data
- await this.loadFields();
- await this.loadUploads();
- // Initialize fields
this.initializeFields();
-
- // Set up core listeners
this.initListeners();
+ // Queue integration - handle completion/failure
this.queue.subscribe((event, operation) => {
- if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
+ if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
return;
}
+
const fieldId = operation.data instanceof FormData
? operation.data.get('fieldId')
- : operation.data.fieldId;
+ : operation.data?.fieldId;
+
switch(event) {
case 'cancel-operation':
- if (fieldId) {
- this.clearField(fieldId);
- }
+ if (fieldId) this.handleOperationCancelled(fieldId);
break;
case 'operation-status':
- if (fieldId) {
- this.updateFieldStatus(fieldId, operation.status);
- }
+ if (fieldId) this.updateFieldStatus(fieldId, operation.status);
break;
case 'operation-complete':
- const results = operation.result?.data || [];
- results.forEach(result => {
- const upload = this.uploads.get(result.upload_id);
- if (upload) {
- upload.attachmentId = result.attachment_id;
- upload.status = 'completed';
- this.uploads.set(upload.id, upload);
- }
- });
- if (fieldId) {
- this.cleanField(fieldId);
- }
+ this.handleOperationComplete(operation, fieldId);
+ break;
+ case 'operation-failed':
+ case 'operation-failed-permanent':
+ this.handleOperationFailed(operation, fieldId);
break;
}
-
});
window.addEventListener('beforeunload', () => {
@@ -172,12 +165,9 @@
worker: null,
timeout: null,
tasks: new Map(),
- restart: {
- count: 0,
- max: 3,
- },
+ restart: { count: 0, max: 3 },
settings: {
- timeout: 10000, //10 seconds per image
+ timeout: 10000,
batchSize: 1,
maxConcurrent: 3,
restartAfterTimeout: true
@@ -185,71 +175,68 @@
};
}
- /**
- * Initialize all upload fields on the page
- */
+ /*******************************************************************************
+ * FIELD MANAGEMENT
+ *******************************************************************************/
+
initializeFields() {
const fields = document.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
+ fields.forEach(uploader => this.registerUploader(uploader));
}
scanFields(container) {
const fields = container.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
+ fields.forEach(uploader => this.registerUploader(uploader));
}
registerUploader(uploader) {
const fieldId = this.determineFieldId(uploader);
const config = this.extractFieldConfig(uploader);
+ const ui = this.buildFieldUI(uploader);
- // Create field data structure
- const field = {
+ // Store field data with Sets for runtime
+ const fieldData = {
id: fieldId,
config: config,
- element: uploader,
- ui: this.buildFieldUI(uploader),
uploads: new Set(),
- groups: new Set(),
+ groups: [],
state: 'ready',
+ timestamp: Date.now()
};
- this.fields.set(fieldId, field);
+ // Save to store (will convert Sets to Arrays automatically)
+ this.fieldStore.save(fieldData);
+
+ // Store DOM references separately
+ this.fieldElements.set(fieldId, { element: uploader, ui, config });
+
uploader.dataset.uploader = fieldId;
this.addFieldSelectionHandler(fieldId);
if (config.destination === 'post_group' && !this.dragController) {
this.initGroupFeatures();
}
+
if (config.type !== 'single') {
- this.initSortable(field);
+ this.initSortable(fieldId);
}
return fieldId;
}
- /**
- * Extract configuration from field element
- */
extractFieldConfig(fieldElement) {
return {
destination: fieldElement.dataset.destination || 'meta',
content: fieldElement.dataset.content || null,
mode: fieldElement.dataset.mode || 'direct',
type: fieldElement.dataset.type || 'single',
- name: fieldElement.dataset.field, // Field name for meta
- itemID: fieldElement.dataset.itemId || 0, // Post/term/user ID
+ name: fieldElement.dataset.field,
+ itemID: fieldElement.dataset.itemId || 0,
maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
subtype: fieldElement.dataset.subtype || 'image'
};
}
- /**
- * Build UI element references for a field
- */
buildFieldUI(fieldElement) {
let UI = {
field: fieldElement,
@@ -279,92 +266,129 @@
return UI;
}
- /**
- * Set up core event listeners
- */
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
+ /*******************************************************************************
+ * SORTABLE INITIALIZATION
+ *******************************************************************************/
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
+ initSortable(fieldId) {
+ if (!window.Sortable) return;
- // External file drops
- this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
- this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
- this.dragOverHandler = this.handleExternalDragOver.bind(this);
- this.dropHandler = this.handleExternalDrop.bind(this);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl) return;
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('drop', this.dropHandler);
+ // Find all sortable grids (preview + group grids, but not restore grids)
+ const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
+
+ grids.forEach((grid) => {
+ // Skip if already initialized
+ if (grid.sortableInstance) return;
+
+ const isGroupGrid = grid.classList.contains('group');
+ const gridId = isGroupGrid
+ ? `${fieldId}-group-${grid.closest('.upload-group')?.dataset.groupId}`
+ : `${fieldId}-preview`;
+
+ const sortableInstance = new Sortable(grid, {
+ ...this.sortableConfig,
+ group: {
+ name: fieldId,
+ pull: true,
+ put: true
+ },
+
+ onAdd: (evt) => {
+ // Re-enable when items added
+ this.updateSortableState(evt.to);
+ },
+ onRemove: (evt) => {
+ // Disable source if now empty
+ this.updateSortableState(evt.from);
+ }
+ });
+
+ // Store reference on element for easy access
+ grid.sortableInstance = sortableInstance;
+ this.sortableInstances.set(gridId, sortableInstance);
+ });
}
/**
- * Initialize group-specific features (drag & drop for rearranging)
+ * Update sortable enabled/disabled state based on item count
*/
+ updateSortableState(grid) {
+ const sortable = grid?.sortableInstance;
+ if (!sortable) return;
+
+ const hasItems = grid.querySelectorAll('.item').length > 0;
+ sortable.option('disabled', !hasItems);
+ }
+
+ /**
+ * Refresh sortable for a field (call after adding/removing items dynamically)
+ */
+ refreshSortable(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl) return;
+
+ const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
+ grids.forEach(grid => this.updateSortableState(grid));
+ }
+
+ handleReorder(evt) {
+ const grid = evt.to;
+ const fieldWrapper = grid.closest('.field, .upload');
+ if (!fieldWrapper) return;
+
+ let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
+ let items = Array.from(fieldWrapper.querySelectorAll('.item')).map(upload => upload.dataset.id);
+ console.log(items);
+ hiddenInput.value = items.join(',');
+
+ if (window.jvbA11y) {
+ window.jvbA11y.announce('Item reordered');
+ }
+
+ fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
+ detail: { from: evt.from, to: evt.to, oldIndex: evt.oldIndex, newIndex: evt.newIndex },
+ bubbles: true
+ }));
+ }
+
+ /*******************************************************************************
+ * DRAG & DROP INITIALIZATION
+ *******************************************************************************/
+
initGroupFeatures() {
- // Initialize drag controller for rearranging items
this.dragController = new window.jvbDragHandler({
- // What can be dragged
draggableSelector: this.selectors.items.item,
-
- // Where items can be dropped
dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,
-
- // Don't start drag on interactive elements
ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a',
previewElement: 'img, video, .icon',
-
- // Extract upload ID from element
- getItemId: (element) => {
- return element.dataset.uploadId;
- },
-
- // Get selected items for multi-drag
+ getItemId: (element) => element.dataset.uploadId,
getSelectedItems: (element) => {
const fieldId = this.getFieldIdFromElement(element);
const uploadId = element.dataset.uploadId;
const selected = this.getCurrentSelection(fieldId);
-
- if (selected && selected.includes(uploadId)) {
- return selected;
- }
-
- return [uploadId];
+ return (selected && selected.includes(uploadId)) ? selected : [uploadId];
},
-
- // Validate drop location
validateDrop: (itemIds, targetElement) => {
const targetFieldId = this.getFieldIdFromElement(targetElement);
const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
const itemFieldId = this.getFieldIdFromElement(itemElement);
-
return targetFieldId === itemFieldId;
},
-
- // Handle successful drop
onDrop: (itemIds, targetElement) => {
this.handleItemDrop(itemIds, targetElement);
targetElement.scrollIntoView({behavior:'smooth', block:'center'});
},
-
- // Optional callbacks
- onDragStart: (itemIds) => {
- },
-
onDragEnd: (itemIds, success) => {
if (success) {
- // Clear selection after successful move
const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
const fieldId = this.getFieldIdFromElement(itemElement);
const handler = this.selectionHandlers.get(fieldId);
handler?.clearSelection();
}
},
-
- // Preview options
previewOptions: {
multiOffset: { x: -60, y: -80 },
singleOffset: { x: -50, y: -60 },
@@ -373,93 +397,38 @@
});
}
- initSortable(field) {
- if (!window.Sortable) return;
-
- // Main grid
- const mainGrid = field.element.querySelector('.item-grid:not(.group)');
- if (mainGrid) {
- this.sortableInstances.set(`${field.id}-main`,
- new Sortable(mainGrid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- }
-
- // Group grids (for selection mode with grouping)
- const groupGrids = field.element.querySelectorAll('.item-grid.group');
- groupGrids.forEach((grid, index) => {
- this.sortableInstances.set(`${field.id}-group-${index}`,
- new Sortable(grid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- });
- }
-
-// Add reorder handler
- handleReorder(evt) {
- const grid = evt.to;
- const fieldWrapper = grid.closest('.field, .upload');
- if (!fieldWrapper) return;
-
- const form = fieldWrapper.closest('form');
- if (!form) return;
-
- // Get form config if available
- const formId = form.dataset.formId;
- if (formId && window.jvbForms) {
- const formConfig = window.jvbForms.forms?.get(formId);
- if (formConfig?.options.autosave) {
- // Trigger autosave after reordering
- window.jvbForms.scheduleSave(formConfig, 1000);
- }
- }
-
- // Announce for accessibility
- if (window.jvbA11y) {
- window.jvbA11y.announce('Item reordered');
- }
-
- // Trigger custom event
- fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
- detail: {
- from: evt.from,
- to: evt.to,
- oldIndex: evt.oldIndex,
- newIndex: evt.newIndex
- },
- bubbles: true
- }));
- }
-
/*******************************************************************************
- * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
+ * FILE DROP HANDLERS
*******************************************************************************/
+ 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 dropZone = e.target.closest(this.selectors.field.dropZone);
if (dropZone && !dropZone.contains(e.relatedTarget)) {
dropZone.classList.remove('dragover');
}
}
+
handleExternalDragEnter(e) {
- if (!e.dataTransfer.types.includes('Files')) {
- return;
- }
-
+ if (!e.dataTransfer.types.includes('Files')) return;
const dropZone = e.target.closest(this.selectors.field.dropZone);
-
if (dropZone) {
e.preventDefault();
dropZone.classList.add('dragover');
@@ -468,7 +437,6 @@
handleExternalDragOver(e) {
if (!e.dataTransfer.types.includes('Files')) return;
-
const dropZone = e.target.closest(this.selectors.field.dropZone);
if (dropZone) {
e.preventDefault();
@@ -478,33 +446,25 @@
handleExternalDrop(e) {
const dropZone = e.target.closest(this.selectors.field.dropZone);
-
if (!dropZone) return;
e.preventDefault();
dropZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
-
if (files.length === 0) return;
const fieldId = this.getFieldIdFromElement(dropZone);
-
if (fieldId) {
this.processFiles(fieldId, files);
this.a11y.announce(`${files.length} file(s) dropped for upload`);
- } else {
- console.error('No field ID found for drop zone');
}
}
/*******************************************************************************
- * ITEM DROP HANDLER (for rearranging existing uploads)
+ * ITEM DROP HANDLER (for rearranging)
*******************************************************************************/
- /**
- * Handle items being dropped (called by DragController)
- */
handleItemDrop(itemIds, targetElement) {
const isPreviewDrop = targetElement.classList.contains('preview');
let actualTarget = targetElement;
@@ -513,43 +473,32 @@
if (targetElement.classList.contains('empty-group')) {
const fieldId = this.getFieldIdFromElement(targetElement);
const group = this.createGroup(fieldId);
-
- if (!group) {
- console.error('Failed to create group');
- return;
- }
-
+ if (!group) return;
actualTarget = group.grid;
}
- // Move each item to target
+ // Move each item
itemIds.forEach(uploadId => {
if (isPreviewDrop) {
- // Moving back to preview (ungrouping)
this.removeFromGroup(uploadId);
} else {
- // Moving to a group
this.addToGroup(uploadId, actualTarget);
}
});
- // Persist state
const fieldId = this.getFieldIdFromElement(targetElement);
this.schedulePersistance(fieldId);
- // Announce for accessibility
- const message = itemIds.length > 1
- ? `Moved ${itemIds.length} items`
- : 'Moved item';
+ const message = itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Moved item';
this.a11y.announce(message);
}
/*******************************************************************************
- * CLICK HANDLERS
+ * CLICK & CHANGE HANDLERS
*******************************************************************************/
handleClick(e) {
- // File input triggers
+ // Trigger file input
if (e.target.matches(this.selectors.field.dropZone) ||
e.target.closest(this.selectors.field.dropZone)) {
const dropZone = e.target.closest(this.selectors.field.dropZone);
@@ -568,11 +517,10 @@
handleChange(e) {
const fieldId = this.getFieldIdFromElement(e.target);
+
// File input change
if (e.target.matches(this.selectors.field.input)) {
- const fieldId = this.getFieldIdFromElement(e.target);
const files = Array.from(e.target.files);
-
if (files.length > 0 && fieldId) {
this.processFiles(fieldId, files);
}
@@ -580,7 +528,8 @@
// Meta field changes
if (fieldId) {
- if (this.fields.get(fieldId).config.destination === 'post_group') {
+ const fieldData = this.getFieldData(fieldId);
+ if (fieldData?.config.destination === 'post_group') {
this.handleGroupMetaChange(e.target);
} else {
this.queueUploadMeta(e);
@@ -588,363 +537,39 @@
}
}
- /********************************************************************************
- UTILITY
- ********************************************************************************/
- 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;
- }
-
- getSubtypeFromMime(mimeType) {
- if (mimeType.startsWith('image/')) return 'image';
- if (mimeType.startsWith('video/')) return 'video';
- return 'document';
- }
-
- getStatusText(status) {
- return this.statusMapping[status] || status;
- }
-
- getStatusIcon(status) {
- return window.getIcon(this.queue.icons[status]);
- }
- getStatusProgress(status) {
- switch (status) {
- case 'local_processing':
- return 28;
- case 'queued':
- return 50;
- case 'uploading':
- return 66;
- case 'pending':
- return 75;
- case 'processing':
- return 89;
- case 'completed':
- return 100;
- default:
- return 0;
- }
- }
-
- getModalType(field) {
- // Return cached value if available
- if (field._cachedModalType !== undefined) {
- return field._cachedModalType;
- }
-
- // Safety check for field.element
- if (!field || !field.element) {
- field._cachedModalType = null;
- return null;
- }
-
- const dialog = field.element.closest('dialog');
- if (!dialog) {
- field._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;
-
- // Cache the result
- field._cachedModalType = modalType;
- return modalType;
- }
- /*******************************************************************************
- * GROUP ACTIONS
- *******************************************************************************/
-
- handleAction(button) {
- const action = button.dataset.action;
- const fieldId = this.getFieldIdFromElement(button);
- switch(action) {
- case 'add-to-group':
- this.handleAddToGroup(button);
- break;
- case 'delete-group':
- this.handleDeleteGroup(button);
- break;
- case 'delete-upload':
- case 'remove-from-group':
- this.handleRemoveItem(button);
- break;
- case 'upload':
- //upload groups
- let field = this.fields.get(fieldId);
- field.element.closest('details').open = false;
- document.body.classList.add('uploading');
-
- this.submitUploads(fieldId);
- break;
- case 'restore':
- this.handleRestoreUploads().then(()=>{});
- break;
- case 'clear-cache':
- if (!confirm(`Save these uploads for later?`)) {
- this.cleanupStoredUploads();
- }
- this.cleanupRestore();
- break;
- }
- }
-
- handleAddToGroup(button) {
- const fieldElement = button.closest(this.selectors.field.field);
- const fieldId = fieldElement?.dataset.uploader;
-
- if (!fieldId) return;
-
- const selected = this.selected.get(fieldId);
-
- if (!selected || selected.size === 0) {
- // Create empty group
- this.createGroup(fieldId);
- } else {
- // Create group with selected items
- const group = this.createGroup(fieldId);
- if (!group) return;
-
- selected.forEach(uploadId => {
- this.addToGroup(uploadId, group.grid);
- });
-
- // Clear selection
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
-
- this.a11y.announce(`Created group with ${selected.size} items`);
- }
-
- this.schedulePersistance(fieldId);
- }
-
- handleDeleteGroup(button) {
- const group = button.closest(this.selectors.groups.container);
- if (!group) return;
-
- const groupId = group.dataset.groupId;
- const fieldId = this.getFieldIdFromElement(group);
-
- if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
- return;
- }
-
- // Move items back to preview
- const items = group.querySelectorAll(this.selectors.items.item);
- items.forEach(item => {
- const uploadId = item.dataset.uploadId;
- this.removeFromGroup(uploadId);
- });
-
- // Remove group
- this.deleteGroup(groupId);
-
- this.a11y.announce('Group deleted, items returned to upload area');
- this.schedulePersistance(fieldId);
- }
-
- handleRemoveItem(button) {
- const item = button.closest(this.selectors.items.item);
- if (!item) return;
-
- const uploadId = item.dataset.uploadId;
- const fieldId = this.getFieldIdFromElement(item);
-
- if (!confirm('Remove this item?')) {
- return;
- }
-
- this.removeUpload(fieldId, uploadId);
- this.a11y.announce('Item removed');
- this.schedulePersistance(fieldId);
- }
-
- /*******************************************************************************
- * SELECTION MANAGEMENT
- *******************************************************************************/
-
- /**
- * Add selection handler for a field
- */
- addFieldSelectionHandler(fieldId) {
- if (this.selectionHandlers.has(fieldId)) {
- return this.selectionHandlers.get(fieldId);
- }
-
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- const container = field.ui.field;
- if (!container) return;
-
- const handler = new window.jvbHandleSelection({
- container: container,
- ui: {
- selectAll: container.querySelector('[name="select-all-uploads"]'),
- bulkControls: container.querySelector('.selection-actions'),
- count: container.querySelector('.selection-count')
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- // Subscribe to selection changes
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- case 'item-deselected':
- case 'range-selected':
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(fieldId, handler);
- return handler;
- }
-
- /**
- * Add selection handler for a group
- */
- addGroupSelectionHandler(fieldId, groupId) {
- const handlerKey = `${fieldId}_${groupId}`;
-
- if (this.selectionHandlers.has(handlerKey)) {
- return this.selectionHandlers.get(handlerKey);
- }
-
- const group = this.groups.get(groupId);
- if (!group) return;
-
- const handler = new window.jvbHandleSelection({
- container: group.element,
- ui: {
- selectAll: group.element.querySelector(this.selectors.groups.selectAll),
- bulkControls: group.element.querySelector(this.selectors.groups.actions),
- count: group.element.querySelector(this.selectors.groups.count)
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- case 'item-deselected':
- case 'range-selected':
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(handlerKey, handler);
- return handler;
- }
-
- handleSelectAll(container, selected) {
- }
-
- /*******************************************************************************
- * HELPER METHODS
- *******************************************************************************/
-
- determineFieldId(fieldElement) {
- const content = fieldElement.dataset.content ||
- fieldElement.closest('dialog')?.dataset.content ||
- fieldElement.closest('form')?.dataset.save || '';
- const itemID = fieldElement.dataset.itemId ||
- fieldElement.closest('dialog')?.dataset.itemId || '';
- const field = fieldElement.dataset.field || '';
-
- return `${content}_${itemID}_${field}`;
- }
-
- getFromElement(element, type) {
- const map = {
- 'field': { selector: this.selectors.field.field, key: 'uploader', store: this.fields },
- 'upload': { selector: this.selectors.items.item, key: 'uploadId', store: this.uploads },
- 'group': { selector: this.selectors.groups.container, key: 'groupId', store: this.groups }
- };
-
- const config = map[type];
- if (!config) return null;
-
- const el = element.closest(config.selector);
- if (!el) return null;
-
- const id = el.dataset[config.key];
- return config.store.get(id);
- }
- getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
- getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
- getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
-
- getFieldIdFromElement(el) { return this.getFromElement(el, 'field')?.id ?? null};
- getUploadIdFromElement(el) {return this.getFromElement(el, 'upload')?.id ?? null};
- getGroupIdFromElement(el) {return this.getFromElement(el, 'group')?.id ?? null};
-
-
/*******************************************************************************
* FILE PROCESSING
*******************************************************************************/
- async processFiles(fieldId, files) {
- const field = this.fields.get(fieldId);
- if (!field) return;
- // Hide upload container, show group display
- if (field.ui.dropZone) {
- field.ui.dropZone.hidden = true;
+ async processFiles(fieldId, files) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Show group display, hide upload zone
+ if (fieldEl.ui.dropZone) {
+ fieldEl.ui.dropZone.hidden = true;
}
- if (field.ui.groups.display) {
- field.ui.groups.display.hidden = false;
+ if (fieldEl.ui.groups?.display) {
+ fieldEl.ui.groups.display.hidden = false;
}
const totalFiles = files.length;
let processedCount = 0;
- // Show initial progress
this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
- // Initialize field uploads set if needed
- if (!field.uploads) {
- field.uploads = new Set();
- }
-
- // Process files
- const processPromises = Array.from(files).map(async (file, index) => {
+ const processPromises = Array.from(files).map(async (file) => {
try {
-
- // Create upload ID
const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- // Create upload data
+ // Create upload data (without blob initially)
const uploadData = {
id: uploadId,
- attachment_id: null,
+ attachmentId: null,
fieldId: fieldId,
- originalFile: file,
- processedFile: null,
- preview: null,
status: 'local_processing',
- element: null,
- location: null,
+ groupId: null,
meta: {
originalName: file.name,
size: file.size,
@@ -952,50 +577,55 @@
}
};
- // Create preview URL
- uploadData.preview = this.createPreviewUrl(file);
+ // Process file
+ const preview = this.createPreviewUrl(file);
+ const processedFile = file.type.startsWith('image/')
+ ? await this.processImage(file, fieldData.config.subtype)
+ : file;
- // Process the file (resize if image)
- if (file.type.startsWith('image/')) {
- uploadData.processedFile = await this.processImage(file, field.subtype);
- } else {
- uploadData.processedFile = file;
- }
-
- // Store blob data separately in IndexedDB
- await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file);
+ // Store blob data as ArrayBuffer
+ await this.saveBlobData(uploadId, processedFile || file);
// Create DOM element
const subtype = this.getSubtypeFromMime(file.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
+ const element = this.createUploadElement({
+ id: uploadId,
+ preview: preview,
+ meta: uploadData.meta,
subtype: subtype
- }, field.config.destination === 'post_group');
+ }, fieldData.config.destination === 'post_group');
- // Show progress on the item
+ // Show progress
this.showUploadProgress(uploadId, true);
this.updateUploadItemProgress(uploadId, 50, 'local_processing');
// Add to preview grid
- if (field.ui.preview) {
- field.ui.preview.appendChild(uploadData.element);
- uploadData.location = field.ui.preview;
+ if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.appendChild(element);
+
+ // Store runtime element data
+ this.uploadElements.set(uploadId, {
+ element: element,
+ preview: preview,
+ location: fieldEl.ui.preview
+ });
}
- // Store upload
- this.uploads.set(uploadId, uploadData);
- field.uploads.add(uploadId);
+ // Store persistent data
+ uploadData.status = 'processed';
+ await this.uploadStore.save(uploadData);
+
+ // Add to field
+ fieldData.uploads.add(uploadId);
+ await this.saveFieldData(fieldData);
// Update progress
processedCount++;
this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
this.updateUploadItemProgress(uploadId, 100, 'processed');
- uploadData.status = 'processed';
- // Fade out item progress after a moment
- setTimeout(() => {
- this.showUploadProgress(uploadId, false);
- }, 1000);
+ // Fade out progress
+ setTimeout(() => this.showUploadProgress(uploadId, false), 1000);
return uploadId;
@@ -1007,192 +637,21 @@
}
});
- // Wait for all files to process
await Promise.all(processPromises);
this.updateFieldState(fieldId);
- // Cache the state (now without DOM references)
- await this.schedulePersistance(fieldId);
+ this.refreshSortable(fieldId);
// Queue for upload if in direct mode
- if (field.config.destination !== 'post_group') {
+ if (fieldData.config.destination !== 'post_group') {
await this.queueUpload(fieldId);
- // Lock uploads if max reached
this.maybeLockUploads(fieldId);
}
-
}
- updateFieldState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field || !field.ui.field) return;
-
- const container = field.ui.field;
- const uploadCount = field.uploads?.size || 0;
- const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
-
- // Set data attributes for CSS targeting
- container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
- container.dataset.uploadCount = uploadCount.toString();
- container.dataset.hasGroups = hasGroups ? 'true' : 'false';
-
- // Update ARIA labels for accessibility
- if (field.ui.preview) {
- field.ui.preview.setAttribute('aria-label',
- `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
- );
- }
- }
-
- updateUploadProgress(fieldId, current, total, message) {
- const field = this.fields.get(fieldId);
- if (!field?.ui?.progress?.progress) return;
-
- const progress = field.ui.progress;
- const percent = total > 0 ? (current / total) * 100 : 0;
-
- if (progress.fill) {
- progress.fill.style.width = `${percent}%`;
- }
- if (progress.text) {
- progress.text.textContent = message;
- }
- if (progress.count) {
- progress.count.textContent = `${current}/${total}`;
- }
-
- progress.progress.hidden = (current === total);
- }
-
- updateFieldStatus(fieldId, status) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- field.state = status;
- // Update UI based on status
- }
-
- updateUploadStatus(uploadId, status) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
-
- upload.status = status;
- this.updateUploadUI(uploadId);
- }
-
- updateUploadUI(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload?.element) return;
-
- // Update status classes
- upload.element.className = upload.element.className.replace(/status-[\w-]+/g, '');
- upload.element.classList.add(`status-${upload.status}`);
-
- // Update progress if showing
- const progress = upload.element.querySelector('.progress');
- if (progress) {
- this.updateUploadItemProgress(uploadId,
- this.getStatusProgress(upload.status),
- upload.status
- );
- }
- }
-
- /**
- * Show/hide progress indicator on individual upload items
- */
- showUploadProgress(uploadId, show = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
-
- const progressEl = upload.element.querySelector('.progress');
- if (progressEl) {
- if (show) {
- progressEl.style.removeProperty('animation');
- progressEl.hidden = false;
- } else {
- progressEl.style.animation = 'fadeOut var(--transition-base)';
- setTimeout(() => {
- progressEl.hidden = true;
- }, 300);
- }
- }
- }
-
- /**
- * Update individual upload progress bar
- */
- updateUploadItemProgress(uploadId, percent, status = null) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
-
- const progressEl = upload.element.querySelector('.progress');
- if (!progressEl) return;
-
- const fill = progressEl.querySelector('.fill');
- const details = progressEl.querySelector('.details');
- const icon = progressEl.querySelector('.icon');
-
- if (fill) {
- fill.style.width = `${percent}%`;
- }
-
- if (status && details) {
- details.textContent = this.getStatusText(status);
- }
-
- if (status && icon) {
- icon.innerHTML = this.getStatusIcon(status).outerHTML;
- }
- }
- checkFieldLimits(fieldId, additionalFiles) {
- const field = this.fields.get(fieldId);
- if (!field) return false;
-
- const currentCount = field.uploads?.size || 0;
- const totalCount = currentCount + additionalFiles;
-
- return totalCount <= field.maxFiles;
-
-
- }
- validateFile(file, field) {
- // Type validation
- if (!this.settings.allowedTypes.includes(file.type)) {
- this.notify(`Invalid file type: ${file.type}`, 'error');
- return false;
- }
-
- // Size validation
- if (file.size > this.settings.maxFileSize) {
- this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error');
- return false;
- }
-
- return true;
- }
-
- formatBytes(bytes, decimals = 2) {
- if (bytes === 0) return '0 Bytes';
-
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
-
- shouldProcessClientSide(file, subtype) {
- // Only process images client-side
- if (subtype === 'image' && file.type.startsWith('image/')) {
- return true;
- }
-
- // Videos and documents go straight to server
- return false;
- }
+ /*******************************************************************************
+ * IMAGE PROCESSING (simplified - keeping existing logic)
+ *******************************************************************************/
async processImage(file, uploadId) {
const timeout = this.worker.settings.timeout;
@@ -1201,27 +660,19 @@
let timeoutId;
let taskCompleted = false;
- // Set timeout
timeoutId = setTimeout(() => {
if (!taskCompleted) {
taskCompleted = true;
-
- // Remove from active tasks
this.worker.tasks.delete(uploadId);
-
- // Maybe restart worker if configured
if (this.worker.settings.restartAfterTimeout) {
this.restartCompressionWorker();
}
-
reject(new Error(`Processing timeout for ${file.name}`));
}
}, timeout);
- // Track this task
this.worker.tasks.set(uploadId, { file, timeoutId });
- // Process image
this.handleProcess(file, uploadId)
.then(result => {
if (!taskCompleted) {
@@ -1243,7 +694,6 @@
}
async handleProcess(file, uploadId) {
- // Skip non-images
if (!file.type.startsWith('image/')) {
return file;
}
@@ -1251,14 +701,11 @@
const maxDimension = this.getMaxDimension();
const quality = 0.85;
- // Try worker first if available
if (this.shouldUseWorker(file)) {
try {
- // Ensure worker is initialized
if (!this.worker.worker) {
this.initCompressionWorker();
}
-
if (this.worker.worker) {
return await this.processWithWorker(file, uploadId, maxDimension, quality);
}
@@ -1267,13 +714,9 @@
}
}
- // Fallback to main thread
return await this.processOnMainThread(file, maxDimension, quality);
}
- /**
- * Process image on main thread with better error handling
- */
async processOnMainThread(file, maxDimension, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -1288,7 +731,6 @@
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
- // Explicitly clean up canvas
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
@@ -1300,7 +742,6 @@
canvas.width = width;
canvas.height = height;
- // Enhanced image smoothing
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
@@ -1347,67 +788,41 @@
});
}
- /**
- * Get optimal output format
- */
getOptimalFormat(file) {
- // Keep original format for certain types
if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
return file.type;
}
-
- // Use WebP if supported, otherwise JPEG
return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
}
- /**
- * Get optimal quality setting
- */
getOptimalQuality(file, requestedQuality) {
- // Higher quality for smaller files
if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
if (file.size < 2 * 1024 * 1024) return requestedQuality;
-
- // Lower quality for very large files
return Math.min(requestedQuality, 0.8);
}
- /**
- * Generate processed file name
- */
getProcessedFileName(originalFile, outputFormat) {
const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
-
const extensions = {
'image/webp': '.webp',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif'
};
-
return baseName + (extensions[outputFormat] || '.jpg');
}
- /**
- * Get maximum dimension based on device capabilities
- */
getMaxDimension() {
const screenWidth = window.screen.width;
const devicePixelRatio = window.devicePixelRatio || 1;
-
- // Scale based on device capabilities
if (screenWidth * devicePixelRatio > 2560) return 2400;
if (screenWidth * devicePixelRatio > 1920) return 1920;
return 1200;
}
- /**
- * Determine if we should use Web Worker
- */
shouldUseWorker(file) {
- // Use worker for large files or when available
return this.worker.worker &&
- file.size > 1024 * 1024 && // > 1MB
+ file.size > 1024 * 1024 &&
typeof OffscreenCanvas !== 'undefined';
}
@@ -1418,14 +833,11 @@
return;
}
- // Create unique message ID for this task
const messageId = `${uploadId}_${Date.now()}`;
- // Handler for this specific message
const messageHandler = (e) => {
if (e.data.messageId !== messageId) return;
- // Remove handler
this.worker.worker.removeEventListener('message', messageHandler);
this.worker.worker.removeEventListener('error', errorHandler);
@@ -1447,11 +859,9 @@
reject(new Error(`Worker error: ${error.message}`));
};
- // Add handlers
this.worker.worker.addEventListener('message', messageHandler);
this.worker.worker.addEventListener('error', errorHandler);
- // Send message to worker
this.worker.worker.postMessage({
messageId,
file,
@@ -1462,85 +872,45 @@
});
}
- /**
- * Restart compression worker
- */
restartCompressionWorker() {
- // Terminate existing worker
if (this.worker.worker) {
this.worker.worker.terminate();
this.worker.worker = null;
}
-
- // Clear active tasks
this.worker.tasks.clear();
-
- // Check restart limit
if (this.worker.restart.count >= this.worker.restart.max) {
console.error('Max worker restarts reached, disabling worker');
return;
}
-
this.worker.restart.count++;
-
- // Reinitialize
this.initCompressionWorker();
}
- /**
- * Initialize Web Worker for image compression
- */
initCompressionWorker() {
if (this.worker.worker || typeof Worker === 'undefined') return;
try {
const workerScript = `
- self.onmessage = async function(e) {
- const { messageId, file, maxDimension, quality, outputFormat } = e.data;
-
- try {
- // Create ImageBitmap from file
- const bitmap = await createImageBitmap(file);
-
- // Calculate dimensions
- const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
- const width = Math.round(bitmap.width * scale);
- const height = Math.round(bitmap.height * scale);
-
- // Create OffscreenCanvas
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
-
- // Draw and resize
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(bitmap, 0, 0, width, height);
-
- // Clean up bitmap
- bitmap.close();
-
- // Convert to blob
- const blob = await canvas.convertToBlob({
- type: outputFormat,
- quality: quality
- });
-
- self.postMessage({
- messageId,
- success: true,
- blob: blob,
- format: outputFormat
- });
-
- } catch (error) {
- self.postMessage({
- messageId,
- success: false,
- error: error.message
- });
- }
- };
- `;
+ self.onmessage = async function(e) {
+ const { messageId, file, maxDimension, quality, outputFormat } = e.data;
+ try {
+ const bitmap = await createImageBitmap(file);
+ const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
+ const width = Math.round(bitmap.width * scale);
+ const height = Math.round(bitmap.height * scale);
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(bitmap, 0, 0, width, height);
+ bitmap.close();
+ const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });
+ self.postMessage({ messageId, success: true, blob: blob, format: outputFormat });
+ } catch (error) {
+ self.postMessage({ messageId, success: false, error: error.message });
+ }
+ };
+ `;
const blob = new Blob([workerScript], { type: 'application/javascript' });
this.worker.worker = new Worker(this.createPreviewUrl(blob));
@@ -1551,30 +921,18 @@
}
}
- /**
- * Calculate optimal dimensions with aspect ratio preservation
- */
calculateOptimalDimensions(img, maxDimension) {
let { width, height } = img;
-
- // Don't upscale
if (width <= maxDimension && height <= maxDimension) {
return { width, height };
}
-
- // Calculate scale factor
const scale = Math.min(maxDimension / width, maxDimension / height);
-
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
-
- /**
- * Check WebP support
- */
supportsWebP() {
const canvas = document.createElement('canvas');
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
@@ -1582,7 +940,6 @@
createPreviewUrl(file) {
const url = URL.createObjectURL(file);
- // Track for cleanup
if (!this.previewUrls) this.previewUrls = new Set();
this.previewUrls.add(url);
return url;
@@ -1595,131 +952,19 @@
}
}
- maybeLockUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.ui?.dropZone) return;
-
- if (field.config.destination === 'post_group') {
- return;
- }
-
- const uploadCount = field.uploads?.size || 0;
- const maxFiles = field.config?.maxFiles || 999;
-
- // Hide dropzone if at max files
- field.ui.dropZone.hidden = uploadCount >= maxFiles;
-
- // Update field state
- field.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
- }
- createUploadElement(upload, draggable = false) {
- let image = window.getTemplate('uploadItem');
- if (!image) {
- console.error('Image template not found');
- return;
- }
- image.dataset.uploadId = upload.id;
- if (upload.originalFile) {
- image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type);
- }
-
-
- image.querySelector('[name="featured"]').value = upload.id;
- let [
- featured,
- img,
- video,
- preview,
- details
- ] = [
- image.querySelector('[name="featured"]'),
- image.querySelector('img'),
- image.querySelector('video'),
- image.querySelector('label > span'),
- image.querySelector('details')
- ];
- [
- featured.value,
- img.src,
- img.alt
- ] = [
- upload.id,
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName ?? '',
- ];
-
- switch (image.dataset.subtype) {
- case 'image':
- [
- img.src,
- img.alt
- ] = [
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
- ];
- video.remove();
- preview.remove();
- break;
- case 'video':
- video.src = upload.preview;
- img.remove();
- preview.remove();
- break;
- case 'document':
- const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? '';
- const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
- const iconMap = {
- 'pdf': 'file-pdf',
- 'csv': 'file-csv',
- 'doc': 'file-doc',
- 'docx': 'file-doc',
- 'txt': 'file-txt',
- 'xls': 'file-xls',
- 'xlsx': 'file-xls'
- };
-
- const icon = window.getIcon(iconMap[extension] || 'file');
-
- preview.innerText = upload.originalFile.name;
- preview.prepend(icon);
- img.remove();
- video.remove();
- break;
- }
- if (details) {
- let template = window.getTemplate('uploadMeta');
- if (template){
- details.append(template);
- }
- }
- image.draggable = draggable;
-
- // Update input IDs safely
- image.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) {
- label.htmlFor = newId;
- }
- }
- });
-
- return image;
- }
/*******************************************************************************
* QUEUE INTEGRATION
*******************************************************************************/
+
async submitUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads || field.uploads.size === 0) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData?.uploads || fieldData.uploads.size === 0) {
return;
}
- let uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
+ let uploadIds = Array.from(fieldData.uploads);
+ if (uploadIds.length === 0) {
this.error.log('No uploads to upload', {
component: 'UploadManager',
action: 'submitGroupedUploads',
@@ -1744,108 +989,88 @@
const formData = new FormData();
let uploadMap = [];
- uploads = uploads.map((upload) => {
- return this.uploads.get(upload);
- });
-
- fieldGroups.forEach((group, groupIndex) => {
+ // Process each group
+ for (const group of fieldGroups) {
const post = {
images: [],
fields: {}
};
+
+ // Add group metadata
for (let [name, value] of Object.entries(group.changes)) {
post.fields[name] = value;
}
- let groupUploads = uploads.filter((upload) => {
- return upload['groupId'] === group.id;
+ // Get uploads for this group
+ const groupUploadIds = uploadIds.filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return upload?.groupId === group.id;
});
- groupUploads.forEach((upload) => {
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
-
- const imageData = {
- upload_id: upload.id,
- index: uploadMap.length
- };
- post.images.push(imageData);
- uploadMap.push(upload.id);
- }
- }
- });
- // Add images for this group
- // group.uploads.forEach(uploadId => {
- // const upload = this.uploads.get(uploadId);
- // if (upload) {
- // const fileToUpload = upload.processedFile || upload.originalFile;
- // if (fileToUpload) {
- // formData.append('files[]', fileToUpload);
- //
- // const imageData = {
- // upload_id: upload.id,
- // index: uploadMap.length
- // };
- //
- // // Check if this is the featured image
- // const radioInput = upload.element?.querySelector('[name="featured"]');
- // if (radioInput?.checked) {
- // post.fields.featured = upload.id;
- // }
- //
- // post.images.push(imageData);
- // uploadMap.push(upload.id);
- // }
- // }
- // });
-
- posts.push(post);
- });
-
- //Each remaining upload (without a groupId) becomes its own post
- let remainingUploads = uploads.filter((upload) => {
- return !Object.hasOwn(upload, 'groupId');
- });
-
- remainingUploads.forEach((upload) => {
- if (upload) {
-
- const post = {
- images: [],
- fields: {}
- };
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
+ // Add files for this group
+ for (const uploadId of groupUploadIds) {
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
const imageData = {
- upload_id: upload.id,
+ upload_id: uploadId,
index: uploadMap.length
};
+
+ // Check if featured
+ const uploadEl = this.uploadElements.get(uploadId);
+ const radioInput = uploadEl?.element?.querySelector('[name="featured"]');
+ if (radioInput?.checked) {
+ post.fields.featured = uploadId;
+ }
+
post.images.push(imageData);
- uploadMap.push(upload.id);
+ uploadMap.push(uploadId);
}
- posts.push(post);
}
+
+ posts.push(post);
+ }
+
+ // Handle remaining uploads (without groupId) - each becomes its own post
+ const remainingUploadIds = uploadIds.filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return !upload?.groupId;
});
+ for (const uploadId of remainingUploadIds) {
+ const post = {
+ images: [],
+ fields: {}
+ };
+
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
+
+ const imageData = {
+ upload_id: uploadId,
+ index: uploadMap.length
+ };
+ post.images.push(imageData);
+ uploadMap.push(uploadId);
+ }
+
+ posts.push(post);
+ }
// Add metadata to FormData
- formData.append('content', field.config.content);
- formData.append('user', field.config.itemID); // Assuming itemID is user ID
+ formData.append('content', fieldData.config.content);
+ formData.append('user', fieldData.config.itemID);
formData.append('posts', JSON.stringify(posts));
formData.append('upload_ids', JSON.stringify(uploadMap));
- for (const [key, value] of formData.entries()) {
- console.log(key, value);
- }
const operation = {
endpoint: 'uploads/groups',
method: 'POST',
data: formData,
- title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
+ title: `Creating ${posts.length} ${fieldData.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
canMerge: false,
headers: {
@@ -1857,15 +1082,20 @@
try {
const operationId = await this.queue.addToQueue(operation);
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
+ // Update upload statuses
+ uploadIds.forEach(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
if (upload) {
upload.operationId = operationId;
+ upload.status = 'queued';
+ this.uploadStore.save(upload);
this.updateUploadStatus(uploadId, 'queued');
}
});
- field.operationId = operationId;
+ fieldData.operationId = operationId;
+ await this.saveFieldData(fieldData);
+
this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
return operationId;
@@ -1876,181 +1106,107 @@
fieldId: fieldId
});
throw error;
- } finally {
- this.schedulePersistance(field.id);
}
}
async queueUpload(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads) return;
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData?.uploads || fieldData.uploads.size === 0) return;
- const uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
- return;
- }
+ const uploads = Array.from(fieldData.uploads);
+ const data = this.prepareUploadData(fieldData, uploads);
- const data = this.prepareUploadData(field, uploads);
this.a11y.announce('Queuing for upload');
- let img = (uploads.length === 1) ? 'file' : 'files';
+
const operation = {
endpoint: 'uploads',
method: 'POST',
data: data,
- title: `Uploading ${uploads.length} ${img} to server...`,
- popup: `Uploading ${uploads.length} ${img}...`,
+ title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''} to server...`,
+ popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
canMerge: false,
- headers: {
- 'action_nonce': jvbSettings.dash
- },
+ headers: { 'action_nonce': jvbSettings.dash },
append: '_upload'
- }
+ };
+
try {
const operationId = await this.queue.addToQueue(operation);
+ // Update upload statuses
uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (!upload) {
- return;
+ const upload = this.uploadStore.get(uploadId);
+ if (upload) {
+ upload.operationId = operationId;
+ upload.status = 'queued';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(uploadId, 'queued');
}
- upload.operationId = operationId;
- this.updateUploadStatus(uploadId, 'queued');
});
- field.operationId = operationId;
+
+ fieldData.operationId = operationId;
+ await this.saveFieldData(fieldData);
return operationId;
} catch (error) {
throw error;
- } finally {
- this.schedulePersistance(field.id);
}
}
- prepareUploadData(field, uploads) {
-
+ async prepareUploadData(fieldData, uploads) {
const formData = new FormData();
- formData.append('content', field.config.content);
- formData.append('mode', field.config.mode);
- formData.append('field_name', field.config.name);
- formData.append('fieldId', field.id);
- formData.append('field_type', field.config.type);
- formData.append('subtype', field.config.subtype);
- formData.append('item_id', field.config.itemID); //post, term, or user id
- formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group
+ formData.append('content', fieldData.config.content);
+ formData.append('mode', fieldData.config.mode);
+ formData.append('field_name', fieldData.config.name);
+ formData.append('fieldId', fieldData.id);
+ formData.append('field_type', fieldData.config.type);
+ formData.append('subtype', fieldData.config.subtype);
+ formData.append('item_id', fieldData.config.itemID);
+ formData.append('destination', fieldData.config.destination || 'meta');
+
let uploadMap = [];
- const fieldGroups = this.getFieldGroups(field.id);
- if (field.config.destination === 'post_group' && fieldGroups.length > 0) {
- // User has created groups
- let groups = [];
- let titles = [];
- let featuredImages = [];
- fieldGroups.forEach(group => {
- let groupUploadIndices = [];
- let featuredIndex = null;
+ const blobPromises = uploads.map(async (uploadId) => {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload) return;
- group.uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
- const fileIndex = uploadMap.length;
- uploadMap.push(upload.id);
- groupUploadIndices.push(upload.id);
-
- // Check if this is the featured image
- const radioInput = upload.element?.querySelector('[name="featured"]');
- if (radioInput?.checked) {
- featuredIndex = upload.id;
- }
- }
- }
- });
-
- groups.push(groupUploadIndices);
- titles.push(group.title || '');
- featuredImages.push(featuredIndex);
- });
-
- formData.append('groups', JSON.stringify(groups));
- formData.append('group_titles', JSON.stringify(titles));
- formData.append('featured_images', JSON.stringify(featuredImages));
- } else {
- // No groups - just append all files
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
- uploadMap.push(upload.id);
- }
- }
- });
- }
- formData.append('upload_ids', JSON.stringify(uploadMap));
-
- // console.log('Final FormData:');
- // for (let pair of formData.entries()) {
- // console.log(pair[0], pair[1]);
- // }
-
- return formData;
- }
-
- getFieldGroups(fieldId) {
- const groups = [];
-
- this.groups.forEach((groupData, groupId) => {
- if (groupData.fieldId === fieldId) {
- const field = this.fields.get(fieldId);
- const groupElement = field?.ui?.groups?.groups?.get(groupId);
-
- groups.push({
- id: groupId,
- uploads: Array.from(groupData.uploads || new Set()),
- changes: groupData.changes || {},
- element: groupElement || null
- });
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
+ uploadMap.push(upload.id);
}
});
- return groups;
+ await Promise.all(blobPromises);
+
+ formData.append('upload_ids', JSON.stringify(uploadMap));
+ return formData;
}
async queueUploadMeta(e) {
- const upload = this.getUploadFromElement(e.target);
+ const uploadId = this.getUploadIdFromElement(e.target);
+ const upload = this.uploadStore.get(uploadId);
if (!upload) return;
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- const container = e.target.closest('.upload-meta');
- if (!container) return;
+ const fieldData = this.getFieldData(upload.fieldId);
+ if (!fieldData) return;
let data = {};
data[e.target.name] = e.target.value;
- upload.meta = {
- ...upload.meta,
- ... data
- };
+ upload.meta = { ...upload.meta, ...data };
+ await this.uploadStore.save(upload);
let queueData = {};
- //If there is an attachment ID, use that: else, use our generated upload id
- queueData[upload.attachmentId??upload.id] = upload.meta;
+ queueData[upload.attachmentId ?? upload.id] = upload.meta;
const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- data: queueData,
- title: `Updating meta`,
- canMerge: true,
- headers: {
- 'action_nonce': jvbSettings.dash
- }
+ endpoint: 'uploads/meta',
+ method: 'POST',
+ data: queueData,
+ title: 'Updating meta',
+ canMerge: true,
+ headers: { 'action_nonce': jvbSettings.dash }
};
try {
@@ -2063,441 +1219,109 @@
});
}
}
+
/*******************************************************************************
- * GROUP MANAGEMENT
+ * QUEUE EVENT HANDLERS - CLEANUP AFTER SUCCESS
*******************************************************************************/
- createGroup(fieldKey, groupId = null) {
- const field = this.fields.get(fieldKey);
- if (!field) {
- console.error('Field not found:', fieldKey);
- return null;
- }
+ /**
+ * Handle successful operation completion - CLEAR STORES
+ */
+ async handleOperationComplete(operation, fieldId) {
+ const results = operation.result?.data || operation.serverData?.data || [];
- if (!groupId) {
- groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- const groupElement = this.createGroupElement(groupId, fieldKey);
- if (!groupElement) {
- console.error('Failed to create group element');
- return null;
- }
-
- // Store in field UI Map
- if (!field.ui.groups) {
- field.ui.groups = {
- groups: new Map(),
- container: null,
- empty: null,
- display: null
- };
- }
-
- field.ui.groups.groups.set(groupId, groupElement);
-
- // Insert into DOM
- if (field.ui.groups.container && field.ui.groups.empty) {
- field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
- } else if (field.ui.groups.container) {
- field.ui.groups.container.appendChild(groupElement);
- }
-
- // Create group object
- const group = {
- id: groupId,
- fieldId: fieldKey,
- element: groupElement,
- grid: groupElement.querySelector('.item-grid.group'),
- uploads: new Set(),
- changes: {}
- };
-
- // Store group
- this.groups.set(groupId, group);
-
- // Initialize selection handler for this group
- this.addGroupSelectionHandler(fieldKey, groupId);
-
- // Persist state
- this.schedulePersistance(fieldKey);
-
- return group;
- }
-
- createGroupElement(groupId, fieldId) {
- let groupElement = window.getTemplate('imageGroup');
- if (!groupElement) return;
-
- groupElement.dataset.groupId = groupId;
- groupElement.dataset.fieldId = fieldId;
-
- let fields = window.getTemplate('groupMetadata');
- const fieldsContainer = groupElement.querySelector('.fields');
- if (fieldsContainer && fields) {
- fieldsContainer.append(fields);
-
- // Set unique IDs and names for form fields
- const titleInput = fieldsContainer.querySelector('[name="post_title"]');
- const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
-
- if (titleInput) {
- titleInput.id = `${groupId}_title`;
- titleInput.name = `${groupId}[post_title]`;
+ // Update upload statuses with attachment IDs
+ results.forEach(result => {
+ const upload = this.uploadStore.get(result.upload_id);
+ if (upload) {
+ upload.attachmentId = result.attachment_id;
+ upload.status = 'completed';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(result.upload_id, 'completed');
}
- if (excerptInput) {
- excerptInput.id = `${groupId}_excerpt`;
- excerptInput.name = `${groupId}[post_excerpt]`;
- }
- let field = this.fields.get(fieldId);
- if (field.config.content !== '') {
- let summary = groupElement.querySelector('summary');
- summary.textContent = field.config.content + ' Fields';
- }
+ });
+
+ if (!fieldId) return;
+
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ // Clean up completed uploads from stores
+ const completedUploads = Array.from(fieldData.uploads).filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return upload?.status === 'completed';
+ });
+
+ for (const uploadId of completedUploads) {
+ await this.clearUpload(uploadId, false);
+ fieldData.uploads.delete(uploadId);
+ }
+
+ // If all uploads complete, clear entire field from stores
+ if (fieldData.uploads.size === 0) {
+ await this.clearFieldFromStores(fieldId);
+ this.a11y.announce('All uploads completed successfully');
} else {
- groupElement.querySelector('details').remove();
+ // Otherwise just update field state
+ await this.saveFieldData(fieldData);
}
- const gridContainer = groupElement.querySelector('.item-grid.group');
- if (gridContainer) {
- gridContainer.dataset.groupId = groupId;
- }
-
- return groupElement;
- }
-
- deleteGroup(groupId, confirm = true) {
- let group = this.groups.get(groupId);
- if (!group) {
- return;
- }
-
- let keepUploads = true;
- if (confirm && group.uploads && group.uploads.size > 0) {
- keepUploads = !window.confirm('Delete uploads in group?');
- }
-
- if (confirm && keepUploads) {
- // Move any remaining uploads back to preview
- if (group.uploads && group.uploads.size > 0) {
- Array.from(group.uploads).forEach(uploadId => {
- this.addImageToGroup(uploadId, null, false);
- });
- }
- }
-
- // Remove from groups Map
- this.groups.delete(groupId);
-
- // Remove DOM element
- let groupElement = group.element;
- if (groupElement) {
- groupElement.remove();
- this.a11y.announce('Group removed');
- }
-
- this.schedulePersistance(group.fieldId);
- }
-
- addToGroup(uploadId, target = null, persist = true) {
- let upload = this.uploads.get(uploadId);
- if(!upload) {
- return;
- }
- let field = this.fields.get(upload.fieldId);
- if (!field) {
- return;
- }
-
- //Already in the Preview Grid, or already in the group we're moving to
- if ((!target && upload.location === field.ui.preview) || target === upload.location) {
- return;
- }
-
- // Remove from previous location
- if (upload.location) {
- let groupId = upload.location.dataset.groupId;
- if (groupId) {
- let group = this.groups.get(groupId);
- if (group && group.uploads) {
- group.uploads.delete(uploadId);
-
- if (group.uploads.size === 0) {
- this.deleteGroup(groupId);
- }
- }
- }
- }
-
- const checkbox = upload.element.querySelector('[name*="select-item"]');
- if (checkbox) {
- checkbox.checked = false;
- }
-
- let featured = upload.element.querySelector('[name="featured"]');
- featured.hidden = !target;
-
-
- //If no target, it's going to the preview grid
- if (!target) {
- target = field.ui.preview;
- upload.groupId = null;
- } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) {
- // It's a group target
- let groupId = target.dataset.groupId;
- featured.name = groupId+'_'+featured.name;
- let group = this.groups.get(groupId);
- if (!group) {
- group = this.createGroup(upload.fieldId);
- target = group.grid;
- groupId = group.id;
- }
- if (group) {
- group.uploads.add(uploadId);
- upload.groupId = groupId;
- }
-
- }
-
- upload.location = target;
- target.append(upload.element);
-
- if (persist) {
- this.schedulePersistance(field.id);
- }
- }
-
- removeFromGroup(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
-
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- // Remove from current group if in one
- if (upload.groupId) {
- const group = this.groups.get(upload.groupId);
- if (group?.uploads) {
- group.uploads.delete(uploadId);
-
- // Delete empty group
- if (group.uploads.size === 0) {
- this.deleteGroup(upload.groupId, false);
- }
- }
- upload.groupId = null;
- }
-
- // Move back to preview
- if (field.ui?.preview) {
- field.ui.preview.appendChild(upload.element);
- upload.location = field.ui.preview;
- }
-
- // Hide featured radio
- const featured = upload.element.querySelector('[name="featured"]');
- if (featured) {
- featured.hidden = true;
- featured.checked = false;
- }
- }
-
- removeUpload(fieldId, uploadId) {
- const field = this.fields.get(fieldId);
- const upload = this.uploads.get(uploadId);
-
- if (!field || !upload) return;
-
- // Remove from field
- field.uploads?.delete(uploadId);
-
- // Remove from group if grouped
- if (upload.groupId) {
- const group = this.groups.get(upload.groupId);
- if (group && group.uploads) {
- group.uploads.delete(uploadId);
-
- if (group.uploads.size === 0) {
- this.removeGroup(upload.groupId);
- }
- }
- }
-
- // Clean up element
- upload.element?.remove();
-
- // Clean up memory
- this.clearUpload(uploadId);
-
- // Update field state after removal
this.updateFieldState(fieldId);
-
- // Update UI
- this.maybeLockUploads(fieldId);
- const handler = this.selectionHandlers.get(field.id);
- if (handler) {
- handler.deselect(uploadId);
- }
-
- this.a11y.announce('Upload removed');
}
- /*******************************************************************************
- * STATE MANAGEMENT
- *******************************************************************************/
- schedulePersistance(fieldId) {
- const key = `persist_${fieldId}`;
- window.debouncer.schedule(
- key,
- () => this.persistFieldState(fieldId),
- 1000
- );
- }
+ /**
+ * Handle operation failure
+ */
+ handleOperationFailed(operation, fieldId) {
+ const uploadIds = operation.data instanceof FormData
+ ? JSON.parse(operation.data.get('upload_ids') || '[]')
+ : operation.data.upload_ids || [];
- async persistFieldState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
+ uploadIds.forEach(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ if (upload) {
+ upload.status = operation.status === 'operation-failed-permanent'
+ ? 'failed_permanent'
+ : 'failed';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(uploadId, upload.status);
+ }
+ });
- // Convert Sets to Arrays for storage
- const fieldData = {
- ...field,
- id: fieldId, // Use as primary key
- fieldId: fieldId,
- uploads: Array.from(field.uploads || []).map(uploadId => {
- return this.uploads.get(uploadId);;
- }),
- groups: Array.from(this.groups.entries())
- .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0)
- .map(([id, data]) => ({
- id: data.id,
- uploads: Array.from(data.uploads),
- changes: data.changes || {}
- })),
-
- // Context for restoration
- context: {
- url: this.normalizeUrl(window.location.href),
- fullUrl: window.location.href,
- modalType: this.getModalType(field),
- formId: field.formId,
- fieldSelector: `.field.upload[data-field="${field.config.name}"]`
- },
- timestamp: Date.now()
- };
-
- // Save to store
- await this.fieldStore.save(fieldData);
- }
- normalizeUrl(url) {
- try {
- const urlObj = new URL(url);
- // Return just the origin + pathname (no query string or hash)
- return urlObj.origin + urlObj.pathname;
- } catch (e) {
- return url;
+ if (fieldId) {
+ this.updateFieldState(fieldId);
}
}
/**
- * Get uploads for a field, optionally cleaned for storage
- * @param {string} fieldId
- * @param {boolean} clean - Remove DOM references for IndexedDB storage
- * @returns {Array}
+ * Handle operation cancellation
*/
- getFieldUploads(fieldId, clean = false) {
- const field = this.fields.get(fieldId);
- if (!field || !field.uploads) return [];
+ async handleOperationCancelled(fieldId) {
+ const fieldData = this.getFieldData(fieldId); // ✅
+ if (!fieldData) return;
- return Array.from(field.uploads)
- .map(uploadId => {
- const upload = this.uploads.get(uploadId);
- if (!upload) return null;
+ const uploadsArray = fieldData.uploads instanceof Set
+ ? Array.from(fieldData.uploads)
+ : fieldData.uploads;
- if (clean) {
- // Return cleaned version without DOM references or blob URLs
- return {
- id: upload.id,
- fieldId: upload.fieldId,
- status: upload.status,
- // DON'T include preview (blob URL)
- // DON'T include originalFile or processedFile (in blob storage)
- attachmentId: upload.attachmentId,
- operationId: upload.operationId,
- groupId: upload.groupId || null,
- changes: upload.changes || {}, // ← ADD: Include changes
- meta: {
- originalName: upload.meta?.originalName || upload.originalFile?.name,
- size: upload.meta?.size || upload.originalFile?.size,
- type: upload.meta?.type || upload.originalFile?.type,
- title: upload.meta?.title,
- alt: upload.meta?.alt,
- caption: upload.meta?.caption
- }
- };
- }
-
- // Return full upload object
- return upload;
- })
- .filter(Boolean);
- }
-
- async checkForStoredUploads() {
- if (!this.db) return;
-
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const fieldStore = tx.objectStore('fieldStates');
-
- const allFieldStates = await new Promise(resolve => {
- const request = fieldStore.getAll();
- request.onsuccess = () => resolve(request.result);
- });
-
- //
- // allFieldStates.forEach(field => {
- // console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`);
- // field.uploads.forEach((upload, idx) => {
- // console.log(` Upload ${idx}:`, {
- // id: upload.id,
- // status: upload.status,
- // operationId: upload.operationId,
- // hasOperationId: !!upload.operationId
- // });
- // });
- // });
-
- // Filter for pending uploads (not yet sent to server)
- const pendingFields = allFieldStates.filter(field =>
- field.uploads.some(upload =>
- // If no operationId, it hasn't been sent to server yet
- !upload.operationId &&
- // And it's been processed locally
- (upload.status === 'completed' ||
- upload.status === 'processed' ||
- upload.status === 'local_processing' ||
- upload.status === 'processed-original')
- )
- );
-
- if (pendingFields.length === 0) return;
-
- // Show recovery notification
- this.showRecoveryNotification(pendingFields);
- }
-
- async handleRestoreUploads() {
- let notification = document.querySelector('dialog.restore-uploads');
- if (!notification) {
- return;
+ for (const uploadId of uploadsArray) {
+ await this.clearUpload(uploadId, false);
}
- const selectedUploads = this.getSelectedRestorationUploads(notification);
- if (selectedUploads.length === 0) {
- return;
- }
- await this.restoreSelectedUploads(selectedUploads);
+ await this.clearFieldFromStores(fieldId);
+ this.updateFieldState(fieldId);
+ this.a11y.announce('Upload cancelled');
+ }
- this.cleanupRestore();
+ getFieldGroups(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData?.groups) return [];
+
+ return fieldData.groups.map(group => ({
+ id: group.id,
+ uploads: group.uploads || [],
+ changes: group.changes || {}
+ }));
}
getSelectedRestorationUploads(notificationEl) {
@@ -2517,29 +1341,1347 @@
return selected;
}
- handleGroupMetaChange(input) {
- let group = this.getGroupFromElement(input);
- if (!group) {
- return;
+ async restoreSelectedUploads(selectedUploads) {
+ const byField = new Map();
+ selectedUploads.forEach(item => {
+ if (!byField.has(item.fieldId)) {
+ byField.set(item.fieldId, []);
+ }
+ byField.get(item.fieldId).push(item.uploadId);
+ });
+
+ for (const [fieldId, uploadIds] of byField.entries()) {
+ const fieldState = this.fieldStore.get(fieldId);
+ if (fieldState) {
+ fieldState.uploads = uploadIds;
+ await this.restoreField(fieldState);
+ }
}
- if (!Object.hasOwn(group, 'changes')) {
- group.changes = {};
- }
- let name = input.name;
- if (name.includes('group')) {
- let replace = group.id+'_';
- let replace2 = group.id+'[';
- name = name.replace(replace, '').replace(replace2,'').replace(']', '');
- }
- group.changes[`${name}`] = input.value;
- this.groups.set(group.id, group);
- this.schedulePersistance(group.fieldId);
}
+ async restoreField(fieldState) {
+ const { config, context, uploads, groups, id } = fieldState;
+
+ // If in a modal, open it first
+ if (context?.modalType) {
+ await this.openModalForRestore(context);
+ }
+
+ // Find field element
+ let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
+
+ if (!fieldElement) {
+ const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
+ fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
+ }
+
+ if (!fieldElement) {
+ console.warn(`Field ${config.name} not found for restoration`, config);
+ return;
+ }
+
+ // Register the field if not already registered
+ let fieldKey = fieldElement.dataset.uploader;
+ if (!fieldKey || !this.fieldElements.has(fieldKey)) {
+ fieldKey = this.registerUploader(fieldElement);
+ }
+
+ const fieldEl = this.fieldElements.get(fieldKey);
+ const fieldData = this.getFieldData(fieldKey);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Failed to register field for restoration');
+ return;
+ }
+
+ // Merge saved state back into field
+ fieldData.state = fieldState.state || 'ready';
+
+ // Rebuild UI references if needed
+ if (!fieldEl.ui) {
+ fieldEl.ui = this.buildFieldUI(fieldElement);
+ }
+
+ if (fieldEl.ui.groups?.display) {
+ fieldEl.ui.groups.display.hidden = false;
+ }
+ if (fieldEl.ui.dropZone) {
+ fieldEl.ui.dropZone.hidden = true;
+ }
+
+ // Restore groups first
+ if (groups && groups.length > 0) {
+ await this.restoreGroups(fieldKey, groups);
+ }
+
+ // Handle both Array and Set for uploads
+ const uploadsArray = uploads instanceof Set
+ ? Array.from(uploads)
+ : Array.isArray(uploads)
+ ? uploads
+ : [];
+
+ // Restore uploads
+ for (const uploadId of uploadsArray) {
+ // Get upload data from store
+ const uploadData = this.uploadStore.get(uploadId);
+ if (uploadData) {
+ await this.restoreUpload(fieldKey, uploadData);
+ }
+ }
+
+ // Update field state
+ await this.saveFieldData(fieldData);
+ this.updateFieldState(fieldKey);
+ this.maybeLockUploads(fieldKey);
+ this.refreshSortable(fieldKey);
+
+ // Queue for upload if needed
+ if (config.mode === 'direct' && config.destination !== 'post_group') {
+ await this.queueUpload(fieldKey);
+ }
+ }
+
+ async restoreUpload(fieldId, uploadData) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Field not found for upload restoration:', fieldId);
+ return;
+ }
+
+ // Get reconstructed File from blob data
+ const file = await this.getBlobData(uploadData.id);
+
+ if (!file) {
+ console.warn('Blob data not found for upload:', uploadData.id);
+ return;
+ }
+
+ // Create preview URL
+ const previewUrl = this.createPreviewUrl(file);
+
+ // Recreate DOM element
+ const subtype = this.getSubtypeFromMime(file.type);
+ const element = this.createUploadElement({
+ id: uploadData.id,
+ preview: previewUrl,
+ meta: uploadData.meta || {
+ originalName: file.name,
+ size: file.size,
+ type: file.type
+ },
+ subtype: subtype
+ }, fieldData.config.destination === 'post_group');
+
+ // Determine correct location
+ let location;
+ if (uploadData.groupId) {
+ // Check if group exists
+ const groupEl = this.groupElements.get(uploadData.groupId);
+ if (groupEl?.grid) {
+ location = groupEl.grid;
+
+ // Add to group's upload list
+ const group = fieldData.groups?.find(g => g.id === uploadData.groupId);
+ if (group) {
+ if (!group.uploads) group.uploads = [];
+ if (!group.uploads.includes(uploadData.id)) {
+ group.uploads.push(uploadData.id);
+ }
+ }
+ } else {
+ // Group doesn't exist, add to preview
+ location = fieldEl.ui.preview;
+ uploadData.groupId = null;
+ }
+ } else {
+ // No group, add to preview
+ location = fieldEl.ui.preview;
+ }
+
+ // Add element to DOM
+ if (location) {
+ location.appendChild(element);
+ } else if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.appendChild(element);
+ location = fieldEl.ui.preview;
+ }
+
+ // Store runtime element data
+ this.uploadElements.set(uploadData.id, {
+ element: element,
+ preview: previewUrl,
+ location: location
+ });
+
+ // Add to field uploads
+ if (!fieldData.uploads) fieldData.uploads = new Set();
+ fieldData.uploads.add(uploadData.id);
+
+ // Update upload data in store
+ uploadData.status = 'processed';
+ await this.uploadStore.save(uploadData);
+
+ // Update sortable state for the grid
+ if (location) {
+ this.updateSortableState(location);
+ }
+ }
+
+ async restoreGroups(fieldId, groups) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Field not found for group restoration:', fieldId);
+ return;
+ }
+
+ for (const groupData of groups) {
+ // Create group using existing method which handles all initialization
+ const group = this.createGroup(fieldId, groupData.id);
+
+ if (!group) {
+ console.warn('Failed to create group:', groupData.id);
+ continue;
+ }
+
+ // Find the group in fieldData (createGroup already added it)
+ const storedGroup = fieldData.groups?.find(g => g.id === groupData.id);
+
+ if (storedGroup) {
+ // Restore metadata
+ if (groupData.changes) {
+ storedGroup.changes = { ...groupData.changes };
+ }
+
+ if (groupData.uploads) {
+ storedGroup.uploads = [...groupData.uploads];
+ }
+
+ // Restore form field values if they exist
+ if (groupData.changes) {
+ const titleInput = group.element.querySelector('[name*="post_title"]');
+ const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
+
+ if (titleInput && groupData.changes.post_title) {
+ titleInput.value = groupData.changes.post_title;
+ }
+
+ if (excerptInput && groupData.changes.post_excerpt) {
+ excerptInput.value = groupData.changes.post_excerpt;
+ }
+ }
+ }
+ }
+
+ // Save updated field data
+ await this.saveFieldData(fieldData);
+ }
+
+ async openModalForRestore(context) {
+ if (!context) return;
+
+ const { modalType, itemId } = context;
+
+ // Find and click the appropriate button to open the modal
+ let trigger = null;
+
+ switch(modalType) {
+ case 'create':
+ trigger = document.querySelector('[data-action="create"]');
+ break;
+ case 'edit':
+ // Need to find the specific edit button
+ if (itemId) {
+ trigger = document.querySelector(`[data-action="edit"][data-id="${itemId}"]`);
+ }
+ break;
+ case 'bulkEdit':
+ trigger = document.querySelector('[data-action="bulk-edit"]');
+ break;
+ }
+
+ if (trigger) {
+ trigger.click();
+
+ // Wait for modal to open and render
+ await new Promise(resolve => setTimeout(resolve, 300));
+ } else {
+ console.warn('Modal trigger not found for restoration:', context);
+ }
+ }
+
+ formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+ }
/*******************************************************************************
- * RESTORING UPLOADS
+ * CLEANUP METHODS - AGGRESSIVE CLEANUP AFTER SUCCESS
*******************************************************************************/
+
+ /**
+ * Clear individual upload from stores (called after successful upload)
+ */
+ async clearUpload(uploadId, persist = true) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (uploadEl) {
+ this.revokePreviewUrl(uploadEl.preview);
+ if (uploadEl.element) {
+ const previewUrl = uploadEl.element.dataset.previewUrl;
+ this.revokePreviewUrl(previewUrl);
+ delete uploadEl.element.dataset.previewUrl;
+ }
+ }
+
+ // Remove from runtime memory
+ this.uploadElements.delete(uploadId);
+
+ // Remove from store (no separate blob store - it's part of the upload object)
+ await this.uploadStore.delete(uploadId);
+
+ // Update field if needed
+ if (persist) {
+ const upload = this.uploadStore.get(uploadId);
+ if (upload?.fieldId) {
+ await this.schedulePersistance(upload.fieldId);
+ }
+ }
+ }
+
+ /**
+ * Clear entire field from stores (called when all uploads complete)
+ */
+ async clearFieldFromStores(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+
+ // Clear all related uploads
+ if (fieldData?.uploads) {
+ const uploadsArray = fieldData.uploads instanceof Set
+ ? Array.from(fieldData.uploads)
+ : fieldData.uploads;
+
+ for (const uploadId of uploadsArray) {
+ await this.uploadStore.delete(uploadId);
+ }
+ }
+
+ // Clear field from store
+ await this.fieldStore.delete(fieldId);
+
+ // Keep runtime references (fieldElements, etc) intact for reuse
+ }
+
+ cleanupAllPreviewUrls() {
+ if (this.previewUrls) {
+ this.previewUrls.forEach(url => {
+ try {
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ // Ignore errors during cleanup
+ }
+ });
+ this.previewUrls.clear();
+ }
+ }
+
+ /*******************************************************************************
+ * UI UPDATE METHODS
+ *******************************************************************************/
+
+ updateFieldState(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldEl || !fieldData) return;
+
+ const container = fieldEl.element;
+ const uploadCount = fieldData.uploads?.size || 0;
+ const hasGroups = fieldEl.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
+
+ container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
+ container.dataset.uploadCount = uploadCount.toString();
+ container.dataset.hasGroups = hasGroups ? 'true' : 'false';
+
+ if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.setAttribute('aria-label',
+ `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
+ );
+ }
+ }
+
+ updateUploadProgress(fieldId, current, total, message) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl?.ui?.progress?.progress) return;
+
+ const progress = fieldEl.ui.progress;
+ const percent = total > 0 ? (current / total) * 100 : 0;
+
+ if (progress.fill) progress.fill.style.width = `${percent}%`;
+ if (progress.text) progress.text.textContent = message;
+ if (progress.count) progress.count.textContent = `${current}/${total}`;
+
+ progress.progress.hidden = (current === total);
+ }
+
+ updateFieldStatus(fieldId, status) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ fieldData.state = status;
+ this.saveFieldData(fieldData);
+ }
+
+ updateUploadStatus(uploadId, status) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload) return;
+
+ upload.status = status;
+ this.uploadStore.save(upload);
+ this.updateUploadUI(uploadId);
+ }
+
+ updateUploadUI(uploadId) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload || !uploadEl?.element) return;
+
+ uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
+ uploadEl.element.classList.add(`status-${upload.status}`);
+
+ const progress = uploadEl.element.querySelector('.progress');
+ if (progress) {
+ this.updateUploadItemProgress(uploadId,
+ this.getStatusProgress(upload.status),
+ upload.status
+ );
+ }
+ }
+
+ showUploadProgress(uploadId, show = true) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!uploadEl?.element) return;
+
+ const progressEl = uploadEl.element.querySelector('.progress');
+ if (progressEl) {
+ if (show) {
+ progressEl.style.removeProperty('animation');
+ progressEl.hidden = false;
+ } else {
+ progressEl.style.animation = 'fadeOut var(--transition-base)';
+ setTimeout(() => { progressEl.hidden = true; }, 300);
+ }
+ }
+ }
+
+ updateUploadItemProgress(uploadId, percent, status = null) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!uploadEl?.element) return;
+
+ const progressEl = uploadEl.element.querySelector('.progress');
+ if (!progressEl) return;
+
+ const fill = progressEl.querySelector('.fill');
+ const details = progressEl.querySelector('.details');
+ const icon = progressEl.querySelector('.icon');
+
+ if (fill) fill.style.width = `${percent}%`;
+ if (status && details) details.textContent = this.getStatusText(status);
+ if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML;
+ }
+
+ maybeLockUploads(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldEl?.ui?.dropZone || !fieldData) return;
+
+ if (fieldData.config.destination === 'post_group') return;
+
+ const uploadCount = fieldData.uploads?.size || 0;
+ const maxFiles = fieldData.config?.maxFiles || 999;
+
+ fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
+ fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
+ }
+
+ /*******************************************************************************
+ * GROUP MANAGEMENT
+ *******************************************************************************/
+
+ createGroup(fieldId, groupId = null) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData || !fieldEl) return null;
+
+ if (!groupId) {
+ groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ }
+
+ const groupElement = this.createGroupElement(groupId, fieldId);
+ if (!groupElement) return null;
+
+ // Store in field UI Map
+ if (!fieldEl.ui.groups) {
+ fieldEl.ui.groups = {
+ groups: new Map(),
+ container: null,
+ empty: null,
+ display: null
+ };
+ }
+
+ fieldEl.ui.groups.groups.set(groupId, groupElement);
+
+ // Insert into DOM
+ if (fieldEl.ui.groups.container && fieldEl.ui.groups.empty) {
+ fieldEl.ui.groups.container.insertBefore(groupElement, fieldEl.ui.groups.empty);
+ } else if (fieldEl.ui.groups.container) {
+ fieldEl.ui.groups.container.appendChild(groupElement);
+ }
+
+ // Store group element reference
+ const grid = groupElement.querySelector('.item-grid.group');
+ this.groupElements.set(groupId, {
+ element: groupElement,
+ grid: grid,
+ fieldId: fieldId
+ });
+
+ // Add to field groups
+ if (!fieldData.groups) fieldData.groups = [];
+ fieldData.groups.push({
+ id: groupId,
+ uploads: [],
+ changes: {}
+ });
+ this.saveFieldData(fieldData);
+
+ // Initialize selection handler and sortable
+ this.addGroupSelectionHandler(fieldId, groupId);
+
+ // Initialize sortable for this new group grid
+ if (grid) {
+ const sortableInstance = new Sortable(grid, {
+ ...this.sortableConfig,
+ group: { name: fieldId, pull: true, put: true },
+ disabled: true, // Empty initially
+ onAdd: (evt) => this.updateSortableState(evt.to),
+ onRemove: (evt) => this.updateSortableState(evt.from)
+ });
+ grid.sortableInstance = sortableInstance;
+ this.sortableInstances.set(`${fieldId}-group-${groupId}`, sortableInstance);
+ }
+
+ return { id: groupId, element: groupElement, grid: grid };
+ }
+
+ createGroupElement(groupId, fieldId) {
+ let groupElement = window.getTemplate('imageGroup');
+ if (!groupElement) return;
+
+ groupElement.dataset.groupId = groupId;
+ groupElement.dataset.fieldId = fieldId;
+
+ let fields = window.getTemplate('groupMetadata');
+ const fieldsContainer = groupElement.querySelector('.fields');
+ if (fieldsContainer && fields) {
+ fieldsContainer.append(fields);
+
+ const titleInput = fieldsContainer.querySelector('[name="post_title"]');
+ const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
+
+ if (titleInput) {
+ titleInput.id = `${groupId}_title`;
+ titleInput.name = `${groupId}[post_title]`;
+ }
+ if (excerptInput) {
+ excerptInput.id = `${groupId}_excerpt`;
+ excerptInput.name = `${groupId}[post_excerpt]`;
+ }
+
+ const fieldData = this.getFieldData(fieldId);
+ if (fieldData && fieldData.config.content !== '') {
+ let summary = groupElement.querySelector('summary');
+ if (summary) summary.textContent = fieldData.config.content + ' Fields';
+ }
+ } else {
+ groupElement.querySelector('details')?.remove();
+ }
+
+ const gridContainer = groupElement.querySelector('.item-grid.group');
+ if (gridContainer) {
+ gridContainer.dataset.groupId = groupId;
+ }
+
+ return groupElement;
+ }
+
+ deleteGroup(groupId, confirm = true) {
+ const groupEl = this.groupElements.get(groupId);
+ if (!groupEl) return;
+
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ if (!fieldData) return;
+
+ const group = fieldData.groups?.find(g => g.id === groupId);
+ let keepUploads = true;
+
+ if (confirm && group?.uploads?.length > 0) {
+ keepUploads = !window.confirm('Delete uploads in group?');
+ }
+
+ if (confirm && keepUploads && group?.uploads) {
+ // Move uploads back to preview
+ group.uploads.forEach(uploadId => {
+ this.removeFromGroup(uploadId);
+ });
+ }
+
+ // Remove from field groups
+ if (fieldData.groups) {
+ fieldData.groups = fieldData.groups.filter(g => g.id !== groupId);
+ this.saveFieldData(fieldData);
+ }
+
+ // Remove DOM element
+ if (groupEl.element) {
+ groupEl.element.remove();
+ this.a11y.announce('Group removed');
+ }
+
+ // Remove from maps
+ this.groupElements.delete(groupId);
+
+ // Clean up sortable
+ const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
+ const sortable = this.sortableInstances.get(sortableKey);
+ if (sortable?.destroy) {
+ sortable.destroy();
+ }
+ this.sortableInstances.delete(sortableKey);
+
+ this.schedulePersistance(groupEl.fieldId);
+ }
+
+ addToGroup(uploadId, target = null, persist = true) {
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!upload || !uploadEl) return;
+
+ const fieldData = this.getFieldData(upload.fieldId);
+ const fieldEl = this.fieldElements.get(upload.fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Already in correct location
+ if ((!target && uploadEl.location === fieldEl.ui.preview) || target === uploadEl.location) {
+ return;
+ }
+
+ // Remove from previous group
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId);
+ }
+ }
+ }
+
+ // Clear selection checkbox
+ const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
+ if (checkbox) checkbox.checked = false;
+
+ let featured = uploadEl.element.querySelector('[name="featured"]');
+ if (featured) featured.hidden = !target;
+
+ // Moving to preview or to group
+ if (!target || target.classList.contains('preview')) {
+ target = fieldEl.ui.preview;
+ upload.groupId = null;
+ } else {
+ // Moving to group
+ const groupId = target.dataset.groupId;
+ if (featured) featured.name = groupId + '_' + featured.name;
+
+ const group = fieldData.groups?.find(g => g.id === groupId);
+ if (group) {
+ if (!group.uploads) group.uploads = [];
+ group.uploads.push(uploadId);
+ upload.groupId = groupId;
+ }
+ }
+
+ // Update location
+ uploadEl.location = target;
+ target.append(uploadEl.element);
+
+ // Update stores
+ this.uploadStore.save(upload);
+ if (persist) {
+ this.saveFieldData(fieldData);
+ }
+
+ // Update sortable state
+ this.updateSortableState(target);
+ if (uploadEl.location && uploadEl.location !== target) {
+ this.updateSortableState(uploadEl.location);
+ }
+ }
+
+ removeFromGroup(uploadId) {
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!upload || !uploadEl) return;
+
+ const fieldData = this.getFieldData(upload.fieldId);
+ const fieldEl = this.fieldElements.get(upload.fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Remove from current group
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId, false);
+ }
+ }
+ upload.groupId = null;
+ }
+
+ // Move back to preview
+ if (fieldEl.ui?.preview) {
+ fieldEl.ui.preview.appendChild(uploadEl.element);
+ uploadEl.location = fieldEl.ui.preview;
+ }
+
+ // Hide featured radio
+ const featured = uploadEl.element.querySelector('[name="featured"]');
+ if (featured) {
+ featured.hidden = true;
+ featured.checked = false;
+ }
+
+ this.uploadStore.save(upload);
+ this.updateSortableState(fieldEl.ui.preview);
+ }
+
+ removeUpload(fieldId, uploadId) {
+ const fieldData = this.getFieldData(fieldId);
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+
+ if (!fieldData || !upload) return;
+
+ // Remove from field
+ fieldData.uploads?.delete(uploadId);
+
+ // Remove from group if grouped
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId);
+ }
+ }
+ }
+
+ // Clean up element
+ uploadEl?.element?.remove();
+
+ // Clean up memory
+ this.clearUpload(uploadId);
+
+ // Update field state
+ this.saveFieldData(fieldData);
+ this.updateFieldState(fieldId);
+ this.maybeLockUploads(fieldId);
+
+ const handler = this.selectionHandlers.get(fieldId);
+ if (handler) {
+ handler.deselect(uploadId);
+ }
+
+ this.a11y.announce('Upload removed');
+ }
+
+ handleGroupMetaChange(input) {
+ const groupEl = this.getGroupFromElement(input);
+ if (!groupEl) return;
+
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ const group = fieldData?.groups?.find(g => g.id === groupEl.element.dataset.groupId);
+ if (!group) return;
+
+ if (!group.changes) group.changes = {};
+
+ let name = input.name;
+ if (name.includes('group')) {
+ name = name.replace(`${group.id}_`, '').replace(`${group.id}[`, '').replace(']', '');
+ }
+
+ group.changes[name] = input.value;
+ this.saveFieldData(fieldData);
+ this.schedulePersistance(groupEl.fieldId);
+ }
+
+ /*******************************************************************************
+ * ACTION HANDLERS
+ *******************************************************************************/
+
+ handleAction(button) {
+ const action = button.dataset.action;
+ const fieldId = this.getFieldIdFromElement(button);
+
+ switch(action) {
+ case 'add-to-group':
+ this.handleAddToGroup(button);
+ break;
+ case 'delete-group':
+ this.handleDeleteGroup(button);
+ break;
+ case 'delete-upload':
+ case 'remove-from-group':
+ this.handleRemoveItem(button);
+ break;
+ case 'upload':
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (fieldEl) {
+ fieldEl.element.closest('details').open = false;
+ document.body.classList.add('uploading');
+ this.submitUploads(fieldId);
+ }
+ break;
+ case 'restore':
+ this.handleRestoreUploads().then(() => {});
+ break;
+ case 'clear-cache':
+ if (!confirm('Save these uploads for later?')) {
+ this.cleanupStoredUploads();
+ }
+ this.cleanupRestore();
+ break;
+ }
+ }
+
+ handleAddToGroup(button) {
+ const fieldElement = button.closest(this.selectors.field.field);
+ const fieldId = fieldElement?.dataset.uploader;
+ if (!fieldId) return;
+
+ const selected = this.selected.get(fieldId);
+
+ if (!selected || selected.size === 0) {
+ this.createGroup(fieldId);
+ } else {
+ const group = this.createGroup(fieldId);
+ if (!group) return;
+
+ selected.forEach(uploadId => {
+ this.addToGroup(uploadId, group.grid);
+ });
+
+ const handler = this.selectionHandlers.get(fieldId);
+ handler?.clearSelection();
+
+ this.a11y.announce(`Created group with ${selected.size} items`);
+ }
+
+ this.schedulePersistance(fieldId);
+ }
+
+ handleDeleteGroup(button) {
+ const group = button.closest(this.selectors.groups.container);
+ if (!group) return;
+
+ const groupId = group.dataset.groupId;
+ const fieldId = this.getFieldIdFromElement(group);
+
+ if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
+ return;
+ }
+
+ const items = group.querySelectorAll(this.selectors.items.item);
+ items.forEach(item => {
+ const uploadId = item.dataset.uploadId;
+ this.removeFromGroup(uploadId);
+ });
+
+ this.deleteGroup(groupId);
+ this.a11y.announce('Group deleted, items returned to upload area');
+ this.schedulePersistance(fieldId);
+ }
+
+ handleRemoveItem(button) {
+ const item = button.closest(this.selectors.items.item);
+ if (!item) return;
+
+ const uploadId = item.dataset.uploadId;
+ const fieldId = this.getFieldIdFromElement(item);
+
+ if (!confirm('Remove this item?')) return;
+
+ this.removeUpload(fieldId, uploadId);
+ this.a11y.announce('Item removed');
+ this.schedulePersistance(fieldId);
+ }
+
+ /*******************************************************************************
+ * SELECTION MANAGEMENT
+ *******************************************************************************/
+
+ addFieldSelectionHandler(fieldId) {
+ if (this.selectionHandlers.has(fieldId)) {
+ return this.selectionHandlers.get(fieldId);
+ }
+
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl?.element) return;
+
+ const handler = new window.jvbHandleSelection({
+ container: fieldEl.element,
+ ui: {
+ selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'),
+ bulkControls: fieldEl.element.querySelector('.selection-actions'),
+ count: fieldEl.element.querySelector('.selection-count')
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ case 'item-deselected':
+ case 'range-selected':
+ this.selected.set(fieldId, data.selectedItems);
+ break;
+ case 'select-all':
+ this.handleSelectAll(data.container, data.selected);
+ break;
+ }
+ });
+
+ this.selectionHandlers.set(fieldId, handler);
+ return handler;
+ }
+
+ addGroupSelectionHandler(fieldId, groupId) {
+ const handlerKey = `${fieldId}_${groupId}`;
+ if (this.selectionHandlers.has(handlerKey)) {
+ return this.selectionHandlers.get(handlerKey);
+ }
+
+ const groupEl = this.groupElements.get(groupId);
+ if (!groupEl?.element) return;
+
+ const handler = new window.jvbHandleSelection({
+ container: groupEl.element,
+ ui: {
+ selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll),
+ bulkControls: groupEl.element.querySelector(this.selectors.groups.actions),
+ count: groupEl.element.querySelector(this.selectors.groups.count)
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ case 'item-deselected':
+ case 'range-selected':
+ this.selected.set(fieldId, data.selectedItems);
+ break;
+ case 'select-all':
+ this.handleSelectAll(data.container, data.selected);
+ break;
+ }
+ });
+
+ this.selectionHandlers.set(handlerKey, handler);
+ return handler;
+ }
+
+ handleSelectAll(container, selected) {
+ // 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
+ *******************************************************************************/
+
+ /**
+ * Get field data from store and normalize it
+ * Always use this instead of directly accessing fieldStore.get()
+ */
+ getFieldData(fieldId) {
+ const fieldData = this.fieldStore.get(fieldId);
+ if (!fieldData) return null;
+
+ // Only convert uploads back to Set (DataStore returns Arrays)
+ if (Array.isArray(fieldData.uploads)) {
+ fieldData.uploads = new Set(fieldData.uploads);
+ } else if (!fieldData.uploads) {
+ fieldData.uploads = new Set();
+ }
+
+ // Ensure groups is an array
+ if (!Array.isArray(fieldData.groups)) {
+ fieldData.groups = [];
+ }
+
+ return fieldData;
+ }
+
+ /**
+ * Save field data to store, converting Sets to Arrays
+ */
+ async saveFieldData(fieldData) {
+ await this.fieldStore.save({
+ ...fieldData,
+ timestamp: Date.now()
+ });
+ }
+
+ determineFieldId(fieldElement) {
+ const content = fieldElement.dataset.content ||
+ fieldElement.closest('dialog')?.dataset.content ||
+ fieldElement.closest('form')?.dataset.save || '';
+ const itemID = fieldElement.dataset.itemId ||
+ fieldElement.closest('dialog')?.dataset.itemId || '';
+ const field = fieldElement.dataset.field || '';
+
+ return `${content}_${itemID}_${field}`;
+ }
+
+ getFromElement(element, type) {
+ const map = {
+ 'field': {
+ selector: this.selectors.field.field,
+ key: 'uploader',
+ getRuntimeData: (id) => this.fieldElements.get(id),
+ getStoreData: (id) => this.getFieldData(id)
+ },
+ 'upload': {
+ selector: this.selectors.items.item,
+ key: 'uploadId',
+ getRuntimeData: (id) => this.uploadElements.get(id),
+ getStoreData: (id) => this.uploadStore.get(id)
+ },
+ 'group': {
+ selector: this.selectors.groups.container,
+ key: 'groupId',
+ getRuntimeData: (id) => this.groupElements.get(id),
+ getStoreData: (id) => {
+ // Groups are stored in field.groups array
+ const groupEl = this.groupElements.get(id);
+ if (!groupEl) return null;
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ return fieldData?.groups?.find(g => g.id === id);
+ }
+ }
+ };
+
+ const config = map[type];
+ if (!config) return null;
+
+ const el = element.closest(config.selector);
+ if (!el) return null;
+
+ const id = el.dataset[config.key];
+
+ // Return combined runtime + store data for convenience
+ const runtime = config.getRuntimeData(id);
+ const store = config.getStoreData(id);
+
+ return { ...runtime, ...store };
+ }
+
+ getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
+ getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
+ getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
+
+ getFieldIdFromElement(el) {
+ const field = this.getFromElement(el, 'field');
+ return field?.id ?? null;
+ }
+ getUploadIdFromElement(el) {
+ const upload = this.getFromElement(el, 'upload');
+ return upload?.id ?? null;
+ }
+ getGroupIdFromElement(el) {
+ const group = this.getFromElement(el, 'group');
+ return group?.id ?? null;
+ }
+
+ getSubtypeFromMime(mimeType) {
+ if (mimeType.startsWith('image/')) return 'image';
+ if (mimeType.startsWith('video/')) return 'video';
+ return 'document';
+ }
+
+ getStatusText(status) {
+ return this.statusMapping[status] || status;
+ }
+
+ getStatusIcon(status) {
+ return window.getIcon(this.queue.icons[status]);
+ }
+
+ getStatusProgress(status) {
+ const progress = {
+ 'local_processing': 28,
+ 'queued': 50,
+ 'uploading': 66,
+ 'pending': 75,
+ 'processing': 89,
+ 'completed': 100
+ };
+ 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');
+ if (!image) return;
+
+ image.dataset.uploadId = upload.id;
+ image.dataset.subtype = upload.subtype || 'image';
+
+ let [featured, img, video, preview, details] = [
+ image.querySelector('[name="featured"]'),
+ image.querySelector('img'),
+ image.querySelector('video'),
+ image.querySelector('label > span'),
+ image.querySelector('details')
+ ];
+
+ if (featured) featured.value = upload.id;
+
+ switch (upload.subtype) {
+ case 'image':
+ if (img) {
+ img.src = upload.preview;
+ img.alt = upload.meta?.originalName || '';
+ }
+ video?.remove();
+ preview?.remove();
+ break;
+ case 'video':
+ if (video) video.src = upload.preview;
+ img?.remove();
+ preview?.remove();
+ break;
+ case 'document':
+ const fileName = upload.meta?.originalName || '';
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+ const iconMap = {
+ 'pdf': 'file-pdf', 'csv': 'file-csv',
+ 'doc': 'file-doc', 'docx': 'file-doc',
+ 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
+ };
+ const icon = window.getIcon(iconMap[extension] || 'file');
+ if (preview) {
+ preview.innerText = fileName;
+ preview.prepend(icon);
+ }
+ img?.remove();
+ video?.remove();
+ break;
+ }
+
+ if (details) {
+ let template = window.getTemplate('uploadMeta');
+ if (template) details.append(template);
+ }
+
+ image.draggable = draggable;
+
+ // Update input IDs
+ image.querySelectorAll('input').forEach(input => {
+ let id = input.id;
+ if (id) {
+ let newId = id + upload.id;
+ let label = input.parentNode.querySelector(`label[for="${id}"]`);
+ input.id = newId;
+ if (label) label.htmlFor = newId;
+ }
+ });
+
+ return image;
+ }
+
+ /*******************************************************************************
+ * 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(
+ key,
+ () => this.persistFieldState(fieldId),
+ 1000
+ );
+ }
+
+ async persistFieldState(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ // Save with updated timestamp
+ await this.saveFieldData(fieldData);
+ }
+
+ // In UploadManager, add blob conversion helpers
+ async saveBlobData(uploadId, file) {
+ const arrayBuffer = await file.arrayBuffer();
+
+ const uploadData = this.uploadStore.get(uploadId) || { id: uploadId };
+
+ // Store blob data as ArrayBuffer with metadata
+ uploadData.blobData = {
+ buffer: arrayBuffer,
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ lastModified: file.lastModified || Date.now()
+ };
+
+ await this.uploadStore.save(uploadData);
+ }
+
+ async getBlobData(uploadId) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload?.blobData) return null;
+
+ // Reconstruct File from ArrayBuffer
+ const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type });
+ return new File([blob], upload.blobData.name, {
+ type: upload.blobData.type,
+ lastModified: upload.blobData.lastModified
+ });
+ }
+
+ /*******************************************************************************
+ * RECOVERY & RESTORATION
+ *******************************************************************************/
+
+ handleFieldStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ // Check for pending uploads to restore
+ this.checkForStoredUploads();
+ break;
+ }
+ }
+
+ handleUploadStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ break;
+ case 'item-saved':
+ this.showSaveIndicator(data.key);
+ break;
+ }
+ }
+
+ async checkForStoredUploads() {
+ const allFieldStates = this.fieldStore.getAll();
+
+ console.log('Checking for stored uploads...', {
+ fieldStates: allFieldStates.length,
+ uploadStoreSize: this.uploadStore.data.size
+ });
+ const pendingFields = allFieldStates.filter(field => {
+ if (!field.uploads) return false;
+
+ // Handle both Set and Array (from IndexedDB)
+ const uploadsArray = field.uploads instanceof Set
+ ? Array.from(field.uploads)
+ : Array.isArray(field.uploads)
+ ? field.uploads
+ : [];
+
+ return uploadsArray.some(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return upload && !upload.operationId &&
+ ['completed', 'processed', 'local_processing', 'processed-original'].includes(upload.status);
+ });
+ });
+ console.log('Found pending fields:', pendingFields.length);
+ if (pendingFields.length === 0) return;
+
+ this.showRecoveryNotification(pendingFields);
+ }
+
async showRecoveryNotification(pendingFields) {
const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
const totalGroups = pendingFields.reduce((sum, field) =>
@@ -2588,14 +2730,12 @@
// const imgEl = uploadItem.querySelector('img');
// const placeholderEl = uploadItem.querySelector('.image-placeholder');
//
- const blobData = await this.uploadStore.getBlob(upload.id);
+ const file = await this.getBlobData(upload.id);
+ if (file) {
-
- if (blobData) {
try {
// Create new blob URL from stored data
- const blob = new Blob([blobData.data], { type: blobData.type });
- const previewUrl = this.createPreviewUrl(blob);
+ const previewUrl = this.createPreviewUrl(file);
let [
featured,
@@ -2616,7 +2756,7 @@
uploadItem.dataset.fieldId = field.id;
- let subtype = this.getSubtypeFromMime(blobData.type);
+ let subtype = this.getSubtypeFromMime(file.type);
uploadItem.dataset.subtype = subtype;
switch (subtype) {
case 'image':
@@ -2625,7 +2765,7 @@
img.alt
] = [
previewUrl,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
+ file.name ?? upload.meta?.originalName ?? ''
];
video.remove();
preview.remove();
@@ -2720,336 +2860,23 @@
}
- async restoreSelectedUploads(selectedUploads) {
- // Group by field
- const byField = new Map();
- selectedUploads.forEach(item => {
- if (!byField.has(item.fieldId)) {
- byField.set(item.fieldId, []);
- }
- byField.get(item.fieldId).push(item.uploadId);
- });
-
- // Get full field states from IndexedDB
- if (!this.db) {
- // this.notifications.add('Cannot restore: Database not available', 'error');
+ async handleRestoreUploads() {
+ let notification = document.querySelector('dialog.restore-uploads');
+ if (!notification) {
return;
}
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const store = tx.objectStore('fieldStates');
-
- for (const [fieldId, uploadIds] of byField.entries()) {
- const request = store.get(fieldId);
- const fieldState = await new Promise(resolve => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => resolve(null);
- });
-
- if (fieldState) {
- // Filter to only selected uploads
- fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
- await this.restoreField(fieldState);
- }
- }
-
- // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success');
- }
-
- async restoreField(fieldState) {
- const { config, context, uploads, groups, id } = fieldState; // ← Use 'id'
-
- // If in a modal, open it first
- if (context.modalType) {
- await this.openModalForRestore(context);
- }
-
- // Find field element
- let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
-
- if (!fieldElement) {
- const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
- fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
- }
-
- if (!fieldElement) {
- console.warn(`Field ${config.name} not found for restoration`, config);
+ const selectedUploads = this.getSelectedRestorationUploads(notification);
+ if (selectedUploads.length === 0) {
return;
}
+ await this.restoreSelectedUploads(selectedUploads);
- // Register the field if not already registered
- let fieldKey = fieldElement.dataset.uploader;
- if (!fieldKey || !this.fields.has(fieldKey)) {
- fieldKey = this.registerUploader(fieldElement, config);
- }
-
- const field = this.fields.get(fieldKey);
- if (!field) {
- console.error('Failed to register field for restoration');
- return;
- }
-
- // Merge saved state back into field
- field.state = fieldState.state || 'ready';
-
- // Rebuild UI references
- field.ui = this.buildFieldUI(fieldElement);
-
- if (field.ui.groups?.display) {
- field.ui.groups.display.hidden = false;
- }
-
- // Restore groups
- if (groups && groups.length > 0) {
- await this.restoreGroups(fieldKey, groups);
- }
-
- // Restore uploads
- for (const uploadData of uploads) {
- await this.restoreUpload(field, uploadData);
- }
-
- // Update UI
- this.updateFieldState(fieldKey);
- this.maybeLockUploads(fieldKey);
-
- // Queue for upload if needed
- if (config.mode === 'direct' && config.destination !== 'post_group') {
- await this.queueUpload(fieldKey);
- }
+ this.cleanupRestore();
}
- async restoreUpload(field, uploadData) {
- // Try to get blob data from IndexedDB
- const blobData = await this.uploadStore.getBlob(uploadData.id);
-
- if (blobData) {
- const file = blobData.data instanceof File
- ? blobData.data
- : new File(
- [blobData.data],
- blobData.name,
- { type: blobData.type, lastModified: blobData.lastModified }
- );
-
- uploadData.originalFile = file;
- uploadData.processedFile = file;
- uploadData.preview = this.createPreviewUrl(file);
- } else {
- console.warn('Blob data not found for upload:', uploadData.id);
- return; // Skip this upload if we can't restore the file
- }
-
- // Add to field
- if (!field.uploads) field.uploads = new Set();
- field.uploads.add(uploadData.id);
-
- // Recreate DOM element
- const subtype = this.getSubtypeFromMime(uploadData.originalFile.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
- subtype: subtype
- }, field.config.destination === 'post_group');
-
- // Restore to correct location
- let location;
- if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) {
- location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid');
- } else {
- location = field.ui.preview;
- }
-
- if (location) {
- location.appendChild(uploadData.element);
- uploadData.location = location;
- }
-
- // Store in memory
- this.uploads.set(uploadData.id, uploadData);
- if (uploadData.groupId) {
- const group = this.groups.get(uploadData.groupId);
- if (group && group.uploads) {
- group.uploads.add(uploadData.id);
- }
- }
- }
-
- async restoreGroups(fieldKey, groups) {
- for (const groupData of groups) {
- // Use createGroup which properly initializes EVERYTHING including selection handlers
- const group = this.createGroup(fieldKey, groupData.id);
-
- if (group) {
- // Update the group metadata from saved state
- if (groupData.meta) {
- group.meta = { ...groupData.meta };
- }
- if (groupData.changes) {
- group.changes = { ...groupData.changes };
- }
-
-
- // If you saved group titles, restore them
- if (groupData.title) {
- const titleInput = group.element.querySelector('[name*="post_title"]');
- if (titleInput) {
- titleInput.value = groupData.title;
- }
- }
- }
- }
- }
-
- async openModalForRestore(context) {
- const { modalType, formId } = context;
-
- // Find and click the appropriate button to open the modal
- let trigger = null;
-
- switch(modalType) {
- case 'create':
- trigger = document.querySelector('[data-action="create"]');
- break;
- case 'edit':
- // Need to find the specific edit button
- trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`);
- break;
- case 'bulkEdit':
- trigger = document.querySelector('[data-action="bulk-edit"]');
- break;
- }
-
- if (trigger) {
- trigger.click();
-
- // Wait for modal to open
- await new Promise(resolve => setTimeout(resolve, 300));
- }
- }
-
- /*******************************************************************************
- INDEXEDDB CACHE FUNCTIONALITY
- *******************************************************************************/
- handleFieldStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
-
- break;
- case 'item-saved':
- console.log(`Field state saved: ${data.key}`);
- break;
- }
- }
-
- handleUploadStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
- this.checkForStoredUploads();
- break;
- case 'item-saved':
- this.showSaveIndicator(data.key);
- break;
- }
- }
- async saveUpload(upload) {
- // Use the processed file if available, otherwise original
- const fileToStore = upload.processedFile || upload.originalFile || upload.file;
-
- if (fileToStore instanceof File || fileToStore instanceof Blob) {
- await this.uploadStore.saveBlob(upload.id, fileToStore);
-
- // Don't store file objects in main store
- const { file, originalFile, processedFile, ...cleanUpload } = upload;
- await this.uploadStore.save(cleanUpload);
- } else {
- await this.uploadStore.save(upload);
- }
- }
-
- async loadFields() {
- // Load all field states from the store
- const fields = await this.fieldStore.getAll();
-
- fields.forEach(field => {
- // Reconstruct upload sets
- if (field.uploads && Array.isArray(field.uploads)) {
- field.uploads = new Set(field.uploads.map(u => u.id));
- }
- this.fields.set(field.fieldId, field);
- });
- }
-
- async loadUploads() {
- const uploads = await this.uploadStore.getAll();
- uploads.forEach(upload => {
- this.uploads.set(upload.id, upload);
- });
- }
-
- /**************************************************************************
- SUBSCRIBERS
- **************************************************************************/
- /**
- * Event system
- */
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- notify(event, data) {
- this.subscribers.forEach(cb => cb(event, data));
- }
- /*******************************************************************************
- * CLEANUP
- *******************************************************************************/
-
- destroy() {
- // Remove core listeners
- 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);
-
- // Destroy drag controller
- if (this.dragController) {
- this.dragController.destroy();
- }
-
- // Destroy selection handlers
- this.selectionHandlers.forEach(handler => handler.destroy());
- this.selectionHandlers.clear();
-
- this.cleanupAllPreviewUrls();
- this.sortableInstances.forEach(instance => {
- if (instance?.destroy) {
- instance.destroy();
- }
- });
- this.sortableInstances.clear();
-
- // Clear data
- this.fields.clear();
- this.uploads.clear();
- this.groups.clear();
- this.selected.clear();
- this.subscribers.clear();
- }
-
- destroySortable(fieldName) {
- // Destroy all sortable instances for this field
- const instances = Array.from(this.sortableInstances.keys())
- .filter(key => key.startsWith(fieldName));
-
- instances.forEach(key => {
- const instance = this.sortableInstances.get(key);
- if (instance?.destroy) {
- instance.destroy();
- }
- this.sortableInstances.delete(key);
- });
+ showSaveIndicator(key) {
+ // Optional: show user that state is being saved
}
cleanupRestore() {
@@ -3062,65 +2889,60 @@
}
async cleanupStoredUploads() {
- this.fieldStore.clear();
- this.uploadStore.clear();
+ await this.fieldStore.clear();
+ await this.uploadStore.clear();
}
- /**
- * Clear all uploads for a field and cleanup resources
- */
- async clearField(fieldId) {
- // Clear from stores
- await this.fieldStore.delete(fieldId);
+ /*******************************************************************************
+ * EVENT SYSTEM
+ *******************************************************************************/
- // Clear related uploads
- const field = this.fields.get(fieldId);
- if (field?.uploads) {
- for (const uploadId of field.uploads) {
- await this.uploadStore.delete(uploadId);
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach(cb => {
+ try {
+ cb(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
}
- }
-
- // Clear from memory
- this.fields.delete(fieldId);
+ });
}
- async clearUpload(uploadId, persist = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
+ /*******************************************************************************
+ * DESTROY & CLEANUP
+ *******************************************************************************/
- // Clean up preview URL using helper
- this.revokePreviewUrl(upload.preview);
+ 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);
- // Clean up element preview URL
- if (upload.element) {
- const previewUrl = upload.element.dataset.previewUrl;
- this.revokePreviewUrl(previewUrl);
- delete upload.element.dataset.previewUrl;
+ if (this.dragController) {
+ this.dragController.destroy();
}
- if (persist) {
- await this.schedulePersistance(upload.fieldId);
- }
+ this.selectionHandlers.forEach(handler => handler.destroy());
+ this.selectionHandlers.clear();
- // Remove from memory
- this.uploads.delete(uploadId);
+ this.cleanupAllPreviewUrls();
- // Remove from IndexedDB
- this.uploadStore.delete(uploadId);
- this.uploadStore.delete(uploadId, 'blobs');
- }
- cleanupAllPreviewUrls() {
- if (this.previewUrls) {
- this.previewUrls.forEach(url => {
- try {
- URL.revokeObjectURL(url);
- } catch (e) {
- // Ignore errors during cleanup
- }
- });
- this.previewUrls.clear();
- }
+ this.sortableInstances.forEach(instance => {
+ if (instance?.destroy) instance.destroy();
+ });
+ this.sortableInstances.clear();
+
+ this.uploadElements.clear();
+ this.fieldElements.clear();
+ this.groupElements.clear();
+ this.selected.clear();
+ this.subscribers.clear();
}
}
diff --git a/assets/js/concise/View.js b/assets/js/concise/View.js
index e1e5488..ca6c99e 100644
--- a/assets/js/concise/View.js
+++ b/assets/js/concise/View.js
@@ -12,6 +12,8 @@
this.store = store;
+ this.isTimeline = !!document.querySelector('[data-timeline]');
+
this.items = {
list: new Map(),
grid: new Map(),
@@ -19,6 +21,7 @@
}
this.currentView = 'grid';
this.selectedItems = new Set();
+ this.subscribers = new Set();
this.init();
}
@@ -27,8 +30,11 @@
this.selectors = {
grid: '.item-grid',
table: {
- table: 'table',
+ table: 'form.table',
+ form: 'table',
body: 'table body',
+ header: 'table thead',
+ footer: 'table tfoot',
selectedColumns: '.all-filters .multi-select',
columns: 'thead th'
},
@@ -147,6 +153,11 @@
this.render();
});
});
+
+ const checkedView = document.querySelector('[data-view]:checked');
+ if (checkedView) {
+ this.currentView = checkedView.dataset.view;
+ }
}
/**
@@ -162,16 +173,15 @@
* Handle items update
*/
handleItemsUpdate() {
- console.log(this.store.data);
- this.render(this.store.data);
+ this.render();
}
- render(items = []) {
+ render() {
if (!this.store) {
console.error('No store connected to renderer');
return;
}
- console.log(items);
+ const items = this.store.getFiltered();
// Handle empty state
if (items.length === 0) {
@@ -216,24 +226,15 @@
const fragment = document.createDocumentFragment();
items.forEach(item => {
- let card;
- if (this.store.renderOrRetrieve) {
- card = this.store.renderOrRetrieve(item, 'grid', this.renderGridItem.bind(this));
- } else {
- // Fallback to local cache
- if (this.items.grid.has(item.id)) {
- card = this.items.grid.get(item.id);
- } else {
- card = this.renderGridItem(item);
- this.items.grid.set(item.id, card);
- }
- }
-
+ let card = this.renderGridItem(item);
fragment.appendChild(card);
});
this.ui.grid.appendChild(fragment);
}
renderGridItem(item) {
+ if (this.items.grid.has(item.id)) {
+ return this.items.grid.get(item.id);
+ }
const card = window.getTemplate('gridView');
card.dataset.id = item.id;
@@ -284,7 +285,10 @@
item.images[item.fields.post_thumbnail]?.medium??'',
item.images[item.fields.post_thumbnail]?.alt??'',
];
+
// }
+
+ this.items.grid.set(item.id, card);
return card;
}
@@ -294,12 +298,24 @@
let table = window.getTemplate('contentTable');
this.container.append(table);
this.ui.table.table = this.container.querySelector('form.table');
- this.ui.table.body = this.ui.table.table.querySelector('tbody');
+ this.ui.table.form = this.ui.table.table.querySelector('table');
+ this.ui.table.header = this.ui.table.form.querySelector('thead');
+ this.ui.table.footer = this.ui.table.form.querySelector('tfoot');
+ this.ui.table.body = this.ui.table.form.querySelector('tbody');
this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns);
}
+
if (this.ui.table.table) {
this.ui.table.table.hidden = !on;
- window.removeChildren(this.ui.table.body);
+ if (on) {
+ this.notify('table-view', this.ui.table.table);
+ }else {
+ this.notify('not-table-view', this.ui.table.table);
+ }
+
+ if (this.ui.table.body){
+ window.removeChildren(this.ui.table.body);
+ }
}
this.ui.table.selectedColumns.hidden = !on;
}
@@ -313,22 +329,29 @@
this.toggleGrid();
items.forEach(item => {
- let row;
- if (this.items.table.has(item.id)) {
- row = this.items.table.get(item.id);
+
+ let row = (this.isTimeline) ? this.renderTimelineTableItem(item) : this.renderTableItem(item);
+
+ if (this.ui.table.body) {
+ this.ui.table.body.append(row);
} else {
- row = this.store.renderOrRetrieve(item, 'table', this.renderTableItem.bind(this));
- this.items.table.set(item.id, row);
+ if (!this.ui.table.footer) {
+ this.ui.table.footer = this.ui.table.table.querySelector('tfoot');
+ }
+ this.ui.table.form.insertBefore(row, this.ui.table.footer);
}
- this.ui.table.body.append(row);
});
window.jvbSelector.scanExistingFields();
+
}
renderTableItem(item) {
- let empty = ['',0];
+ if (this.items.table.has(item.id)) {
+ return this.items.table.get(item.id);
+ }
+
const row = window.getTemplate('tableView');
row.dataset.id = item.id;
@@ -346,87 +369,142 @@
item.status
];
+ // Let jvbPopulate do its thing - NO prefixing needed!
+ new window.jvbPopulate(row, item.fields, item.images);
- row.querySelectorAll('td[data-field]').forEach(field => {
- let value = item.fields[field.dataset.field];
- // field.querySelectorAll('label').forEach(label => {
- // label.hidden = true;
- // });
+ // Clean up after population
+ this.cleanupTableRow(row);
- let label = field.querySelector('label');
- let isEmpty = (empty.includes(value));
- let temp;
- switch (field.dataset.fieldType) {
- case 'text':
- case 'number':
- case 'url':
- case 'tel':
- case 'email':
- if (!isEmpty) {
- field.querySelector('input').value = value;
- }
- label.remove();
- break;
- case 'textarea':
- if (!isEmpty) {
- field.querySelector('textarea').value = value;
- }
- label.remove();
- break;
- case 'taxonomy':
- label.remove();
- if (!isEmpty) {
- temp = field.querySelector('input[type=hidden]');
- temp.value = value;
- }
- break;
- case 'image':
- if (!isEmpty) {
- let image = window.getTemplate('uploadItem');
- let img = image.querySelector('img');
- [
- img.src,
- img.alt
- ] = [
- item.images[value].medium??'',
- item.images[value].alt??'',
- ];
- field.querySelector('.item-grid').append(image);
- field.querySelector('input[type=hidden]').value = value;
- }
- field.querySelectorAll('.progress,label,.upload-select,.status,details').forEach(item => {
- item.remove();
- });
- break;
- case 'true_false':
- if (!isEmpty) {
- field.querySelector('input').checked = parseInt(value) === 1;
- }
- field.querySelector('.toggle-label')?.remove();
- break;
- case 'select':
- label.remove();
- case 'radio':
- case 'checkbox':
- field.querySelector('.label')?.remove();
- if (!isEmpty) {
- value = value.split(',');
- value.forEach(v => {
- temp = field.querySelector(`[value="${v}"]`);
- if (temp) {
- temp.checked = true;
- }
- });
- }
- break;
- default:
- if (!isEmpty) {
- console.log(value);
- }
- break;
+ this.items.table.set(item.id, row);
+ return row;
+ }
+
+ renderTimelineTableItem(item) {
+ if (this.items.table.has(item.id)) {
+ return this.items.table.get(item.id);
+ }
+
+ const row = window.getTemplate('tableView');
+ row.dataset.id = item.id;
+
+ [
+ row.querySelector('.select-item').id,
+ row.querySelector('.select-item').value,
+ row.querySelector('.select-item').checked,
+ row.querySelector('.select-item + label').htmlFor,
+ ] = [
+ item.id,
+ item.id,
+ this.selectedItems.has(`${item.id}`),
+ item.id,
+ ];
+
+ let timelinePoint = row.querySelector('.timeline-point');
+ let tbody = row;
+
+ // Populate shared fields - NO prefixing!
+ let sharedRow = row.querySelector('tr.shared');
+ new window.jvbPopulate(sharedRow, item.fields, item.images);
+ this.prefixTimelineFieldNames(sharedRow, item.id);
+ this.cleanupTableRow(sharedRow);
+
+ // Handle timeline points - NO prefixing!
+ if (item.fields.timeline && typeof item.fields.timeline === 'object') {
+ const timelineArray = Object.entries(item.fields.timeline);
+
+ timelineArray.forEach(([imgId, timeline], index) => {
+
+ let point = timelinePoint.cloneNode(true);
+ point.dataset.index = index;
+ point.dataset.imageId = imgId;
+
+
+ new window.jvbPopulate(point, timeline, item.images);
+
+ this.cleanupTableRow(point);
+ let imgdata = item.images[timeline.post_thumbnail];
+ if (imgdata) {
+ point.querySelector('.field.upload').title = imgdata['image-title'];
+ }
+
+ this.prefixTimelineFieldNames(point,timeline.id);
+
+ tbody.insertBefore(point, timelinePoint);
+ });
+ }
+
+ timelinePoint.remove();
+
+ this.items.table.set(item.id, row);
+ return row;
+ }
+
+ /**
+ * Timeline uses bracket notation: [postId]fieldName
+ * This matches the collectTimeline() method in FormController
+ */
+ prefixTimelineFieldNames(row, postId) {
+ row.querySelectorAll('input, textarea, select').forEach(field => {
+ const currentName = field.name;
+
+ if (!currentName || currentName.startsWith('[') ||
+ currentName === 'form-id' || currentName.startsWith('_')) {
+ return;
+ }
+
+ // Use bracket notation for timeline
+
+ let label = field.nextElementSibling;
+ field.name = `[${postId}]${currentName}`;
+ if (label && label.tagName === 'LABEL') {
+ field.id = `[${postId}]${field.id}`;
+ label.htmlFor = field.id;
}
});
- return row;
+ }
+
+ cleanupTableRow(row) {
+ row.querySelectorAll('td[data-field]').forEach(field => {
+ // Remove labels (they're in the header)
+ field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => {
+ if (!label.closest('.radio-options')) {
+ label.remove();
+ }
+ });
+
+ // Special handling for image/upload fields
+ // if (field.dataset.fieldType === 'image' || field.dataset.fieldType === 'upload') {
+ // const itemGrid = field.querySelector('.item-grid');
+ // const uploadContainer = field.querySelector('.file-upload-container');
+ //
+ // // If grid has items (populated), just remove upload UI
+ // if (itemGrid && itemGrid.children.length > 0) {
+ // // Remove upload controls but keep the populated items
+ // field.querySelectorAll('.progress, .upload-select, .status, details:not(.item-grid details)').forEach(el => {
+ // el.remove();
+ // });
+ // // Keep upload container hidden if it was hidden
+ // if (uploadContainer && uploadContainer.hidden) {
+ // uploadContainer.hidden = true;
+ // }
+ // } else {
+ // // No items, remove all upload UI
+ // field.querySelectorAll('.file-upload-wrapper, .progress, .upload-select, .status, details').forEach(el => {
+ // el.remove();
+ // });
+ // }
+ // }
+
+ // Remove toggle labels for true_false fields
+ if (field.dataset.fieldType === 'true_false') {
+ field.querySelector('.toggle-label')?.remove();
+ }
+
+ // Remove field labels for checkbox/radio groups
+ if (['checkbox', 'radio', 'select'].includes(field.dataset.fieldType)) {
+ field.querySelector('.label')?.remove();
+ }
+ });
}
renderList(items) {
@@ -437,19 +515,15 @@
this.ui.grid.classList.add('list-view');
items.forEach(item => {
- let row;
- if (this.items.list.has(item.id)) {
- row = this.items.list.get(item.id);
- } else {
- row = this.store.renderOrRetrieve(item, 'list', this.renderListItem.bind(this));
- this.items.list.set(item.id, row);
- }
-
+ let row = this.renderListItem(item);
this.ui.grid.appendChild(row);
});
}
renderListItem(item) {
+ if (this.items.list.has(item.id)) {
+ return this.items.list.get(item.id);
+ }
const row = window.getTemplate('listView');
row.dataset.id = item.id;
@@ -498,9 +572,158 @@
item.images[item.fields.post_thumbnail]?.alt??'',
]
}
+ this.items.list.set(item.id, row);
return row;
}
+ setupTimelineDragHandler() {
+ if (!this.isTimeline || this.currentView !== 'table') return;
+
+ // Clean up existing handler if any
+ if (this.timelineDragHandler) {
+ this.timelineDragHandler.destroy();
+ }
+
+ this.timelineDragHandler = new window.jvbDragHandler({
+ draggableSelector: '.timeline-point',
+ dropTargetSelector: '.timeline-point',
+ handleSelector: '.drag-handle',
+
+ getItemId: (element) => {
+ return element.dataset.imageId;
+ },
+
+ getSelectedItems: () => {
+ return [];
+ },
+
+ validateDrop: (itemIds, dropTarget) => {
+ const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`);
+ if (!draggedRow) return false;
+
+ const draggedTbody = draggedRow.closest('tbody');
+ const targetTbody = dropTarget.closest('tbody');
+
+ return draggedTbody === targetTbody;
+ },
+
+ onDragStart: (itemIds, element) => {
+ element.classList.add('is-dragging');
+ },
+
+ onDrop: (itemIds, dropTarget) => {
+ const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`);
+ if (!draggedRow) return;
+
+ // Remove all drop indicators
+ document.querySelectorAll('.drop-above, .drop-below').forEach(el => {
+ el.classList.remove('drop-above', 'drop-below');
+ });
+
+ const tbody = draggedRow.closest('tbody');
+ const dropPosition = dropTarget.dataset.dropPosition;
+
+ // Insert based on drop position
+ if (dropPosition === 'above') {
+ tbody.insertBefore(draggedRow, dropTarget);
+ } else {
+ tbody.insertBefore(draggedRow, dropTarget.nextSibling);
+ }
+
+ draggedRow.classList.remove('is-dragging');
+ this.updateTimelineOrder(tbody);
+ },
+
+ onDragEnd: (itemIds, success) => {
+ // Clean up all drag classes
+ document.querySelectorAll('.is-dragging, .drop-above, .drop-below').forEach(el => {
+ el.classList.remove('is-dragging', 'drop-above', 'drop-below');
+ });
+ },
+
+ previewElement: '.drag-handle',
+ previewOptions: {
+ offset: { x: -20, y: -20 },
+ showCount: false
+ }
+ });
+
+ // Add custom hover logic for better drop positioning
+ this.addTimelineDragHoverLogic();
+ }
+
+ addTimelineDragHoverLogic() {
+ let currentHover = null;
+
+ document.addEventListener('pointermove', (e) => {
+ if (!document.querySelector('.timeline-point.is-dragging')) return;
+
+ const target = e.target.closest('.timeline-point:not(.is-dragging)');
+ if (!target) {
+ if (currentHover) {
+ currentHover.classList.remove('drop-above', 'drop-below');
+ delete currentHover.dataset.dropPosition;
+ currentHover = null;
+ }
+ return;
+ }
+
+ // Determine if we're in the top or bottom half
+ const rect = target.getBoundingClientRect();
+ const midpoint = rect.top + (rect.height / 2);
+ const isTopHalf = e.clientY < midpoint;
+
+ // Update classes
+ if (currentHover && currentHover !== target) {
+ currentHover.classList.remove('drop-above', 'drop-below');
+ delete currentHover.dataset.dropPosition;
+ }
+
+ target.classList.remove('drop-above', 'drop-below');
+ target.classList.add(isTopHalf ? 'drop-above' : 'drop-below');
+ target.dataset.dropPosition = isTopHalf ? 'above' : 'below';
+
+ currentHover = target;
+ });
+ }
+ updateTimelineOrder(tbody) {
+ const postId = parseInt(tbody.dataset.id);
+ const rows = Array.from(tbody.querySelectorAll('.timeline-point'));
+
+ const item = this.store.get(postId);
+ if (!item) return;
+
+ let timeline = {};
+ // Update menu_order for each timeline point
+ rows.forEach((row, index) => {
+ const imgID = row.dataset.imageId;
+ timeline[imgID] = item.fields.timeline[imgID];
+ });
+ item.fields.timeline = timeline;
+
+ // Update store (triggers autosave)
+ this.store.save(item);
+ this.notify('order-changed', postId);
+ this.a11y?.announce(`Timeline order updated. ${rows.length} steps reordered.`);
+ }
+
+ extractRowFields(row) {
+ const fields = {};
+ row.querySelectorAll('[data-field]').forEach(cell => {
+ const fieldName = cell.dataset.field;
+ const input = cell.querySelector('input, textarea, select');
+
+ if (input) {
+ if (input.type === 'checkbox') {
+ fields[fieldName] = input.checked;
+ } else {
+ fields[fieldName] = input.value;
+ }
+ }
+ });
+ return fields;
+ }
+
toggleSelection(id) {
if (this.selectedItems.has(id)) {
this.selectedItems.delete(id);
@@ -545,6 +768,19 @@
this.ui.bulk.count.textContent = count === 0 ? '' : `${count} ${item} selected`;
}
}
+
+
+ /**
+ * Event system
+ */
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data) {
+ this.subscribers.forEach(cb => cb(event, data));
+ }
}
window.jvbViews = ViewController;
diff --git a/assets/js/dash/A11yHelper.js b/assets/js/dash/A11yHelper.js
index 02be1a0..9723d6f 100644
--- a/assets/js/dash/A11yHelper.js
+++ b/assets/js/dash/A11yHelper.js
@@ -132,4 +132,6 @@
}
}
-window.jvbA11y = new A11yHelper();
+document.addEventListener('DOMContentLoaded', function() {
+ window.jvbA11y = new A11yHelper();
+});
diff --git a/assets/js/dash/CRUD.js b/assets/js/dash/CRUD.js
index dfb568b..6780203 100644
--- a/assets/js/dash/CRUD.js
+++ b/assets/js/dash/CRUD.js
@@ -4,7 +4,6 @@
class CRUDManager {
constructor(config) {
this.queue = window.jvbQueue;
- console.log(this.queue);
this.config = config;
this.content = config.content || false;
this.settings = window.jvbUserSettings;
@@ -13,37 +12,71 @@
return;
}
this.isTimeline = false;
+ this.currentItemID = null;
this.initElements();
this.updateBulkOptions();
// Initialize components
- this.store = new window.jvbStore({
- name: this.content,
- storeName: this.content,
- endpoint: 'content',
- headers: {
- 'action_nonce': jvbSettings.dash,
- },
- indexes: [
- { name: 'status', keyPath: 'post_status'},
- { name: 'modified', keyPath: 'modified'},
- ],
- filters: {
- content: this.content,
- user: jvbSettings.currentUser,
- page: 1,
- status: 'all'
- },
- TTL: 3600000,
- cacheDOM: true
- });
+ this.store = window.jvbStore.register(
+ this.content,
+ {
+ keyPath: 'id',
+ endpoint: 'content',
+ headers: {
+ 'action_nonce': jvbSettings.dash,
+ },
+ indexes: [
+ {name: 'id', keyPath: 'id'},
+ { name: 'status', keyPath: 'status'},
+ { name: 'date', keyPath: 'date'},
+ { name: 'modified', keyPath: 'modified'},
+ { name: 'title', keyPath: 'title'}
+ ],
+ filters: {
+ content: this.content,
+ user: jvbSettings.currentUser,
+ page: 1,
+ status: 'all',
+ orderby: 'modified', //or title
+ order: 'desc'
+ },
+ TTL: 30 * 60 * 1000, //30 minutes cache
+ });
this.status = 'all';
this.filterTimeout = null;
this.viewController = new window.jvbViews(this.ui.container, this.store);
- this.formController = new window.jvbForm();
+ this.tableForm = null;
+ this.tableChanges = new Map();
+
+
+ this.formController = (this.isTimeline) ? new window.jvbForm({collectFormData: () => this.collectTimelineData.bind(this)}) : new window.jvbForm();
+ this.viewController.subscribe((event, form) => {
+ if (event === 'table-view' && !this.tableForm) {
+ if (!this.tableForm) {
+ this.tableForm = this.formController.registerForm(form, {
+ autosave: false,
+ formStatus: false,
+ isTable: true,
+ });
+ }
+
+ } else if (event === 'not-table-view') {
+ if (this.tableForm) {
+
+ }
+ } else if (event === 'order-changed') {
+ let data = this.store.get(form);
+ if (!data) {
+ return;
+ }
+ let changes = {};
+ changes[form] = data;
+ this.savePosts(changes, `Updating progression order`);
+ }
+ });
this.formController.subscribe((event, data) => {
switch(event) {
@@ -54,15 +87,16 @@
}
});
- if (window.jvbQueue) {
- window.jvbQueue.subscribe((event, data) => {
- if (event === 'operation-completed' && data.source === 'form') {
- this.handleQueueSuccess(event, data);
- } else if (event === 'operation-failed-permanent' && data.source === 'form') {
- this.handleQueueFailure(event, data);
- }
- });
- }
+ this.queue.subscribe((event, data) => {
+ if (!Object.hasOwn(data, 'endpoint') || data.endpoint !== 'content') return;
+ console.log('Queue Subscription in CRUD.js: ', data);
+ if (event === 'operation-completed') {
+ this.handleQueueSuccess(event, data);
+ } else if (event === 'operation-failed-permanent') {
+ this.handleQueueFailure(event, data);
+ }
+ });
+
// Track initialization
this.initialized = false;
@@ -70,35 +104,32 @@
this.init();
}
handleFormChange(event, data) {
+ let title = data.fullData.post_title;
+ let changes = (Object.hasOwn(data, 'changes')) ? data.changes : data.fullData;
+
+ let theChanges = {};
if (this.isTimeline) {
- data.changes.content = this.content;
- let changes = {};
- let postID = data.config.id.replace('edit-', '');
- changes[postID] = data.changes;
- this.savePosts(changes, `Saving ${data.fullData.post_title}'s Changes`);
+ theChanges[this.currentItemID] = changes;
+ this.savePosts(theChanges, title);
return;
}
- data.changes.content = this.content;
- let changes = {};
- let title = '';
+
let itemsToRemove = [];
switch (true) {
case data.config.element === this.ui.forms.edit:
- let postID = data.config.id.replace('edit-', '');
- console.log(postID);
- changes[postID] = data.changes;
- title = `Saving ${data.fullData['post_title']} Changes`;
+ theChanges[this.currentItemID] = changes;
+ title = `Saving ${title} Changes`;
// Check if status change requires removal
- if (data.changes.post_status && this.shouldRemoveItem(data.changes.post_status)) {
- itemsToRemove.push(postID);
+ if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
+ itemsToRemove.push(this.currentItemID);
}
break;
case data.config.element === this.ui.forms.bulkEdit:
let selected = data.config.element.querySelectorAll('.selected input:checked');
selected.forEach(sel => {
- changes[sel.value] = data.changes;
+ theChanges[sel.value] = changes;
// Check if status change requires removal
- if (data.changes.post_status && this.shouldRemoveItem(data.changes.post_status)) {
+ if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
itemsToRemove.push(sel.value);
}
});
@@ -107,8 +138,8 @@
break;
case data.config.element === this.ui.forms.create:
if (event === 'form-submit') {
- changes[data.config.data['form-id']] = data.fullData;
- title = `Saving ${data.fullData['post_title']} Changes`;
+ theChanges[data.config.data['form-id']] = changes;
+ title = `Saving ${title} Changes`;
}
break;
}
@@ -134,11 +165,11 @@
}
}
- if (window.isEmptyObject(changes)) {
+ if (window.isEmptyObject(theChanges)) {
return;
}
- this.savePosts(changes, title);
+ this.savePosts(theChanges, title);
}
shouldRemoveItem(newStatus) {
@@ -150,6 +181,13 @@
if (window.isEmptyObject(changes)) {
return;
}
+
+ //ensure content is in each post
+ for (let postId in changes) {
+ if (!changes[postId]['content']) {
+ changes[postId]['content'] = this.content;
+ }
+ }
let operation = {
endpoint: 'content',
headers: {
@@ -165,15 +203,15 @@
this.queue.addToQueue(operation);
}
- handleQueueSuccess(event, data) {
- console.log('Handling queue success...');
- console.log('Event', event);
- console.log('Data', data);
+ async handleQueueSuccess(event, data) {
+ this.store.clearCache();
+ this.store.clearHttpHeaders();
+ this.store.fetch();
}
handleQueueFailure(event, data) {
- console.log('Handling queue failure...');
- console.log('Event', event);
- console.log('Data', data);
+ console.error('Operation failed permanently:', data);
+ // Optionally show error notification to user
+ this.a11y?.announce(`Operation failed: ${data.error_message || 'Unknown error'}`);
}
initElements() {
@@ -194,14 +232,11 @@
uploader: 'details.uploader'
};
this.ui = window.uiFromSelectors(this.elements);
- this.isTimeline = Object.hasOwn(this.ui.forms.edit.dataset, 'timeline');
+ this.isTimeline = !!document.querySelector('[data-timeline]');
}
init() {
this.settings.addSetting(this.ui.uploader, 'open');
this.ui.uploader.addEventListener('toggle', (e) =>{
- console.log(e);
- console.log('Is Open: ', this.ui.uploader.open);
- console.log(this.ui.uploader.open ? 'on' : 'off');
this.settings.saveSetting('open', this.ui.uploader.open ? 'on' : 'off');
});
@@ -219,9 +254,9 @@
this.modals[name].subscribe((event, data) => {
switch (event) {
case 'modal-close':
+ this.currentItemID = null;
this.formController.cleanupForm(this.modals[name].modal.querySelector('form').dataset.formId);
//double check we have finished saving
- console.log('Data on modal close: ', data);
break;
case 'modal-open':
//probably not needed in this class
@@ -235,15 +270,6 @@
this.setupFilters();
-
- this.queue.subscribe((event, data) => {
- switch (event) {
- case 'operation-status':
- //update items?
- break;
- }
- });
-
this.initialized = true;
}
@@ -347,6 +373,14 @@
});
}
handleChange(e) {
+ if (e.target.closest('[data-id]')) {
+ if (this.isTimeline) {
+ this.handleTimelineTableChange(e);
+ } else {
+ this.handleTableChange(e);
+ }
+ return;
+ }
if (e.target.classList.contains('bulk-action-select')) {
if (e.target.value.startsWith('tax-')) {
const taxonomy = e.target.value.replace('tax-', '');
@@ -378,6 +412,73 @@
}
}
}
+ handleTableChange(e) {
+ const row = e.target.closest('tr[data-id]');
+ if (!row) return;
+
+ const input = e.target;
+ const postID = parseInt(row.dataset.id);
+ const fieldName = input.closest(['data-field'])?.dataset.field;
+ if (!fieldName) return;
+
+ const item = this.store.get(postID);
+ if (!item) return;
+
+ item.fields[fieldName] = this.getInputValue(input);
+
+ this.store.save(item);
+
+ let post = {};
+ post[postID] = item.fields;
+ this.savePosts(post, `Saving changes to ${this.content}`);
+ }
+ handleTimelineTableChange(e) {
+ const tbody = e.target.closest('tbody[data-id]');
+ if (!tbody) return;
+
+ const input = e.target;
+ const fieldName = input.closest('[data-field]')?.dataset.field;
+
+ if (!fieldName) return;
+
+ const parentID = parseInt(tbody.dataset.id);
+ const timelinePoint = input.closest('tr.timeline-point');
+
+ const item = this.store.get(parentID);
+ if (!item) return;
+
+ const value = this.getInputValue(input);
+
+ // Check if this is a specific point, or a shared value
+ if (timelinePoint) {
+ const imgID = timelinePoint.dataset.imageId;
+ if (!item.fields.timeline) {
+ item.fields.timeline = {};
+ }
+ if (!item.fields.timeline[imgID]) {
+ item.fields.timeline[imgID] = {};
+ }
+ item.fields.timeline[imgID][fieldName] = value;
+ } else {
+ item.fields[fieldName] = value;
+ }
+
+ //Update store directly
+ this.store.save(item);
+
+ let changes = {};
+ changes[parentID] = item.fields;
+ this.savePosts(changes, 'Updating progress post');
+ }
+ getInputValue(input) {
+ if (input.type === 'checkbox') {
+ return input.checked ? (input.value || '1') : '';
+ }
+ if (input.type === 'radio') {
+ return input.checked ? input.value : null;
+ }
+ return input.value;
+ }
openTaxonomyModal(taxonomy) {
// Check if jvbSelector exists
@@ -393,14 +494,13 @@
);
}
handleBulkTaxonomy(selectedIds, taxonomy) {
- console.log(taxonomy, selectedIds);
// Callback when terms are selected
if (selectedIds.length > 0) {
selectedIds = selectedIds.join(',');
let changes = {};
let selected = Array.from(this.viewController.selectedItems);
- console.log('selected',selected);
+
selected.forEach(sel => {
changes[sel] = {
content: this.content
@@ -408,7 +508,6 @@
changes[sel][taxonomy] = selectedIds;
});
- console.log('Taxonomy changes: ', changes);
let title = `Adding ${selected.length} ${this.config.plural??'posts'} to ${selectedIds.length} ${jvbSettings.labels[taxonomy].plural}`;
this.viewController.clearSelection();
@@ -420,7 +519,7 @@
if (!['publish', 'draft', 'trash', 'delete'].includes(status)){
return;
}
- console.log(`Setting status: ${status}`);
+
let changes = {};
for (let selected of this.viewController.selectedItems) {
changes[selected] = {
@@ -436,7 +535,7 @@
default:
title = window.uppercaseFirst(status)+'ing';
}
- console.log(this.status);
+
if ((this.status === 'all' && !['publish', 'draft'].includes(status)) || status !== this.status) {
let delay = 0;
for (let selected of this.viewController.selectedItems) {
@@ -503,9 +602,8 @@
window.removeChildren(container);
for (let selected of this.viewController.selectedItems) {
- console.log(selected);
let item = this.store.get(selected);
- console.log(item);
+
const img = window.getTemplate('bulkItem');
if (!img) return;
@@ -533,14 +631,12 @@
];
this.formController.registerForm(this.ui.forms.bulkEdit);
- console.log('Bulk Edit form registered');
}
populateEditForm(itemID) {
- console.log(itemID);
+ this.currentItemID = itemID;
let item = this.store.get(parseInt(itemID));
- console.log(item);
if (item) {
this.ui.modals.edit.dataset.itemID = itemID;
this.ui.modals.edit.dataset.content = this.content;
@@ -552,10 +648,8 @@
`Editing ${item.fields.post_title}`
];
form.dataset.formId = `edit-${itemID}`;
- console.log(form.dataset.formId);
new window.jvbPopulate(form, item.fields, item.images);
this.formController.registerForm(this.ui.forms.edit);
- console.log('Edit form registered');
}
}
@@ -593,7 +687,6 @@
document.querySelectorAll('[data-filter]').forEach(filter => {
filter.removeEventListener('change', this.filterHandler);
});
- this.store.subscribers.clear();
}
}
diff --git a/assets/js/dash/Modal.js b/assets/js/dash/Modal.js
index 346dc03..39d345f 100644
--- a/assets/js/dash/Modal.js
+++ b/assets/js/dash/Modal.js
@@ -51,6 +51,7 @@
this.a11y.announce(this.options.closeMessage);
this.modal.close();
+ this.isOpen = false;
// Remove this modal from the stack
diff --git a/assets/js/dash/TaxonomyCreator.js b/assets/js/dash/TaxonomyCreator.js
index 8f27b17..37b5960 100644
--- a/assets/js/dash/TaxonomyCreator.js
+++ b/assets/js/dash/TaxonomyCreator.js
@@ -8,19 +8,19 @@
constructor(selector) {
this.selector = selector;
- // Get taxonomy from current active field config
- this.taxonomy = selector.currentConfig?.taxonomy;
- if (!this.taxonomy) {
- console.error('TaxonomyCreator: No active field or taxonomy found');
- return;
+ // Only initialize modal elements if modal exists
+ if (selector.modal) {
+ this.createNew = selector.modal.querySelector('.create-new-term');
+ this.toggle = selector.modal.querySelector('.new-term-toggle');
+ this.form = this.createNew?.querySelector('.create-new-term-section');
}
- this.createNew = selector.modal.querySelector('.create-new-term');
- this.toggle = selector.modal.querySelector('.new-term-toggle');
- this.form = this.createNew.querySelector('.create-new-term-section');
-
this.initListeners();
- this.initTermCreation();
+
+ // Only init term creation UI if we have modal elements
+ if (this.form) {
+ this.initTermCreation();
+ }
}
initListeners() {
@@ -47,6 +47,9 @@
}
async handleTermCreation(e) {
+ const taxonomy = this.selector.currentConfig?.taxonomy;
+ if (!taxonomy) return;
+
const termName = this.form.querySelector('input[name="term_name"]').value.trim();
const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0;
@@ -54,98 +57,127 @@
try {
this.form.querySelector('button').disabled = true;
- const response = await this.createTerm(termName, parentId);
+ const response = await this.createTerm(termName, parentId, taxonomy);
if (response.success && response.term) {
let term = response.term;
- // Close the create new section
this.createNew.open = false;
+ await this.selector.store.clearCache();
- // Invalidate the cache for this taxonomy
- await this.selector.store.invalidate({ taxonomy: this.taxonomy });
+ // Add to store's data BEFORE display update
+ this.selector.store.data.set(term.id, {
+ id: term.id,
+ name: term.name,
+ path: termPath,
+ taxonomy: field.taxonomy,
+ parent: 0,
+ count: 0,
+ hasChildren: false,
+ slug: term.slug || termName.toLowerCase().replace(/\s+/g, '-')
+ });
- // Add to current modal selection
this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
- // If we're viewing the parent category where this was created, refresh the list
const currentParent = this.selector.store.filters.parent || 0;
if (currentParent === parentId) {
await this.selector.store.setFilters({
- taxonomy: this.taxonomy,
+ taxonomy,
parent: parentId,
page: 1,
search: ''
});
}
- // Clear the form
this.form.querySelector('input[name="term_name"]').value = '';
-
- // Clear suggestions
const suggestionContainer = this.createNew.querySelector('.term-suggestions');
if (suggestionContainer) {
suggestionContainer.hidden = true;
}
+ this.selector.store.cache.clear();
}
} catch (error) {
console.error('Error creating term:', error);
this.selector.error?.log(error, {
component: 'TaxonomyCreator',
action: 'handleTermCreation'
- }) || console.error('Failed to create term');
+ });
} finally {
this.form.querySelector('button').disabled = false;
}
}
async handleAutocompleteCreate(e) {
- const button = e.target;
+ const button = e.target.closest('.create-term');
const fieldId = this.selector.getFieldId(button);
const field = this.selector.fields.get(fieldId);
if (!field) return;
- // Get the current input value (not the debounced query)
const input = field.container.querySelector('input[data-autocomplete]');
const termName = input?.value.trim() || button.dataset.query;
if (!termName) return;
+ const originalHTML = button.innerHTML;
+
try {
button.disabled = true;
- const originalText = button.innerHTML;
button.textContent = 'Creating...';
- const response = await this.createTerm(termName, 0);
+ const response = await this.createTerm(termName, 0, field.taxonomy);
if (response.success && response.term) {
const term = response.term;
+ const termPath = term.path || term.name;
- // Add term to field
field.selectedTerms.add(parseInt(term.id));
- this.selector.addTermToDisplay(field.id, term.id, term.name, term.path || term.name);
+ // Add to store's data BEFORE display update
+ this.selector.store.data.set(term.id, {
+ id: term.id,
+ name: term.name,
+ path: termPath,
+ taxonomy: field.taxonomy,
+ parent: 0,
+ count: 0,
+ hasChildren: false,
+ slug: term.slug || termName.toLowerCase().replace(/\s+/g, '-')
+ });
+ this.selector.addTermToDisplay(field.id, term.id, term.name, termPath);
- // Update input
field.input.value = Array.from(field.selectedTerms).join(',');
field.input.dispatchEvent(new Event('change', { bubbles: true }));
- // Clear and hide dropdown
field.autocompleteDropdown.hidden = true;
if (input) input.value = '';
- // Refresh the store to include the new term
- await this.selector.store.setFilter('taxonomy', field.taxonomy);
- } else {
- button.innerHTML = originalText;
+ this.selector.store.clearCache();
+ await this.selector.store.setFilters({
+ taxonomy: field.taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ } else if (response.reason === 'exists' && response.term) {
+ // Term already exists - just add it
+ const term = response.term;
+ field.selectedTerms.add(parseInt(term.id));
+ this.selector.addTermToDisplay(field.id, term.id, term.name, term.path || term.name);
+
+ field.input.value = Array.from(field.selectedTerms).join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ field.autocompleteDropdown.hidden = true;
+ if (input) input.value = '';
}
} catch (error) {
console.error('Error creating term:', error);
- button.textContent = 'Failed - Try again';
- setTimeout(() => {
- button.innerHTML = `<strong>Create:</strong> "${termName}"`;
- button.disabled = false;
- }, 2000);
+ button.innerHTML = originalHTML;
+ button.disabled = false;
+ this.selector.error?.log(error, {
+ component: 'TaxonomyCreator',
+ action: 'handleAutocompleteCreate'
+ });
}
}
@@ -161,6 +193,9 @@
}
resetParentOptions() {
+ const taxonomy = this.selector.currentConfig?.taxonomy;
+ if (!taxonomy) return;
+
let select = this.createNew.querySelector('#select_parent');
if (!select) return;
@@ -188,7 +223,7 @@
// Add all terms currently visible in the taxonomy (from store cache)
const visibleTerms = [];
this.selector.store.data.forEach(term => {
- if (term.taxonomy === this.taxonomy && term.parent === currentParent) {
+ if (term.taxonomy === taxonomy && term.parent === currentParent) {
visibleTerms.push(term);
}
});
@@ -206,45 +241,32 @@
});
}
- async createTerm(name, parent = 0) {
- let loadingMessage = this.createNew.querySelector('.loading-message.create-term');
- let text = loadingMessage?.querySelector('span');
-
+ async createTerm(name, parent = 0, taxonomy) {
try {
- if (loadingMessage) {
- loadingMessage.hidden = false;
- }
+ // Search for the exact term first
+ await this.selector.store.setFilters({
+ taxonomy: taxonomy,
+ search: name,
+ page: 1,
+ parent: 0
+ });
- if (text && window.typeText) {
- window.typeText(text, 'Checking term...');
- } else if (text) {
- text.textContent = 'Checking term...';
- }
+ // Check if exact match exists in results
+ const exactMatch = Array.from(this.selector.store.data.values())
+ .find(term =>
+ term.taxonomy === taxonomy &&
+ term.name.toLowerCase() === name.toLowerCase()
+ );
- // Search for existing terms with this name
- const searchResults = await this.searchExistingTerms(name);
-
- // Check for exact matches
- const exactMatches = searchResults.filter(term =>
- term.name.toLowerCase() === name.toLowerCase()
- );
-
- if (exactMatches.length > 0) {
- this.showTermSuggestions(exactMatches, true);
- return { success: false, reason: 'exists' };
- }
-
- // Show similar terms if found
- if (searchResults.length > 0) {
- this.showTermSuggestions(searchResults, false);
- return { success: false, reason: 'similar' };
+ if (exactMatch) {
+ // For modal context, show suggestions
+ if (this.createNew) {
+ this.showTermSuggestions([exactMatch], true);
+ }
+ return { success: false, reason: 'exists', term: exactMatch };
}
// Term doesn't exist, create it
- if (text) {
- text.textContent = 'Creating term...';
- }
-
const response = await fetch(`${jvbSettings.api}terms`, {
method: 'POST',
headers: {
@@ -252,7 +274,7 @@
'X-WP-Nonce': jvbSettings.nonce
},
body: JSON.stringify({
- taxonomy: this.taxonomy,
+ taxonomy: taxonomy,
name: name,
parent: parent
})
@@ -267,18 +289,13 @@
} catch (error) {
console.error('Error creating term:', error);
throw error;
- } finally {
- this.form.querySelector('button').disabled = false;
- if (loadingMessage) {
- loadingMessage.hidden = true;
- }
}
}
/**
* Search for existing terms using the store
*/
- async searchExistingTerms(searchQuery) {
+ async searchExistingTerms(searchQuery, taxonomy) {
return new Promise((resolve) => {
// Set up a one-time listener for the search results
const handleSearchResults = (event, data) => {
@@ -292,7 +309,7 @@
// Trigger search
this.selector.store.setFilters({
- taxonomy: this.taxonomy,
+ taxonomy: taxonomy,
search: searchQuery,
page: 1,
parent: 0
@@ -365,71 +382,6 @@
return container;
}
- /**
- * Create "Create new term" option for autocomplete dropdown
- */
- createAutocompleteOption(query, field) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'autocomplete-item create-term';
- button.innerHTML = `<span>Create "${query}"</span>`;
- button.dataset.query = query;
- button.dataset.fieldId = field.id;
-
- button.addEventListener('click', async () => {
- await this.handleAutocompleteCreate(button, query, field);
- });
-
- return button;
- }
-
-
- /**
- * Handle term creation from autocomplete
- */
- async handleAutocompleteCreate(button, termName, field) {
- if (!field) return;
-
- const originalHTML = button.innerHTML;
-
- try {
- button.disabled = true;
- button.innerHTML = '<span>Creating...</span>';
-
- const parentId = 0; // Autocomplete always creates at root level
- const result = await this.createTerm(termName, parentId);
-
- if (result.success && result.term) {
- const term = result.term;
-
- // Add to field
- field.selectedTerms.add(parseInt(term.id));
- this.selector.addTermToDisplay(field.id, term.id, term.name, term.path || term.name);
-
- // Update input
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
-
- // Invalidate cache
- await this.selector.store.invalidate({ taxonomy: field.taxonomy });
-
- // Clear and hide dropdown
- field.autocompleteDropdown.hidden = true;
- const input = field.container.querySelector('input[data-autocomplete]');
- if (input) input.value = '';
- }
- // If result.success is false, suggestions are already shown
-
- } catch (error) {
- console.error('Error creating term:', error);
- button.innerHTML = originalHTML;
- button.disabled = false;
- this.selector.error?.log(error, {
- component: 'TaxonomyCreator',
- action: 'handleAutocompleteCreate'
- });
- }
- }
/**
* Clean up when modal closes
diff --git a/assets/js/dash/UtilityFunctions.js b/assets/js/dash/UtilityFunctions.js
index 23f0737..596d6f4 100644
--- a/assets/js/dash/UtilityFunctions.js
+++ b/assets/js/dash/UtilityFunctions.js
@@ -127,7 +127,7 @@
*/
window.getTemplate = function (template){
if (window.templates.size === 0) {
- loadTemplates();
+ window.loadTemplates();
}
if(window.templates.has(template)){
return window.templates.get(template).cloneNode(true);
@@ -189,29 +189,25 @@
* @param icon
* @returns {Node | ActiveX.IXMLDOMNode}
*/
-window.icon = document.querySelector('.icon').cloneNode(true);
-window.icon.className = 'icon';
-window.getIcon = function getIcon(icon){
+window.icon = null;
+window.getIcon = function getIcon(icon, style = ''){
if (typeof icon === 'undefined') {
return '';
}
- console.log('Getting icon: '+icon);
+ if (!window.icon) {
+ window.icon = document.createElement('i');
+ window.icon.className = 'icon';
+ window.icon.ariaHidden = true;
+ }
let theIcon = window.icon.cloneNode(true);
- theIcon.classList.add('icon-'+icon);
+ if (style !== '' && ['regular', 'bold', 'duotone', 'fill', 'light', 'thin' ].includes('style')) {
+ style = `-${style.slice(0, 2)}`;
+ } else {
+ style = '';
+ }
+ theIcon.classList.add(`icon-${icon}${style}`);
return theIcon;
-
-
- if(!window.jvbIcons){
- window.jvbIcons = new Map();
- }
- if(!window.jvbIcons.has(icon) && jvbSettings.icons[icon]){
- let temp = document.createElement('div');
- temp.innerHTML = jvbSettings.icons[icon];
- window.jvbIcons.set(icon, temp.firstElementChild.cloneNode(true));
- temp.remove();
- }
- return window.jvbIcons.get(icon)?.cloneNode(true);
}
/**
@@ -535,6 +531,9 @@
}
window.targetCheck = function (e, selector) {
+ if (Array.isArray(selector)) {
+ selector = selector.join(',');
+ }
if (typeof selector !== 'string') {
return false;
}
diff --git a/assets/js/min/a11y.min.js b/assets/js/min/a11y.min.js
index 4b339dd..e4c0032 100644
--- a/assets/js/min/a11y.min.js
+++ b/assets/js/min/a11y.min.js
@@ -1 +1 @@
-window.jvbA11y=new class{constructor(){this.liveRegion=document.querySelector(".screen-reader-text.live-region"),this.liveRegion||console.log("No accessibility region.")}announce(e,t="polite"){this.liveRegion&&(this.liveRegion.setAttribute("aria-live",t),this.liveRegion.textContent="",setTimeout((()=>{this.liveRegion.textContent=e}),50))}announceItems(e,t=!1,n=!0){const o=t?"Added":"Loaded",i=n?". More available to load.":". No more available to load.";this.announce(`${o} ${e} new items${i}`)}announceEmpty(e=!1){const t=e?"No favourites found. Try adding some items to your collection.":"No items found matching your current filters.";this.announce(t)}announceNavigation(e,t,n=!1,o=!1){n?this.announce("At first image"):o?this.announce("At last image"):this.announce(`Image ${e} of ${t}`)}trapFocus(e,t=null){if(!e)return()=>{};const n=e.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');if(!n.length)return()=>{};const o=n[0],i=n[n.length-1],a=t||document.activeElement;o.focus();const r=e=>{"Tab"===e.key&&(e.shiftKey&&document.activeElement===o?(i.focus(),e.preventDefault()):e.shiftKey||document.activeElement!==i||(o.focus(),e.preventDefault()))};return e.addEventListener("keydown",r),()=>{e.removeEventListener("keydown",r),a&&"function"==typeof a.focus&&a.focus()}}makeNavigable(e,t){e&&e.length&&Array.from(e).forEach((e=>{e.getAttribute("data-keyboard-nav")||(e.setAttribute("data-keyboard-nav","true"),e.setAttribute("tabindex","0"),e.addEventListener("keydown",(n=>{if("Enter"===n.key||" "===n.key)if(n.preventDefault(),t)t(e);else{const t=e.querySelector("a");t&&t.click()}})))}))}};
\ No newline at end of file
+(()=>{class e{constructor(){this.liveRegion=document.querySelector(".screen-reader-text.live-region"),this.liveRegion||console.log("No accessibility region.")}announce(e,t="polite"){this.liveRegion&&(this.liveRegion.setAttribute("aria-live",t),this.liveRegion.textContent="",setTimeout((()=>{this.liveRegion.textContent=e}),50))}announceItems(e,t=!1,n=!0){const o=t?"Added":"Loaded",i=n?". More available to load.":". No more available to load.";this.announce(`${o} ${e} new items${i}`)}announceEmpty(e=!1){const t=e?"No favourites found. Try adding some items to your collection.":"No items found matching your current filters.";this.announce(t)}announceNavigation(e,t,n=!1,o=!1){n?this.announce("At first image"):o?this.announce("At last image"):this.announce(`Image ${e} of ${t}`)}trapFocus(e,t=null){if(!e)return()=>{};const n=e.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');if(!n.length)return()=>{};const o=n[0],i=n[n.length-1],a=t||document.activeElement;o.focus();const r=e=>{"Tab"===e.key&&(e.shiftKey&&document.activeElement===o?(i.focus(),e.preventDefault()):e.shiftKey||document.activeElement!==i||(o.focus(),e.preventDefault()))};return e.addEventListener("keydown",r),()=>{e.removeEventListener("keydown",r),a&&"function"==typeof a.focus&&a.focus()}}makeNavigable(e,t){e&&e.length&&Array.from(e).forEach((e=>{e.getAttribute("data-keyboard-nav")||(e.setAttribute("data-keyboard-nav","true"),e.setAttribute("tabindex","0"),e.addEventListener("keydown",(n=>{if("Enter"===n.key||" "===n.key)if(n.preventDefault(),t)t(e);else{const t=e.querySelector("a");t&&t.click()}})))}))}}document.addEventListener("DOMContentLoaded",(function(){window.jvbA11y=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index b5672e4..056e9f3 100644
--- a/assets/js/min/creator.min.js
+++ b/assets/js/min/creator.min.js
@@ -1 +1 @@
-window.jvbTaxCreator=class{constructor(e){this.selector=e,this.taxonomy=e.currentConfig?.taxonomy,this.taxonomy?(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew.querySelector(".create-new-term-section"),this.initListeners(),this.initTermCreation()):console.error("TaxonomyCreator: No active field or taxonomy found")}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e),window.targetCheck(e,".create-term")&&this.handleAutocompleteCreate(e)}async handleTermCreation(e){const t=this.form.querySelector('input[name="term_name"]').value.trim(),r=parseInt(this.form.querySelector("input#select_parent")?.value)||0;if(t)try{this.form.querySelector("button").disabled=!0;const e=await this.createTerm(t,r);if(e.success&&e.term){let t=e.term;this.createNew.open=!1,await this.selector.store.invalidate({taxonomy:this.taxonomy}),this.selector.addSelectedTermToModal(t.id,t.name,t.path||t.name),(this.selector.store.filters.parent||0)===r&&await this.selector.store.setFilters({taxonomy:this.taxonomy,parent:r,page:1,search:""}),this.form.querySelector('input[name="term_name"]').value="";const n=this.createNew.querySelector(".term-suggestions");n&&(n.hidden=!0)}}catch(e){console.error("Error creating term:",e),this.selector.error?.log(e,{component:"TaxonomyCreator",action:"handleTermCreation"})||console.error("Failed to create term")}finally{this.form.querySelector("button").disabled=!1}}async handleAutocompleteCreate(e){const t=e.target,r=this.selector.getFieldId(t),n=this.selector.fields.get(r);if(!n)return;const o=n.container.querySelector("input[data-autocomplete]"),a=o?.value.trim()||t.dataset.query;if(a)try{t.disabled=!0;const e=t.innerHTML;t.textContent="Creating...";const r=await this.createTerm(a,0);if(r.success&&r.term){const e=r.term;n.selectedTerms.add(parseInt(e.id)),this.selector.addTermToDisplay(n.id,e.id,e.name,e.path||e.name),n.input.value=Array.from(n.selectedTerms).join(","),n.input.dispatchEvent(new Event("change",{bubbles:!0})),n.autocompleteDropdown.hidden=!0,o&&(o.value=""),await this.selector.store.setFilter("taxonomy",n.taxonomy)}else t.innerHTML=e}catch(e){console.error("Error creating term:",e),t.textContent="Failed - Try again",setTimeout((()=>{t.innerHTML=`<strong>Create:</strong> "${a}"`,t.disabled=!1}),2e3)}}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){let e=this.createNew.querySelector("#select_parent");if(!e)return;let t=e.querySelector("option");if(!t)return;window.removeChildren(e),e.append(t.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const n=this.selector.store.data.get(r);if(n){let r=t.cloneNode(!0);r.value=n.id,r.textContent=n.name,e.append(r)}}const n=[];this.selector.store.data.forEach((e=>{e.taxonomy===this.taxonomy&&e.parent===r&&n.push(e)})),n.sort(((e,t)=>e.name.localeCompare(t.name))),n.forEach((r=>{let n=t.cloneNode(!0);n.id=`select-parent-${r.id}`,n.value=r.id,n.textContent=" — "+r.name,e.append(n)}))}async createTerm(e,t=0){let r=this.createNew.querySelector(".loading-message.create-term"),n=r?.querySelector("span");try{r&&(r.hidden=!1),n&&window.typeText?window.typeText(n,"Checking term..."):n&&(n.textContent="Checking term...");const o=await this.searchExistingTerms(e),a=o.filter((t=>t.name.toLowerCase()===e.toLowerCase()));if(a.length>0)return this.showTermSuggestions(a,!0),{success:!1,reason:"exists"};if(o.length>0)return this.showTermSuggestions(o,!1),{success:!1,reason:"similar"};n&&(n.textContent="Creating term...");const s=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({taxonomy:this.taxonomy,name:e,parent:t})});if(!s.ok)throw new Error(`Server error: ${s.status}`);return await s.json()}catch(e){throw console.error("Error creating term:",e),e}finally{this.form.querySelector("button").disabled=!1,r&&(r.hidden=!0)}}async searchExistingTerms(e){return new Promise((t=>{const r=(e,n)=>{"data-loaded"===e&&(this.selector.store.unsubscribe(r),t(n.data?.items||[]))};this.selector.store.subscribe(r),this.selector.store.setFilters({taxonomy:this.taxonomy,search:e,page:1,parent:0})}))}showTermSuggestions(e,t=!1){const r=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(r);const n=document.createElement("h4");n.textContent=t?"This term already exists:":"Similar terms already exist:",r.appendChild(n);const o=document.createElement("ul");o.className="term-suggestion-list",e.forEach((e=>{const t=document.createElement("li"),n=document.createElement("button");n.type="button",n.className="use-existing-term",n.setAttribute("data-id",e.id),n.textContent=e.path||e.name,n.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path||e.name),this.createNew.open=!1,r.hidden=!0,this.form.querySelector('input[name="term_name"]').value=""})),t.appendChild(n),o.appendChild(t)})),r.appendChild(o),r.hidden=!1}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}createAutocompleteOption(e,t){const r=document.createElement("button");return r.type="button",r.className="autocomplete-item create-term",r.innerHTML=`<span>Create "${e}"</span>`,r.dataset.query=e,r.dataset.fieldId=t.id,r.addEventListener("click",(async()=>{await this.handleAutocompleteCreate(r,e,t)})),r}async handleAutocompleteCreate(e,t,r){if(!r)return;const n=e.innerHTML;try{e.disabled=!0,e.innerHTML="<span>Creating...</span>";const n=0,o=await this.createTerm(t,n);if(o.success&&o.term){const e=o.term;r.selectedTerms.add(parseInt(e.id)),this.selector.addTermToDisplay(r.id,e.id,e.name,e.path||e.name),r.input.value=Array.from(r.selectedTerms).join(","),r.input.dispatchEvent(new Event("change",{bubbles:!0})),await this.selector.store.invalidate({taxonomy:r.taxonomy}),r.autocompleteDropdown.hidden=!0;const t=r.container.querySelector("input[data-autocomplete]");t&&(t.value="")}}catch(t){console.error("Error creating term:",t),e.innerHTML=n,e.disabled=!1,this.selector.error?.log(t,{component:"TaxonomyCreator",action:"handleAutocompleteCreate"})}}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler);const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,e.modal&&(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew?.querySelector(".create-new-term-section")),this.initListeners(),this.form&&this.initTermCreation()}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e),window.targetCheck(e,".create-term")&&this.handleAutocompleteCreate(e)}async handleTermCreation(e){const t=this.selector.currentConfig?.taxonomy;if(!t)return;const r=this.form.querySelector('input[name="term_name"]').value.trim(),s=parseInt(this.form.querySelector("input#select_parent")?.value)||0;if(r)try{this.form.querySelector("button").disabled=!0;const e=await this.createTerm(r,s,t);if(e.success&&e.term){let o=e.term;this.createNew.open=!1,await this.selector.store.clearCache(),this.selector.store.data.set(o.id,{id:o.id,name:o.name,path:termPath,taxonomy:field.taxonomy,parent:0,count:0,hasChildren:!1,slug:o.slug||r.toLowerCase().replace(/\s+/g,"-")}),this.selector.addSelectedTermToModal(o.id,o.name,o.path||o.name),(this.selector.store.filters.parent||0)===s&&await this.selector.store.setFilters({taxonomy:t,parent:s,page:1,search:""}),this.form.querySelector('input[name="term_name"]').value="";const a=this.createNew.querySelector(".term-suggestions");a&&(a.hidden=!0),this.selector.store.cache.clear()}}catch(e){console.error("Error creating term:",e),this.selector.error?.log(e,{component:"TaxonomyCreator",action:"handleTermCreation"})}finally{this.form.querySelector("button").disabled=!1}}async handleAutocompleteCreate(e){const t=e.target.closest(".create-term"),r=this.selector.getFieldId(t),s=this.selector.fields.get(r);if(!s)return;const o=s.container.querySelector("input[data-autocomplete]"),a=o?.value.trim()||t.dataset.query;if(!a)return;const n=t.innerHTML;try{t.disabled=!0,t.textContent="Creating...";const e=await this.createTerm(a,0,s.taxonomy);if(e.success&&e.term){const t=e.term,r=t.path||t.name;s.selectedTerms.add(parseInt(t.id)),this.selector.store.data.set(t.id,{id:t.id,name:t.name,path:r,taxonomy:s.taxonomy,parent:0,count:0,hasChildren:!1,slug:t.slug||a.toLowerCase().replace(/\s+/g,"-")}),this.selector.addTermToDisplay(s.id,t.id,t.name,r),s.input.value=Array.from(s.selectedTerms).join(","),s.input.dispatchEvent(new Event("change",{bubbles:!0})),s.autocompleteDropdown.hidden=!0,o&&(o.value=""),this.selector.store.clearCache(),await this.selector.store.setFilters({taxonomy:s.taxonomy,page:1,search:"",parent:0})}else if("exists"===e.reason&&e.term){const t=e.term;s.selectedTerms.add(parseInt(t.id)),this.selector.addTermToDisplay(s.id,t.id,t.name,t.path||t.name),s.input.value=Array.from(s.selectedTerms).join(","),s.input.dispatchEvent(new Event("change",{bubbles:!0})),s.autocompleteDropdown.hidden=!0,o&&(o.value="")}}catch(e){console.error("Error creating term:",e),t.innerHTML=n,t.disabled=!1,this.selector.error?.log(e,{component:"TaxonomyCreator",action:"handleAutocompleteCreate"})}}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){const e=this.selector.currentConfig?.taxonomy;if(!e)return;let t=this.createNew.querySelector("#select_parent");if(!t)return;let r=t.querySelector("option");if(!r)return;window.removeChildren(t),t.append(r.cloneNode(!0));const s=this.selector.store.filters.parent||0;if(0!==s){const e=this.selector.store.data.get(s);if(e){let s=r.cloneNode(!0);s.value=e.id,s.textContent=e.name,t.append(s)}}const o=[];this.selector.store.data.forEach((t=>{t.taxonomy===e&&t.parent===s&&o.push(t)})),o.sort(((e,t)=>e.name.localeCompare(t.name))),o.forEach((e=>{let s=r.cloneNode(!0);s.id=`select-parent-${e.id}`,s.value=e.id,s.textContent=" — "+e.name,t.append(s)}))}async createTerm(e,t=0,r){try{await this.selector.store.setFilters({taxonomy:r,search:e,page:1,parent:0});const s=Array.from(this.selector.store.data.values()).find((t=>t.taxonomy===r&&t.name.toLowerCase()===e.toLowerCase()));if(s)return this.createNew&&this.showTermSuggestions([s],!0),{success:!1,reason:"exists",term:s};const o=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({taxonomy:r,name:e,parent:t})});if(!o.ok)throw new Error(`Server error: ${o.status}`);return await o.json()}catch(e){throw console.error("Error creating term:",e),e}}async searchExistingTerms(e,t){return new Promise((r=>{const s=(e,t)=>{"data-loaded"===e&&(this.selector.store.unsubscribe(s),r(t.data?.items||[]))};this.selector.store.subscribe(s),this.selector.store.setFilters({taxonomy:t,search:e,page:1,parent:0})}))}showTermSuggestions(e,t=!1){const r=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(r);const s=document.createElement("h4");s.textContent=t?"This term already exists:":"Similar terms already exist:",r.appendChild(s);const o=document.createElement("ul");o.className="term-suggestion-list",e.forEach((e=>{const t=document.createElement("li"),s=document.createElement("button");s.type="button",s.className="use-existing-term",s.setAttribute("data-id",e.id),s.textContent=e.path||e.name,s.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path||e.name),this.createNew.open=!1,r.hidden=!0,this.form.querySelector('input[name="term_name"]').value=""})),t.appendChild(s),o.appendChild(t)})),r.appendChild(o),r.hidden=!1}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler);const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}};
\ No newline at end of file
diff --git a/assets/js/min/crud.min.js b/assets/js/min/crud.min.js
index ce4c697..3b0a07f 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(e){this.queue=window.jvbQueue,console.log(this.queue),this.config=e,this.content=e.content||!1,this.settings=window.jvbUserSettings,this.content&&(this.isTimeline=!1,this.initElements(),this.updateBulkOptions(),this.store=new window.jvbStore({name:this.content,storeName:this.content,endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"status",keyPath:"post_status"},{name:"modified",keyPath:"modified"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all"},TTL:36e5,cacheDOM:!0}),this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.formController=new window.jvbForm,this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.source?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&"form"===t.source&&this.handleQueueFailure(e,t)})),this.initialized=!1,this.init())}handleFormChange(e,t){if(this.isTimeline){t.changes.content=this.content;let e={};return e[t.config.id.replace("edit-","")]=t.changes,void this.savePosts(e,`Saving ${t.fullData.post_title}'s Changes`)}t.changes.content=this.content;let s={},i="",o=[];switch(!0){case t.config.element===this.ui.forms.edit:let l=t.config.id.replace("edit-","");console.log(l),s[l]=t.changes,i=`Saving ${t.fullData.post_title} Changes`,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(l);break;case t.config.element===this.ui.forms.bulkEdit:let n=t.config.element.querySelectorAll(".selected input:checked");n.forEach((e=>{s[e.value]=t.changes,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(e.value)})),i=`Updating ${n.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(s[t.config.data["form-id"]]=t.fullData,i=`Saving ${t.fullData.post_title} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(s)||this.savePosts(s,i)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;let s={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(s)}handleQueueSuccess(e,t){console.log("Handling queue success..."),console.log("Event",e),console.log("Data",t)}handleQueueFailure(e,t){console.log("Handling queue failure..."),console.log("Event",e),console.log("Data",t)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.elements),this.isTimeline=Object.hasOwn(this.ui.forms.edit.dataset,"timeline")}init(){this.settings.addSetting(this.ui.uploader,"open"),this.ui.uploader.addEventListener("toggle",(e=>{console.log(e),console.log("Is Open: ",this.ui.uploader.open),console.log(this.ui.uploader.open?"on":"off"),this.settings.saveSetting("open",this.ui.uploader.open?"on":"off")})),this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t)this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId),console.log("Data on modal close: ",s)}));this.setupEventDelegation(),this.setupFilters(),this.queue.subscribe(((e,t)=>{e})),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const s=t.dataset.action,i=t.dataset.id;switch(s){case"edit":this.populateEditForm(i),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(i)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const s=Array.from(this.viewController.selectedItems);s.length>0&&confirm(`Delete ${s.length} items?`)&&(s.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(console.log(t,e),e.length>0){e=e.join(",");let s={},i=Array.from(this.viewController.selectedItems);console.log("selected",i),i.forEach((i=>{s[i]={content:this.content},s[i][t]=e})),console.log("Taxonomy changes: ",s);let o=`Adding ${i.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(s,o)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;console.log(`Setting status: ${e}`);let t,s={};for(let t of this.viewController.selectedItems)s[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if(console.log(this.status),"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(s)||this.savePosts(s,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target,s=t.dataset.filter;if("taxonomies"===s){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,s.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){console.log(t);let s=this.store.get(t);console.log(s);const i=window.getTemplate("bulkItem");if(!i)return;const o=i.querySelector("input[type=checkbox]"),l=i.querySelector("img");o&&(o.id=`bulk_${s.id}`,o.value=s.id,o.checked=!0),l&&s.thumbnail&&(l.src=s.thumbnail,l.alt=s.alt||""),e.append(i)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit),console.log("Bulk Edit form registered")}populateEditForm(e){console.log(e);let t=this.store.get(parseInt(e));if(console.log(t),t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let s=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],s.dataset.formId=`edit-${e}`,console.log(s.dataset.formId),new window.jvbPopulate(s,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit),console.log("Edit form registered")}}setupFilters(){document.querySelectorAll("[data-filter]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("change",(e=>{this.filterTimeout&&clearTimeout(this.filterTimeout),this.filterTimeout=setTimeout((()=>{this.filterHandler(e)}),300)}))}));const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)})),this.store.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(e){this.queue=window.jvbQueue,this.config=e,this.content=e.content||!1,this.settings=window.jvbUserSettings,this.content&&(this.isTimeline=!1,this.currentItemID=null,this.initElements(),this.updateBulkOptions(),this.store=window.jvbStore.register(this.content,{keyPath:"id",endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all",orderby:"modified",order:"desc"},TTL:18e5}),this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.tableForm=null,this.tableChanges=new Map,this.formController=this.isTimeline?new window.jvbForm({collectFormData:()=>this.collectTimelineData.bind(this)}):new window.jvbForm,this.viewController.subscribe(((e,t)=>{if("table-view"!==e||this.tableForm){if("not-table-view"===e)this.tableForm;else if("order-changed"===e){let e=this.store.get(t);if(!e)return;let i={};i[t]=e,this.savePosts(i,"Updating progression order")}}else this.tableForm||(this.tableForm=this.formController.registerForm(t,{autosave:!1,formStatus:!1,isTable:!0}))})),this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),this.queue.subscribe(((e,t)=>{Object.hasOwn(t,"endpoint")&&"content"===t.endpoint&&(console.log("Queue Subscription in CRUD.js: ",t),"operation-completed"===e?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&this.handleQueueFailure(e,t))})),this.initialized=!1,this.init())}handleFormChange(e,t){let i=t.fullData.post_title,s=Object.hasOwn(t,"changes")?t.changes:t.fullData,l={};if(this.isTimeline)return l[this.currentItemID]=s,void this.savePosts(l,i);let o=[];switch(!0){case t.config.element===this.ui.forms.edit:l[this.currentItemID]=s,i=`Saving ${i} Changes`,s.post_status&&this.shouldRemoveItem(s.post_status)&&o.push(this.currentItemID);break;case t.config.element===this.ui.forms.bulkEdit:let a=t.config.element.querySelectorAll(".selected input:checked");a.forEach((e=>{l[e.value]=s,s.post_status&&this.shouldRemoveItem(s.post_status)&&o.push(e.value)})),i=`Updating ${a.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(l[t.config.data["form-id"]]=s,i=`Saving ${i} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(l)||this.savePosts(l,i)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;for(let t in e)e[t].content||(e[t].content=this.content);let i={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(i)}async handleQueueSuccess(e,t){this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch()}handleQueueFailure(e,t){console.error("Operation failed permanently:",t),this.a11y?.announce(`Operation failed: ${t.error_message||"Unknown error"}`)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.elements),this.isTimeline=!!document.querySelector("[data-timeline]")}init(){this.settings.addSetting(this.ui.uploader,"open"),this.ui.uploader.addEventListener("toggle",(e=>{this.settings.saveSetting("open",this.ui.uploader.open?"on":"off")})),this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t)this.currentItemID=null,this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId)}));this.setupEventDelegation(),this.setupFilters(),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const i=t.dataset.action,s=t.dataset.id;switch(i){case"edit":this.populateEditForm(s),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(s)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const i=Array.from(this.viewController.selectedItems);i.length>0&&confirm(`Delete ${i.length} items?`)&&(i.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.closest("[data-id]"))this.isTimeline?this.handleTimelineTableChange(e):this.handleTableChange(e);else if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}}handleTableChange(e){const t=e.target.closest("tr[data-id]");if(!t)return;const i=e.target,s=parseInt(t.dataset.id),l=i.closest(["data-field"])?.dataset.field;if(!l)return;const o=this.store.get(s);if(!o)return;o.fields[l]=this.getInputValue(i),this.store.save(o);let a={};a[s]=o.fields,this.savePosts(a,`Saving changes to ${this.content}`)}handleTimelineTableChange(e){const t=e.target.closest("tbody[data-id]");if(!t)return;const i=e.target,s=i.closest("[data-field]")?.dataset.field;if(!s)return;const l=parseInt(t.dataset.id),o=i.closest("tr.timeline-point"),a=this.store.get(l);if(!a)return;const n=this.getInputValue(i);if(o){const e=o.dataset.imageId;a.fields.timeline||(a.fields.timeline={}),a.fields.timeline[e]||(a.fields.timeline[e]={}),a.fields.timeline[e][s]=n}else a.fields[s]=n;this.store.save(a);let r={};r[l]=a.fields,this.savePosts(r,"Updating progress post")}getInputValue(e){return"checkbox"===e.type?e.checked?e.value||"1":"":"radio"===e.type?e.checked?e.value:null:e.value}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(e.length>0){e=e.join(",");let i={},s=Array.from(this.viewController.selectedItems);s.forEach((s=>{i[s]={content:this.content},i[s][t]=e}));let l=`Adding ${s.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(i,l)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i={};for(let t of this.viewController.selectedItems)i[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if("all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(i)||this.savePosts(i,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target,i=t.dataset.filter;if("taxonomies"===i){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,i.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){let i=this.store.get(t);const s=window.getTemplate("bulkItem");if(!s)return;const l=s.querySelector("input[type=checkbox]"),o=s.querySelector("img");l&&(l.id=`bulk_${i.id}`,l.value=i.id,l.checked=!0),o&&i.thumbnail&&(o.src=i.thumbnail,o.alt=i.alt||""),e.append(s)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit)}populateEditForm(e){this.currentItemID=e;let t=this.store.get(parseInt(e));if(t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let i=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],i.dataset.formId=`edit-${e}`,new window.jvbPopulate(i,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit)}}setupFilters(){document.querySelectorAll("[data-filter]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("change",(e=>{this.filterTimeout&&clearTimeout(this.filterTimeout),this.filterTimeout=setTimeout((()=>{this.filterHandler(e)}),300)}))}));const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)}))}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/dataStore.min.js b/assets/js/min/dataStore.min.js
index 6ddf722..a84b6e9 100644
--- a/assets/js/min/dataStore.min.js
+++ b/assets/js/min/dataStore.min.js
@@ -1 +1 @@
-window.jvbStore=class{constructor(e={}){this.config={name:"default",version:1,storeName:"items",keyPath:"id",indexes:[],endpoint:null,saveToServer:!1,apiBase:jvbSettings.api,headers:{},filters:{},required:null,icon:null,getBlobs:null,TTL:36e5,useHttpCaching:!0,cacheKeyStrategy:"filters",showLoading:!0,stripDOMReferences:!0,storeBlobs:!1,...e},this.db=null,this.data=new Map,this.cache=new Map,this.isFetching=!1,this.pendingFetch=null,this.httpHeaders=new Map,this.subscribers=new Set,this.currentRequest=null,this.filters=this.config.filters??{},this.headers={"X-WP-Nonce":jvbSettings?.nonce,...this.config.headers},this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.initDB(),window.addEventListener("beforeunload",(()=>this.destroy()))}async initDB(){if(!("indexedDB"in window))return void console.warn("IndexedDB not supported");const e=`jvb_${this.config.name}_db`,t=indexedDB.open(e,this.config.version);t.onupgradeneeded=e=>{const t=e.target.result;if(!t.objectStoreNames.contains(this.config.storeName)){const e=t.createObjectStore(this.config.storeName,{keyPath:this.config.keyPath});this.config.indexes.forEach((t=>{e.createIndex(t.name,t.keyPath||t.name,{unique:t.unique||!1})}))}if(this.config.endpoint&&!t.objectStoreNames.contains("cache")){const e=t.createObjectStore("cache",{keyPath:"key"});e.createIndex("timestamp","timestamp",{unique:!1}),e.createIndex("endpoint","endpoint",{unique:!1}),e.createIndex("filters","filters",{unique:!1})}this.config.useHttpCaching&&!t.objectStoreNames.contains("headers")&&t.createObjectStore("headers",{keyPath:"key"}),this.config.storeBlobs&&!t.objectStoreNames.contains("blobs")&&t.createObjectStore("blobs",{keyPath:"uploadId"}),this.config.onUpgrade&&this.config.onUpgrade(t,e.oldVersion,e.newVersion)},t.onsuccess=async e=>{this.db=e.target.result;const t=[this.loadFromDB()];this.db.objectStoreNames.contains("cache")&&t.push(this.loadCache()),this.config.useHttpCaching&&this.db.objectStoreNames.contains("headers")&&t.push(this.loadHeaders()),await Promise.all(t),this.notify("db-init"),this.config.endpoint&&this.fetch()},t.onerror=t=>{console.error(`IndexedDB error for ${e}:`,t),this.config.onError&&this.config.onError(t)}}async loadFromDB(){if(this.db)return new Promise((async(e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).getAll();s.onsuccess=async t=>{const s=t.target.result;for(const e of s){e.data?._isFormData&&this.config.getBlobs&&(e.data=await this.objectToFormData(e.data));const t=this.getItemKey(e);this.data.set(t,e)}this.notify("data-loaded",{count:s.length}),e(s)},s.onerror=e=>t(e)}))}async loadData(){if(this.db)return new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).getAll();s.onsuccess=t=>{t.target.result.forEach((e=>{const t=this.config.stripDOMReferences?this.stripDOMReferences(e):e,s=this.getItemKey(t);this.data.set(s,t)})),e()},s.onerror=e=>t(e)}))}stripDOMReferences(e){if(!e||"object"!=typeof e)return e;if(Array.isArray(e))return e.map((e=>this.stripDOMReferences(e)));const t={};for(const[s,i]of Object.entries(e))this.isDOMReference(s,i)||(i instanceof Set?t[s]=Array.from(i):i instanceof Map?t[s]=Object.fromEntries(i):t[s]="object"==typeof i&&null!==i?this.stripDOMReferences(i):i);return t}isDOMReference(e,t){if(t instanceof HTMLElement||t instanceof NodeList||t instanceof HTMLCollection||t&&void 0!==t.nodeType)return!0;const s=["element","el","dom","node","ui","container","wrapper"],i=e.toLowerCase();return!(!s.includes(i)&&!s.some((e=>i===e||i.startsWith(e+"_")||i.endsWith("_"+e))))}getItemKey(e){if("function"==typeof this.config.keyPath)return this.config.keyPath(e);const t=this.config.keyPath.split(".");let s=e;for(const e of t)s=s?.[e];return s}async save(e){const t=this.getItemKey(e);this.data.set(t,e);let s={...e};return s.data instanceof FormData&&(s.data=this.formDataToObject(s.data)),this.config.stripDOMReferences&&(s=this.stripDOMReferences(s)),await this.saveToDB(s),this.config.endpoint&&this.saveToServer(e),this.notify("item-saved",{item:s,key:t}),s}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(this.config.getBlobs&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids),i=await this.config.getBlobs(s);for(const e of i)if(e){const s=new File([e.data],e.name,{type:e.type,lastModified:e.lastModified});t.append("files[]",s)}}return t}async saveToDB(e){if(this.db)return new Promise(((t,s)=>{const i=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName).put(e);i.onsuccess=()=>t(),i.onerror=e=>s(e)}))}async saveMany(e){if(!this.db)return;const t=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName),s=e.map((e=>{const s=this.config.stripDOMReferences?this.stripDOMReferences(e):e,i=this.getItemKey(s);return this.data.set(i,s),t.put(s)}));await Promise.all(s),this.notify("items-saved",{count:e.length})}get(e){return this.data.get(e)}getAll(){return Array.from(this.data.values())}async delete(e,t=null){if(this.data.delete(e),t||(t=this.config.storeName),this.db){const s=this.db.transaction([t],"readwrite").objectStore(t);await s.delete(e)}this.notify("item-deleted",{key:e})}async saveBlob(e,t){if(!this.db)return;const s=this.db.transaction(["blobs"],"readwrite").objectStore("blobs");await s.put({uploadId:e,data:t,type:t.type,name:t.name,lastModified:t.lastModified||Date.now()})}async getBlob(e){return this.db?new Promise((t=>{const s=this.db.transaction(["blobs"],"readonly").objectStore("blobs").get(e);s.onsuccess=()=>t(s.result),s.onerror=()=>t(null)})):null}async clear(){if(this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.domCache&&this.domCache.clear(),this.db){const e=[this.config.storeName];this.config.endpoint&&e.push("cache"),this.config.useHttpCaching&&e.push("headers");const t=this.db.transaction(e,"readwrite");e.forEach((e=>{this.db.objectStoreNames.contains(e)&&t.objectStore(e).clear()}))}this.notify("data-cleared")}async fetch(e={}){if(!this.config.endpoint)throw new Error("No endpoint configured for fetch");const{filters:t=this.filters,headers:s={}}=e;if(this.config.required&&""===this.filters[this.config.required])return void console.log(this.config.storeName+": Not fetch as we don't have the required items");const i=this.generateCacheKey(t);if(console.log("CacheKey: ",i),this.isFetching&&this.currentCacheKey===i)return new Promise((e=>{this.pendingFetches||(this.pendingFetches=[]),this.pendingFetches.push(e)}));this.isFetching=!0,this.currentCacheKey=i;let n=null;this.config.showLoading&&this.setLoading(!0);const o=this.cache.get(i);if(console.log("Cached Data: ",o),o&&this.isCacheValid(o))return console.log("Returning cached data: "),this.isFetching=!1,this.currentCacheKey=null,this.config.showLoading&&this.setLoading(!1),o.data;const r={...this.headers,...s};if(this.config.useHttpCaching){const e=this.httpHeaders.get(i);e&&(e.etag&&(r["If-None-Match"]=e.etag),e.lastModified&&(r["If-Modified-Since"]=e.lastModified))}const a=this.cleanFilters(t),c=new URLSearchParams(a),h=`${this.config.apiBase}${this.config.endpoint}${c.toString()?"?"+c:""}`;try{const e=await fetch(h,{method:"GET",headers:r});if(304===e.status&&o)return o.timestamp=Date.now(),o.fromCache=!0,o.isError=!1,this.saveCache(i,o),console.log(this.config.storeName+" Data loaded from cache"),this.notify("data-loaded",o),n=o.data,o.data;if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const s=await e.json();this.config.useHttpCaching&&this.storeResponseHeaders(i,e);const a={key:i,data:s,timestamp:Date.now(),endpoint:this.config.endpoint,filters:t};console.log(this.config.storeName+"Fetched fresh from server"),this.cache.set(i,a),this.saveCache(i,a);let c=Array.isArray(s)?s:s.items;return await this.saveMany(c),this.notify("data-loaded",{data:{items:c,...s},count:c.length,filters:t,fromCache:!1,isError:!1}),n=s,s}catch(e){if(console.error("Fetch error:",e),o)return console.warn("Using stale cache due to fetch error"),o.isError=!0,this.notify("data-loaded",o),n=o.data,o.data;throw e}finally{this.config.showLoading&&this.setLoading(!1),this.isFetching=!1,this.currentCacheKey=null,this.pendingFetches&&this.pendingFetches.length>0&&(this.pendingFetches.forEach((e=>e(n))),this.pendingFetches=[])}}async saveToServer(e){if(!this.config.saveToServer||!jvbSettings.currentUser)return;if(!this.config.endpoint&&this.config.saveToServer)throw new Error("No endpoint configured for saving to server");let t,s=this.config.headers;s["X-WP-Nonce"]=jvbSettings.nonce,e instanceof FormData?(e.append("user",jvbSettings.currentUser),t=e):(t=JSON.stringify({...e,user:jvbSettings.currentUser}),s["Content-Type"]="application/json");const i=await fetch(`${this.config.apiBase}${this.config.endpoint}`,{method:"POST",headers:s,body:t}),n=await i.json();this.notify("saved-to-server",{success:n.ok&&n.success})}cleanFilters(e){const t={};return Object.entries(e).forEach((([e,s])=>{null!=s&&""!==s&&("taxonomies"===e&&"object"==typeof s?Object.entries(s).forEach((([e,s])=>{Array.isArray(s)&&s.length>0?t[`tax_${e}`]=s.join(","):s&&(t[`tax_${e}`]=s)})):"date"===e&&"object"==typeof s?(s.after&&(t.after=s.after),s.before&&(t.before=s.before)):t[e]=s)})),t}generateCacheKey(e){if("custom"===this.config.cacheKeyStrategy&&this.config.generateCacheKey)return this.config.generateCacheKey(e);const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}setFilter(e,t){this.filters||(this.filters={});const s=this.filters[e];s!==t&&(""===t||null==t?delete this.filters[e]:this.filters[e]=t,this.notify("filters-changed",{filters:this.filters,changed:{key:e,oldValue:s,newValue:t}}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}removeFilter(e){const t=this.filters[e];void 0!==t&&(delete this.filters[e],this.notify("filters-changed",{filters:this.filters,removed:{key:e,oldValue:t}}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}clearFilters(){const e={...this.filters};this.filters=this.config.filters,this.notify("filters-cleared",{oldFilters:e,filters:this.filters}),this.config.endpoint&&this.fetch()}async setFilters(e){Object.keys(e).some((t=>this.filters[t]!==e[t]))&&(this.filters={...this.filters,...e},this.notify("filters-changed",{filters:this.filters,changed:e}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}isCacheValid(e){return!(!e||!e.timestamp)&&Date.now()-e.timestamp<this.config.TTL}storeResponseHeaders(e,t){const s={key:e,etag:t.headers.get("ETag"),lastModified:t.headers.get("Last-Modified"),timestamp:Date.now()};this.httpHeaders.set(e,s),this.db&&this.db.objectStoreNames.contains("headers")&&this.db.transaction(["headers"],"readwrite").objectStore("headers").put(s)}async saveCache(e,t){if(!this.db||!this.db.objectStoreNames.contains("cache"))return;const s=this.db.transaction(["cache"],"readwrite").objectStore("cache");await s.put(t)}async loadCache(){if(this.db)return new Promise((e=>{this.db.transaction(["cache"],"readonly").objectStore("cache").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.isCacheValid(e)&&this.cache.set(e.key,e)})),e()}}))}async loadHeaders(){if(this.db)return new Promise((e=>{this.db.transaction(["headers"],"readonly").objectStore("headers").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.httpHeaders.set(e.key,e)})),e()}}))}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)}}))}async query(e,t){return this.db?new Promise(((s,i)=>{const n=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName);if(!n.indexNames.contains(e))return void i(new Error(`Index ${e} does not exist`));const o=n.index(e),r=void 0!==t?o.getAll(t):o.getAll();r.onsuccess=e=>{const t=e.target.result.map((e=>this.config.stripDOMReferences?this.stripDOMReferences(e):e));s(t)},r.onerror=e=>i(e)})):[]}async count(){return this.db?new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).count();s.onsuccess=t=>e(t.target.result),s.onerror=e=>t(e)})):this.data.size}setLoading(e){console.log("on"),this.body.classList.toggle("loading",e),e?this.loading.showModal():this.loading.close()}destroy(){this.currentRequest&&this.currentRequest.abort(),this.subscribers.clear(),this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.db&&(this.db.close(),this.db=null)}clearCache(){this.cache.clear(),this.db&&this.db.transaction(["cache"],"readwrite").objectStore("cache").clear(),this.notify("cache-cleared")}};
\ No newline at end of file
+(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,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(),window.addEventListener("beforeunload",(()=>this.destroy()))}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t={}){if(this.stores.has(e))return console.warn(`Store "${e}" already registered`),this.getStoreAPI(e);if(!t.keyPath)throw new Error(`Store "${e}" requires a keyPath`);const s={name:e,config:{dbName:`jvb_${e}_db`,version:1,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},db:null,data:new Map,cache:new Map,httpHeaders:new Map,filters:{...t.filters},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};return s.config.headers={"X-WP-Nonce":jvbSettings?.nonce,...s.config.headers},this.stores.set(e,s),this.subscribers.set(e,new Set),this.initStoreDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)})),this.getStoreAPI(e)}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}normalizeForStorage(e){if(null==e)return e;if(e instanceof Set)return Array.from(e);if(e instanceof Map)return Object.fromEntries(e);if(Array.isArray(e))return e.map((e=>this.normalizeForStorage(e)));if("object"==typeof e){const t={};for(const[s,r]of Object.entries(e))t[s]=this.normalizeForStorage(r);return t}return e}stripDOMReferences(e,t=new WeakSet){if(null==e)return e;const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return e;if("object"===s&&t.has(e))return"[Circular]";if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return null;if(e instanceof Date)return e;if(Array.isArray(e))return t.add(e),e.map((e=>this.stripDOMReferences(e,t))).filter((e=>null!==e));if("object"===s){t.add(e);const s={};for(const[r,i]of Object.entries(e)){const e=this.stripDOMReferences(i,t);null!==e&&(s[r]=e)}return s}return e}async initStoreDB(e){const t=this.stores.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performStoreInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performStoreInit(e){const t=this.stores.get(e),{dbName:s,version:r}=t.config;try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{this.setupStores(e,t.config)}));this.databases.set(s,e)}t.db=this.databases.get(s),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.initStoreDB(e)}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&&r)return this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r;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||[];for(const t of i)await this.save(e,t);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={has_more:t.has_more||!1,total:t.total||i.length,pages:t.pages||1}}async save(e,t){const s=this.stores.get(e);let r=this.normalizeForStorage(t);if(r=this.stripDOMReferences(r),s.config.validateData){const t=this.validateSerializable(r);if(!t.valid)throw console.error(`Cannot save non-serializable data to store "${e}":`,t.error),new Error(`Non-serializable data: ${t.error}`)}const i=this.getItemKey(r,s.config.keyPath);if(s.data.set(i,t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.put(r)}return this.notify(e,"item-saved",{item:t,key:i}),i}validateSerializable(e,t="root"){if(null==e)return{valid:!0};const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return{valid:!0};if("function"===s)return{valid:!1,error:`Function at ${t}`};if(e instanceof Date)return{valid:!0};if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return{valid:!1,error:`DOM element at ${t}`};if(e instanceof FormData)return{valid:!1,error:`FormData at ${t}. Convert to object first.`};if(e instanceof Blob||e instanceof File)return{valid:!1,error:`Blob/File at ${t}. Handle file uploads separately.`};if(Array.isArray(e)){for(let s=0;s<e.length;s++){const r=this.validateSerializable(e[s],`${t}[${s}]`);if(!r.valid)return r}return{valid:!0}}if("object"===s){if(e instanceof Set)return{valid:!1,error:`Set at ${t}. Convert to Array first: Array.from(set)`};if(e instanceof Map)return{valid:!1,error:`Map at ${t}. Convert to Object first: Object.fromEntries(map)`};for(const[s,r]of Object.entries(e)){const e=this.validateSerializable(r,`${t}.${s}`);if(!e.valid)return e}return{valid:!0}}return{valid:!1,error:`Unknown type at ${t}: ${s}`}}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){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",(function(){window.jvbStore=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/favourites.min.js b/assets/js/min/favourites.min.js
index c166151..47d780e 100644
--- a/assets/js/min/favourites.min.js
+++ b/assets/js/min/favourites.min.js
@@ -1 +1 @@
-(()=>{class t{constructor(){this.store=new window.jvbStore({name:"favourites",storeName:"favourites",endpoint:"favourites",indexes:[{name:"content",keyPath:"content"},{name:"listId",keyPath:"listId"}],TTL:864e5,showLoading:!1,filters:{user:jvbSettings.currentUser,content:"all",order:"desc",orderby:"date",page:1,all:!0}}),this.listStore=new window.jvbStore({name:"favourites_lists",storeName:"lists",keyPath:"listId",endpoint:"favourites/lists",TTL:864e5}),this.store.subscribe(((t,e)=>{t})),this.store.fetch()}toggleFavourite(t){if(!jvbSettings.currentUser)return void(window.location.href=jvbSettings.redirect+"&action=register&type=favourites");t.classList.toggle("favourited");const e=t.classList.contains("favourited")?"add":"remove",o=t.classList.contains("favourited")?`Added ${t.dataset.type} to favourites.`:`Removed ${t.dataset.type} from favourites.`;window.jvbA11y.announce(o),t.innerHTML=jvbSettings.icons[t.classList.contains("favourited")?"heart-filled":"heart"],this.store.setItem(t.dataset.id,{target_id:t.dataset.id,action:e,type:t.dataset.type,artist:t.dataset.artist})}isFavourited(t,e){const o=`${this.userId}_${t}_${e}`;return void 0!==this.store.get(o)}}document.addEventListener("DOMContentLoaded",(function(){window.jvbFavourites=!1,""!==jvbSettings.currentUser&&(window.jvbFavourites=new t)})),window.toggleFavourite=function(t){window.jvbFavourites()?window.jvbFavourites.toggleFavourite(t):console.log("No Favourites Loaded")},window.isFavourited=function(t,e){if(window.jvbFavourites())return window.jvbFavourites.isFavourited(t,e);console.log("No Favourites Loaded")}})();
\ No newline at end of file
+(()=>{class t{constructor(){this.store=window.jvbStore.register("favourites",{storeName:"favourites",endpoint:"favourites",indexes:[{name:"content",keyPath:"content"},{name:"listId",keyPath:"listId"}],TTL:36e4,showLoading:!1,filters:{user:jvbSettings.currentUser,content:"all",order:"desc",orderby:"date",page:1,all:!0}}),this.listStore=window.jvbStore.register("favourites_lists",{storeName:"lists",keyPath:"listId",endpoint:"favourites/lists",TTL:36e4}),this.store.subscribe(((t,e)=>{t})),this.store.fetch()}toggleFavourite(t){if(!jvbSettings.currentUser)return void(window.location.href=jvbSettings.redirect+"&action=register&type=favourites");t.classList.toggle("favourited");const e=t.classList.contains("favourited")?"add":"remove",i=t.classList.contains("favourited")?`Added ${t.dataset.type} to favourites.`:`Removed ${t.dataset.type} from favourites.`;window.jvbA11y.announce(i),t.innerHTML=jvbSettings.icons[t.classList.contains("favourited")?"heart-filled":"heart"],this.store.setItem(t.dataset.id,{target_id:t.dataset.id,action:e,type:t.dataset.type,artist:t.dataset.artist})}isFavourited(t,e){const i=`${this.userId}_${t}_${e}`;return void 0!==this.store.get(i)}}document.addEventListener("DOMContentLoaded",(function(){window.jvbFavourites=!1,""!==jvbSettings.currentUser&&(window.jvbFavourites=new t)})),window.toggleFavourite=function(t){window.jvbFavourites()?window.jvbFavourites.toggleFavourite(t):console.log("No Favourites Loaded")},window.isFavourited=function(t,e){if(window.jvbFavourites())return window.jvbFavourites.isFavourited(t,e);console.log("No Favourites Loaded")}})();
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 63e4b33..d564fba 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),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.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){(await this.store.query("status","draft")).forEach((e=>{let t=this.forms.get(e.formId);t&&t.element&&(t.element.querySelector(".restore-form").hidden=!1,new this.populateForm(t.element,e.data))}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(r,t.firstChild),r.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),r.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),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)};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.formData&&this.showPendingNotification(e)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(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})}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:r.id,data: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)}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]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").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]"))return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(s&&(console.log(s.options),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)}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){if(e.target.closest("[data-ignore]"))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"))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}`,((e,t)=>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 http:// or 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}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()||""}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")}));const r=this.getChangedFields(e.data,t);if(console.log("Changes:",r),0!==Object.keys(r).length){console.log("Continuing on:"),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,console.log("Setting status: ",t);const a=s.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.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",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",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);o&&a.prepend(o),""===r&&(r=n[t]||t),i.textContent=r,a.classList.toggle("loading",["uploading","saving"].includes(t)),"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);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 window.isEmptyObject(a)?this.mergeRepeaterData(r,s):(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a))}collectTimeline(e){console.log("Collecting Timeline data:");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,i]=a;r[t]||(r[t]={id:parseInt(t)},s.push(t));this.getFieldProcessor(i)(i,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[""],console.log("Data: ",t),t}getFieldProcessor(e){return e.includes("|")?this.processTableField: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[t,r]in Object.entries(r))e[t]=r;return e}processTableField(e,t,r,s,a,i){let[n,o]=e.split("|");!n in a&&(a[n]={});this.getFieldProcessor(o)(o,t,a,s,a,i)}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}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"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;const a=this.createResultElement(o,r,t,s);a&&n.appendChild(a)}o.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=null,a=null;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}}createResultElement(e,t,r,s){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(r,t.type,s);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}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,"&").replace(/</g,"<").replace(/>/g,">")).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("focus",this.focusHandler,!0),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}))})();
\ No newline at end of file
+(()=>{class e{constructor(e={}){this.config={collectFormData:!1,...e},this.store=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.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=!1,window.crudManager&&window.crudManager.isTimeline&&(this.isTimeline=!0),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.focusHandler=this.handleFocus.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()}}checkPendingForms(){this.store.getAll().filter((e=>"draft"===e.status)).forEach((e=>{const t=this.forms.get(e.formId);if(t?.element){const s=t.element.querySelector(".restore-form");s&&(s.hidden=!1),new this.populateForm(t.element,e.data)}}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e,t){const s=document.querySelector(`[data-form-id="${e}"]`);if(!s)return;const r=document.createElement("div");r.className="pending-changes-notification",r.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 `,s.insertBefore(r,s.firstChild),r.querySelector(".restore-changes").addEventListener("click",(async()=>{await this.restorePendingForm(e,t),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(async()=>{await this.discardPendingForm(e),r.remove()}))}async restorePendingForm(e,t){const s=document.querySelector(`[data-form-id="${e}"]`);s&&(new this.populateForm(s,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("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){if(!e)return;const s=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=s,e.addEventListener("submit",this.submitHandler);const r={element:e,id:s,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,r),this.forms.set(s,r),this.store&&r.options.cache){const e=this.store.get(s);e&&e.formData&&this.showPendingNotification(e)}return r}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(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),s=t.element,r=t.tabs,a=s.querySelectorAll(".tab-content").length,i=s.querySelector(".form-progress .fill"),n=s.querySelector(".step-text .current"),o=s.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,s)=>{const r=s+1;t.classList.remove("current","completed","pending"),r<e?t.classList.add("completed"):r===e?t.classList.add("current"):t.classList.add("pending")}))};s.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=s.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;r.switchTab(e,!0),l(i+1),s.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=s.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;r.switchTab(e,!0),l(i-1),s.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=r.switchTab.bind(r);r.switchTab=(e,t)=>{c(e,t);const r=s.querySelector(`.tab-content[data-tab="${e}"]`);if(r){const e=parseInt(r.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let s=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(s=!1)}})),s}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const s=e.querySelector(".add-repeater-row"),r=e.querySelector(".repeater-items"),a=e.querySelector("template");s&&a&&r&&(window.Sortable&&new Sortable(r,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),s.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),r.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const s=e.querySelector(".repeater-items"),r=e.querySelector("template"),a=s.children.length,i=e.dataset.field,n=r.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 s=e.nextElementSibling;s&&"LABEL"===s.tagName&&(s.htmlFor=e.id)})),s.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 s=e.closest(".repeater"),r=s.dataset.field;e.remove(),this.updateRepeaterOrder(s,t),t&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:r,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const s=e.querySelector(".repeater-items"),r=e.dataset.field;Array.from(s.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const s=e.name.split(":");if(3===s.length){const a=s[2];e.name=`${r}:${t}:${a}`,e.id=`${r}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:r,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((s=>{const r=s.dataset.dependsOn,a=s.dataset.dependsValue,i=s.dataset.dependsOperator||"==";t.dependencies.has(r)||t.dependencies.set(r,[]),t.dependencies.get(r).push({field:s,requiredValue:a,operator:i}),this.checkFieldDependency(e,s,r,a,i)}))}checkFieldDependency(e,t,s,r,a){const i=e.querySelector(`[name="${s}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,r,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,s){const r=String(e||""),a=String(t||"");switch(s){case"==":default:return r==a;case"!=":return r!=a;case">":return parseFloat(r)>parseFloat(a);case"<":return parseFloat(r)<parseFloat(a);case">=":return parseFloat(r)>=parseFloat(a);case"<=":return parseFloat(r)<=parseFloat(a);case"contains":return r.includes(a);case"empty":return""===r;case"not_empty":return""!==r}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.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),s=e.closest(".field");let r=s?.querySelector(".char-count");!r&&s&&(r=document.createElement("div"),r.className="char-count",r.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,s.appendChild(r));const a=()=>{const s=e.value.length;r&&(r.querySelector(".current").textContent=s,r.classList.toggle("exceeded",s>t)),s>t&&(e.value=e.value.substring(0,t),r&&(r.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 s=this.forms.get(t.dataset.formId);if(this.subscribers.size>0){e.preventDefault();const r=this.collectFormData(t);this.notify("form-submit",{formId:t.dataset.formId,fullData:r,config:s})}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 s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const r=window.getIcon?.("check-circle");r&&(r.classList.add("success-icon"),s.prepend(r))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,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 s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const r=window.getIcon?.("close-circle");r&&(r.classList.add("error-icon"),s.prepend(r)),e.insertBefore(s,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]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").hidden=!0}}}handleNumberClick(e,t){let s=0;if(e.target.closest(".increase")?s+=1:e.target.closest(".decrease")&&(s-=1),0!==s){let r=parseFloat(t.step);r=Math.max(r,1),e.ctrlKey&&e.shiftKey?r*=50:e.ctrlKey?r*=5:e.shiftKey&&(r*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+r*s,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,s,r,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>s?(e.value=s,r.disabled=!1):r.disabled?r.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=this.forms?.get(s.dataset.formId);if(r&&(r.options.autosave||this.subscribers.size>0)){const e=r.dependencies.get(t.name);e&&e.forEach((e=>{this.checkFieldDependency(s,e.field,t.name,e.requiredValue,e.operator)}));const a=this.getDelayForField(t);this.scheduleSave(r,a)}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=e.target.closest("input, textarea, select");if(r){const e=this.findFieldWrapper(r);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(r)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(r,e)}const a=this.forms?.get(s.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"))return;const t=e.target.closest("input, textarea, select");if(!t)return;let s=t.closest("form");this.showFormStatus(s.dataset.formId,"pending");const r=this.findFieldWrapper(t);if(!r)return;const a=r.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,((e,t)=>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 http:// or https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const r=t.dataset.min,a=t.dataset.max;return void 0!==r&&s<parseFloat(r)?`Value must be at least ${r}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,r=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(r&&e.length>parseInt(r))||`Must be no more than ${r} 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 s=this.getFieldValue(e),r=t.dataset.field;if(!this.touchedFields.has(r)&&!e.required)return!0;if(!s&&!e.required)return this.clearValidation(t),!0;if(e.required&&!s)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&&s){if(!new RegExp(a).test(s)){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(s))return this.showError(t,e.message),!1;if(e.test){const r=e.test(s,t);if(!0!==r)return this.showError(t,r),!1}}return this.showSuccess(t),this.notify("field-validated",e),!0}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()||""}showSuccess(e,t=""){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=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"),s&&(s.hidden=!1),r&&(r.hidden=!0),a&&(""===t?(a.hidden=!0,a.textContent=""):(a.hidden=!1,a.textContent=t))}showError(e,t){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=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"),s&&(s.hidden=!0),r&&(r.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),s=e.querySelector(".validation-message"),r=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),r?.classList.remove("error"),t&&(t.hidden=!0),s&&(s.hidden=!0,s.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let s=!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 r=e.dataset.field;r&&this.touchedFields.add(r);this.validateField(t,e)||(s=!1,!1===s&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),s}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 s=t.target.closest("input, select");if(!s)return;const r=s.name;if(!r)return;e.querySelectorAll(`[data-show-if*="${r}"]`).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 s=e.dataset.field,r=e.querySelector(".validation-message");s&&r&&(t[s]=r.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 s=`autosave_${e.id}`;this.debouncer.schedule(s,(()=>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 s=this.getChangedFields(e.data,t);if(0!==Object.keys(s).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,r]of Object.entries(t))"object"==typeof r&&(s[e]=r);this.notify("form-autosave",{formId:e.id,changes:s,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const s=this.collectFormData(t.element),r=this.getChangedFields(t.data,s);return Object.keys(r).length>0}showFormStatus(e,t,s=""){let r=this.forms.get(e);if(!r.options.formStatus)return;if(r.status===t)return;r.status=t;const a=r.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.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",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",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);o&&a.prepend(o),""===s&&(s=n[t]||t),i.textContent=s,a.classList.toggle("loading",["uploading","saving"].includes(t)),"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 s=new FormData(e);let r={};const a={},i={};for(let[t,n]of s.entries()){if(this.ignore.includes(t)||t.endsWith("_temp"))continue;this.getFieldProcessor(t)(t,n,r,a,i,e)}return window.isEmptyObject(i)?this.mergeRepeaterData(r,a):(r=this.mergeRepeaterData(r,a),this.mergePostData(r,i))}collectTimeline(e){let t={},s={},r=[],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(s[t]||(s[t]={id:parseInt(t)},r.push(t)),"post_thumbnail"===o)s[t].post_thumbnail=parseInt(e.querySelector(`[name="${i}"]`).closest(".item")?.dataset.id);else{this.getFieldProcessor(o)(o,n,s[t],{},{},e)}}else{this.getFieldProcessor(i)(i,n,t,{},{},e)}}return t.timeline=r.map((e=>s[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((s=>{const r={};Object.keys(t[s]).forEach((e=>{const a=t[s][e];Object.keys(a).length>0&&(r[e]=a)})),e[s]=Object.values(r)})),e}mergePostData(e,t){for(let[s,r]of Object.entries(t))e[s]=r;return e}processRepeaterField(e,t,s,r,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),r[n]||(r[n]={}),r[n][o]||(r[n][o]={}),c||r[n][o][l]?(r[n][o][l]?Array.isArray(r[n][o][l])||(r[n][o][l]=[r[n][o][l]]):r[n][o][l]=[],r[n][o][l].push(t)):r[n][o][l]=t}processGroupField(e,t,s,r,a,i){const n=e.split("::"),o=n[0];s[o]||(s[o]={});let l=s[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,s,r,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(s,n)||(s[n]={},Object.hasOwn(s,"sendAll")?s.sendAll.includes(n)||s.sendAll.push(n):s.sendAll=[n]),s[n][o]=t}processRegularField(e,t,s,r,a,i){s[e=e.replace("[]","")]?(Array.isArray(s[e])||(s[e]=[s[e]]),s[e].push(t)):s[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}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const s=this.forms.get(e);if(!s)return;const r=s.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(s.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const s=this.getFieldInfo(r,e);if(!s.label)continue;const a=this.createResultElement(o,s,t,r);a&&n.appendChild(a)}o.remove(),(t="form"!==t?r.closest(t)??r:r).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 s=e.querySelector(`label[for="${t}"]`),r=null,a=null;if(r||(r=e.querySelector(`[name="${t}"]`)),r||(r=e.querySelector(`[name="${t}[]"]`)),!r){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(s=a.querySelector("legend"),r=a.querySelector("input, select, textarea"))}if(!s&&r){const e=r.closest(".field, fieldset");e&&(s=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:r&&(i="checkbox"===r.type&&r.name.endsWith("[]")?"checkbox":"checkbox"===r.type?"true_false":"SELECT"===r.tagName&&r.multiple?"select":r.type||"text"),{label:s?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:r}}createResultElement(e,t,s,r){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(s,t.type,r);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}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,s){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,s,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,s,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,s)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${s+1}:</strong><ul>`;for(const[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${r}</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[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof r||Array.isArray(r)?t+=`<li><strong>${e}:</strong> ${r}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(r)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((s=>{e[s]&&t.push(e[s])})),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,s=null){if(0===e.length)return"<em>None selected</em>";if(t&&s&&s.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,s.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,s){if("select"===s){const s=t.querySelector(`option[value="${e}"]`);return s?.textContent||e}if("radio"===s){const s=t.querySelector(`input[type="radio"][value="${e}"]`),r=s?.nextElementSibling;return r?.textContent||e}if("checkbox"===s){const s=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(s){const e=t.querySelector(`label[for="${s.id}"]`);if(e)return e.textContent.trim();const r=s.nextElementSibling;if("LABEL"===r?.tagName)return r.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,"&").replace(/</g,"<").replace(/>/g,">")).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((s=>s(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("focus",this.focusHandler,!0),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}))})();
\ No newline at end of file
diff --git a/assets/js/min/gallery.min.js b/assets/js/min/gallery.min.js
index 6f7511b..c5de416 100644
--- a/assets/js/min/gallery.min.js
+++ b/assets/js/min/gallery.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(e,t={}){this.container=t.container?document.querySelector(t.container):document.querySelector("main"),this.gallerySelector=t.gallerySelector||"img[data-small]",this.modal=new window.jvbModal(e,{onOpen:!1,onSave:!1,onClose:()=>this.closeGallery(!1)}),this.modalElement="string"==typeof e?document.querySelector(e):e,this.modalElement&&(this.a11y=window.jvbA11y,this.index=0,this.items=[],this.swipe={touchStart:null,touchEnd:null,minSwipe:50},this.initElements(),this.initListeners())}initElements(){this.prevBtn=this.modalElement.querySelector(".prev"),this.nextBtn=this.modalElement.querySelector(".next"),this.image=this.modalElement.querySelector(".image"),this.counter=this.modalElement.querySelector(".counter")}initListeners(){this.container.addEventListener("click",this.handleClick.bind(this)),this.boundKeyHandler=this.handleKeys.bind(this),this.boundTouchStart=this.handleTouchStart.bind(this),this.boundTouchMove=this.handleTouchMove.bind(this),this.boundTouchEnd=this.handleTouchEnd.bind(this)}handleClick(e){const t=e.target.closest(this.gallerySelector);if(t&&!this.modal.isOpen)return e.preventDefault(),void this.openGallery(t);this.modal.isOpen&&(e.target.closest(".next")?this.navigate(1):e.target.closest(".prev")&&this.navigate(-1))}handleKeys(e){if(this.modal.isOpen)switch(e.key){case"ArrowLeft":e.preventDefault(),this.navigate(-1);break;case"ArrowRight":e.preventDefault(),this.navigate(1)}}handleTouchStart(e){this.modal.isOpen&&(this.swipe.touchStart=e.touches[0].clientX)}handleTouchMove(e){this.modal.isOpen&&(this.swipe.touchEnd=e.touches[0].clientX)}handleTouchEnd(e){if(!this.modal.isOpen||!this.swipe.touchStart||!this.swipe.touchEnd)return;const t=this.swipe.touchStart-this.swipe.touchEnd;Math.abs(t)>this.swipe.minSwipe&&this.navigate(t>0?1:-1),this.swipe.touchStart=null,this.swipe.touchEnd=null}buildGalleryItems(){return Array.from(this.container.querySelectorAll(this.gallerySelector)).map(((e,t)=>({id:e.dataset.id||t,small:e.dataset.small||e.src,medium:e.dataset.medium||e.src,full:e.dataset.full||e.src,alt:e.alt||"",element:e})))}openGallery(e){this.items=this.buildGalleryItems(),this.index=this.items.findIndex((t=>t.element===e)),-1===this.index&&(this.index=0),document.addEventListener("keydown",this.boundKeyHandler),document.addEventListener("touchstart",this.boundTouchStart,{passive:!0}),document.addEventListener("touchmove",this.boundTouchMove,{passive:!0}),document.addEventListener("touchend",this.boundTouchEnd,{passive:!0}),this.modal.handleOpen(),this.updateDisplay(),this.preloadAdjacent(),this.a11y.announce(`Gallery opened. Image ${this.index+1} of ${this.items.length}. Use arrow keys to navigate.`)}closeGallery(e=!0){document.removeEventListener("keydown",this.boundKeyHandler),document.removeEventListener("touchstart",this.boundTouchStart),document.removeEventListener("touchmove",this.boundTouchMove),document.removeEventListener("touchend",this.boundTouchEnd),e&&this.modal.handleClose()}navigate(e){let t=this.index+e;t<0?t=this.items.length-1:t>=this.items.length&&(t=0),this.index=t,this.updateDisplay(),this.preloadAdjacent(),this.a11y.announce(`Image ${this.index+1} of ${this.items.length}`)}updateDisplay(){const e=this.items[this.index];if(!e)return;const t=window.innerWidth<1e3?e.medium||e.src:e.full||e.src;this.image.src=t,this.image.alt=e.alt,this.counter.textContent=`${this.index+1} / ${this.items.length}`,this.prevBtn.classList.toggle("disabled",this.items.length<=1),this.nextBtn.classList.toggle("disabled",this.items.length<=1)}preloadAdjacent(){[-1,1].forEach((e=>{const t=this.index+e;if(t>=0&&t<this.items.length){const e=this.items[t];(new Image).src=window.innerWidth<1e3?e.medium||e.src:e.full||e.src}}))}cleanup(){this.container.removeEventListener("click",this.handleClick),this.closeGallery(!1)}}document.addEventListener("DOMContentLoaded",(function(){let t=document.querySelectorAll("dialog.gallery");t.length>0&&(window.galleries=new Map,t.forEach((t=>{let i=t.id;window.galleries.set(i,new e(t))})))}))})();
\ No newline at end of file
+(()=>{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:"a.open-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-opens="${e}"]`:this.elements.imageSelector;this.items=Array.from(document.querySelectorAll(t)).map(((e,t)=>{let i=e.querySelector("img");return{id:e.dataset.id||t,small:i.dataset.small||e.src,medium:i.dataset.medium||e.src,full:i.dataset.full||e.src,alt:i.alt||"",element:i}}))}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(Object.hasOwn(t.dataset,"opens")?t.dataset.opens:null),this.index=this.items.findIndex((e=>e.element===t.querySelector("img"))),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){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)}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())),this.zoom.panning=!1}}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,a=t.height-o-32;this.zoom.x=Math.min(32,Math.max(l,this.zoom.x)),this.zoom.y=Math.min(32,Math.max(a,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 a=t-l.left,r=i-l.top;this.zoom.x=(this.zoom.x-a)*o+a,this.zoom.y=(this.zoom.y-r)*o+r,this.zoom.scale=n,this.applyTransform(),this.notify("zoom",{scale:this.zoom.scale})}applyTransform(){this.ui.gallery.image.style.transform=`translate(${this.zoom.x}px, ${this.zoom.y}px) scale(${this.zoom.scale})`}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.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];e&&(this.ui.gallery.image.src=e.full,this.ui.gallery.image.alt=e.alt,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)}))})();
\ No newline at end of file
diff --git a/assets/js/min/media.min.js b/assets/js/min/media.min.js
index 84dab73..71ef1d6 100644
--- a/assets/js/min/media.min.js
+++ b/assets/js/min/media.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.currentWidth=window.innerWidth,this.images=document.querySelectorAll(".wp-site-blocks img[data-small]"),0!==this.images.length&&(this.loadVisibleImages(),this.initListeners())}loadVisibleImages(){this.images.forEach(((e,t)=>{const i=e.getBoundingClientRect(),s=i.top<window.innerHeight&&i.bottom>0;(0===t||s)&&(this.loadAppropriateImage(e),e.dataset.loaded="true")}))}initListeners(){this.resizeHandler=this.handleResize.bind(this),window.addEventListener("resize",this.resizeHandler),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&(this.loadAppropriateImage(e.target),this.observer.unobserve(e.target))}))}),{rootMargin:"50px",threshold:.1}),this.images.forEach((e=>{e.dataset.loaded||this.observer.observe(e)}))}handleResize(){window.debouncer.schedule("image-resize",(()=>{const e=window.innerWidth;Math.abs(e-this.currentWidth)>100&&(this.currentWidth=e,this.updateVisibleImages())}),150)}updateVisibleImages(){this.images.forEach((e=>{const t=e.getBoundingClientRect();t.top<window.innerHeight&&t.bottom>0&&this.loadAppropriateImage(e,!0)}))}loadAppropriateImage(e,t=!1){const i=this.getTargetSize(),s=e.dataset[i];s&&(t||s!==e.currentSrc)&&(e.src=s)}getTargetSize(){return this.currentWidth<768?"small":this.currentWidth<1200?"medium":"full"}cleanup(){this.observer?.disconnect(),window.removeEventListener("resize",this.resizeHandler)}}window.isLoaded=!1,document.addEventListener("readystatechange",(()=>{!window.isLoaded&&document.querySelector(".wp-site-blocks img")&&(window.jvbMedia=new e,window.isLoaded=!0)}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.currentWidth=window.innerWidth,this.images=document.querySelectorAll(".wp-site-blocks img[data-small]"),0!==this.images.length&&(this.loadVisibleImages(),this.initListeners())}loadVisibleImages(){this.images.forEach(((e,t)=>{const i=e.getBoundingClientRect(),s=i.top<window.innerHeight&&i.bottom>0;(0===t||s)&&(this.loadAppropriateImage(e),e.dataset.loaded="true")}))}initListeners(){this.resizeHandler=this.handleResize.bind(this),window.addEventListener("resize",this.resizeHandler),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&(this.loadAppropriateImage(e.target),this.observer.unobserve(e.target))}))}),{rootMargin:"50px",threshold:.1}),this.images.forEach((e=>{e.dataset.loaded||this.observer.observe(e)}))}handleResize(){window.debouncer.schedule("image-resize",(()=>{const e=window.innerWidth;Math.abs(e-this.currentWidth)>100&&(this.currentWidth=e,this.updateVisibleImages())}),150)}updateVisibleImages(){this.images.forEach((e=>{const t=e.getBoundingClientRect();t.top<window.innerHeight&&t.bottom>0&&this.loadAppropriateImage(e,!0)}))}loadAppropriateImage(e,t=!1){const i=this.getTargetSize(),s=e.dataset[i];s&&(t||s!==e.currentSrc)&&(e.src=s)}getTargetSize(){return this.currentWidth<768?"medium":this.currentWidth<1200?"large":"full"}cleanup(){this.observer?.disconnect(),window.removeEventListener("resize",this.resizeHandler)}}window.isLoaded=!1,document.addEventListener("readystatechange",(()=>{!window.isLoaded&&document.querySelector(".wp-site-blocks img")&&(window.jvbMedia=new e,window.isLoaded=!0)}))})();
\ No newline at end of file
diff --git a/assets/js/min/modal.min.js b/assets/js/min/modal.min.js
index 0bc2e51..a9358c5 100644
--- a/assets/js/min/modal.min.js
+++ b/assets/js/min/modal.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(t,s){this.modal=t,this.a11y=window.jvbA11y,this.options={openMessage:"Opened modal",closeMessage:"Closed modal",open:null,close:"button.cancel",save:'button[type="submit"]',...s},this.subscribers=new Set,e.modalStack||(e.modalStack=[]),this.boundEscapeListener=this.handleEscapeListener.bind(this),this.boundBackdropListener=this.handleBackdropListener.bind(this),this.init()}init(){this.isOpen=!1,this.hasChanges=!1,this.initElements(),this.initEvents()}initElements(){this.elements={open:this.options.open,close:this.options.close,save:this.options.save}}handleClose(){if(e.modalStack[e.modalStack.length-1]!==this)return;this.notify("modal-close",this.modal),this.a11y.announce(this.options.closeMessage),this.modal.close();const t=e.modalStack.indexOf(this);-1!==t&&e.modalStack.splice(t,1),this.showBody(),this.removeCloseListeners()}handleOpen(t){this.addCloseListeners(),this.hideBody(),this.isOpen=!0,this.modal.showModal(),this.a11y||(this.a11y=window.jvbA11y),this.a11y.trapFocus(this.modal),this.a11y.announce(this.options.openMessage),e.modalStack.push(this),this.notify("modal-open",{modal:this.modal,event:t})}addCloseListeners(){document.addEventListener("keydown",this.boundEscapeListener),document.addEventListener("click",this.boundBackdropListener)}removeCloseListeners(){document.removeEventListener("keydown",this.boundEscapeListener),document.removeEventListener("click",this.boundBackdropListener)}handleEscapeListener(t){"Escape"===t.key&&e.modalStack[e.modalStack.length-1]===this&&(t.preventDefault(),this.handleClose())}handleBackdropListener(t){t.target===this.modal&&e.modalStack[e.modalStack.length-1]===this&&this.handleClose()}hideBody(){0===e.modalStack.length&&(document.body.style.overflow="hidden")}showBody(){0===e.modalStack.length&&(document.body.style.overflow="")}initEvents(){document.addEventListener("click",this.handleClick.bind(this))}handleClick(t){this.elements.open&&window.targetCheck(t,this.elements.open)?this.handleOpen(t):this.modal.contains(t.target)&&(this.elements.save&&window.targetCheck(t,this.elements.save)||this.elements.close&&window.targetCheck(t,this.elements.close)&&e.modalStack[e.modalStack.length-1]===this)&&this.handleClose()}static getTopModal(){return e.modalStack[e.modalStack.length-1]||null}static getAllModals(){return[...e.modalStack]}static closeTopModal(){const t=e.getTopModal();t&&t.handleClose()}static closeAllModals(){for(;e.modalStack.length>0;)e.closeTopModal()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.subscribers.clear(),e.closeAllModals(),this.showBody(),this.removeCloseListeners()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbModal=e}))})();
\ No newline at end of file
+(()=>{class e{constructor(t,s){this.modal=t,this.a11y=window.jvbA11y,this.options={openMessage:"Opened modal",closeMessage:"Closed modal",open:null,close:"button.cancel",save:'button[type="submit"]',...s},this.subscribers=new Set,e.modalStack||(e.modalStack=[]),this.boundEscapeListener=this.handleEscapeListener.bind(this),this.boundBackdropListener=this.handleBackdropListener.bind(this),this.init()}init(){this.isOpen=!1,this.hasChanges=!1,this.initElements(),this.initEvents()}initElements(){this.elements={open:this.options.open,close:this.options.close,save:this.options.save}}handleClose(){if(e.modalStack[e.modalStack.length-1]!==this)return;this.notify("modal-close",this.modal),this.a11y.announce(this.options.closeMessage),this.modal.close(),this.isOpen=!1;const t=e.modalStack.indexOf(this);-1!==t&&e.modalStack.splice(t,1),this.showBody(),this.removeCloseListeners()}handleOpen(t){this.addCloseListeners(),this.hideBody(),this.isOpen=!0,this.modal.showModal(),this.a11y||(this.a11y=window.jvbA11y),this.a11y.trapFocus(this.modal),this.a11y.announce(this.options.openMessage),e.modalStack.push(this),this.notify("modal-open",{modal:this.modal,event:t})}addCloseListeners(){document.addEventListener("keydown",this.boundEscapeListener),document.addEventListener("click",this.boundBackdropListener)}removeCloseListeners(){document.removeEventListener("keydown",this.boundEscapeListener),document.removeEventListener("click",this.boundBackdropListener)}handleEscapeListener(t){"Escape"===t.key&&e.modalStack[e.modalStack.length-1]===this&&(t.preventDefault(),this.handleClose())}handleBackdropListener(t){t.target===this.modal&&e.modalStack[e.modalStack.length-1]===this&&this.handleClose()}hideBody(){0===e.modalStack.length&&(document.body.style.overflow="hidden")}showBody(){0===e.modalStack.length&&(document.body.style.overflow="")}initEvents(){document.addEventListener("click",this.handleClick.bind(this))}handleClick(t){this.elements.open&&window.targetCheck(t,this.elements.open)?this.handleOpen(t):this.modal.contains(t.target)&&(this.elements.save&&window.targetCheck(t,this.elements.save)||this.elements.close&&window.targetCheck(t,this.elements.close)&&e.modalStack[e.modalStack.length-1]===this)&&this.handleClose()}static getTopModal(){return e.modalStack[e.modalStack.length-1]||null}static getAllModals(){return[...e.modalStack]}static closeTopModal(){const t=e.getTopModal();t&&t.handleClose()}static closeAllModals(){for(;e.modalStack.length>0;)e.closeTopModal()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.subscribers.clear(),e.closeAllModals(),this.showBody(),this.removeCloseListeners()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbModal=e}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index eaaad5a..4ffdb88 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-window.jvbPopulate=class{constructor(e,t={},a={},r={}){for(let[l,i]of Object.entries(t)){let t=e.querySelector(`[data-field="${l}"]`);t&&this.populateField(t,l,i,a,r)}}populateField(e,t,a,r={},l={}){if(e&&null!=a)switch(this.getFieldType(e)){case"image":this.populateImageField(e,t,a,r);break;case"gallery":this.populateGalleryField(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){const t=["image","gallery","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;if(e.dataset.type)return e.dataset.type;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&&(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 i=e.querySelector(`input[type="radio"][name="${t}"][value="${r}"]`);i&&(i.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 i=e.querySelector(".editor");if(i){let e=null;if(i.__quill)e=i.__quill;else if(i.quill)e=i.quill;else if(window.Quill&&window.Quill.find)e=window.Quill.find(i);else if(window.Quill&&window.Quill.instances)for(let t of window.Quill.instances)if(t.container===i){e=t;break}e?(e.root.innerHTML=l,i.__quill=e):(console.warn(`Quill instance not found for ${t}, setting HTML directly`),i.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)}populateImageField(e,t,a,r={}){if(!a)return;const l=String(a).split(",").filter((e=>parseInt(e.trim())));if(0===l.length)return;const i=e.querySelector(`input[type="hidden"][name="${t}"]`);i&&(i.value=l.join(","));const o=e.querySelector(".item-grid"),n=e.querySelector(".file-upload-container");e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),l.forEach((e=>{let t=window.getTemplate("uploadItem"),a=t.querySelector("img"),l=t.querySelector("details"),i=window.getTemplate("uploadMeta");l.append(i),[a.src,a.alt,t.querySelector('[name="image-title"]').value,t.querySelector('[name="image-alt-text"]').value,t.querySelector('[name="image-caption"]').value]=[r[e].medium,r[e].alt,r[e].title,r[e].alt,r[e].caption],l.querySelector(".upload-meta > .hint")?.remove(),o.append(t)})),l.length>0&&n&&(n.hidden=!0))}populateGalleryField(e,t,a,r={}){"timeline"===t?this.populateTimelineGallery(e,t,a,r):this.populateImageField(e,t,a,r)}populateTimelineGallery(e,t,a,r){if(!a)return;const l=Object.keys(a);if(0===l.length)return;const i=e.querySelector(".item-grid");e.querySelector(".file-upload-container"),e.querySelector(".progress")?.remove(),i&&(window.removeChildren(i),l.forEach((e=>{let t=window.getTemplate("timelineItem"),l=t.querySelector("img");t.querySelector("video")?.remove(),t.querySelector(".select-item span")?.remove();let o=a[e],n=r[e];l.src=n.medium,o={...o,...n},t.querySelectorAll(".field").forEach((e=>{if(e.classList.contains("group"))return;let t=e.querySelector("input, textarea"),a=e.querySelector("label"),l=t.name.replace("upload_data::",""),i=o[l];this.populateField(e,l,i,r);let n=`[${o.id}]${l}`;[t.name,t.id,a.htmlFor]=[n,n,n]})),i.append(t)})))}populateRepeaterField(e,t,a,r={}){if(!a||!Array.isArray(a))return;const l=e.querySelector(".repeater-items"),i=e.querySelector("template");l&&i?(window.removeChildren(l),a.forEach(((a,r)=>{if(!a||"object"!=typeof a)return;const o=window.getTemplate(i.className);if(!o)return void console.warn(`Repeater field ${t}: template not found`);o.id=`${e.closest("form").id}-${t}-row-${r}`,o.dataset.index=r;const n=o.querySelector(".row-number");n&&(n.textContent=`#${r+1}`),o.querySelectorAll("input, select, textarea").forEach((e=>{const l=e.name,i=`${t}:${r}:${l}`,o=`${t}-${r}-${l}-${e.value}`;e.name=i,e.id=o;const n=e.nextElementSibling;n&&"LABEL"===n.tagName&&(n.htmlFor=o),void 0!==a[l]&&this.populateRepeaterFieldValue(e,l,a[l])})),l.appendChild(o)}))):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||"")}}};
\ No newline at end of file
+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||"")}}};
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index 2139ecc..ad79130 100644
--- a/assets/js/min/queue.min.js
+++ b/assets/js/min/queue.min.js
@@ -1 +1 @@
-(()=>{class t{constructor(t={}){this.canUpdateUI=!0,console.log("jvbSettings",jvbSettings),this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...t},this.user=jvbSettings.currentUser,console.log(this.user),this.headers={"X-WP-Nonce":jvbSettings.nonce,...t.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError,this.store=new window.jvbStore({name:"queue",storeName:"operations",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,getBlobs:async t=>{if(window.jvbUploadBlobs){Array.isArray(t)||"string"!=typeof t||(t=[t]);return(await Promise.all(t.map((t=>window.jvbUploadBlobs.getBlob(t))))).filter(Boolean)}return null}}),this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),console.log(this.ui),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}async initQueue(){const t=this.getOperationsByStatus(["completed","failed_permanent"],!1);t.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((t,e)=>{switch(t){case"data-loaded":this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&this.startPolling(),this.updateUI();break;case"item-saved":this.hasQueuedOperations()&&this.startPolling();default:this.updateUI()}})),this.notify("queue-initialized",{operations:t})}addToQueue(t){const e={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,...t};if(e.headers={...this.headers,...e.headers},!e.endpoint||!e.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((t=>"queued"===t.status&&t.endpoint===e.endpoint&&t.canMerge));if(s.length>0){const t=s[0];return t.data=window.deepMerge(t.data,e.data),t.timestamp=Date.now(),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}return console.log("Added to Queue: ",e),this.setQueue(e),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}setQueue(t){this.store.save(t)}updateOperationStatus(t,e){let s=this.store.get(t);s&&(s.status=e,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(t){return this.store.get(t)}clearQueue(t){this.store.delete(t)}startActivityTracking(){if(!this.activityListeners){const t=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=t.map((t=>{const e=()=>this.resetActivityTimer();return document.addEventListener(t,e,{passive:!0}),{event:t,handler:e}}))}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:t,handler:e})=>{document.removeEventListener(t,e)})),this.activityListeners=null)}setProcessing(t){this.isProcessing=t,this.ui.toggle.classList.toggle("saving",t)}async processQueue(){if(this.isProcessing)return;const t=this.getOperationsByStatus("queued");if(0===t.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const e of t)await this.processOperation(e);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0&&this.startPolling()}async processOperation(t){try{this.updateOperationStatus(t.id,"uploading"),t=this.getQueue(t.id);const i=`${this.config.apiBase}${t.endpoint}`;let a;if(console.log(t.data),t.data instanceof FormData)for(var[e,s]of(t.data.append("id",t.id),t.data.append("user",this.user),a=t.data,console.log("Sending to server:"),a.entries()))console.log(e,s);else a=JSON.stringify({...t.data,id:t.id,user:this.user}),t.headers["Content-Type"]="application/json";const n=await fetch(i,{method:t.method,headers:t.headers,body:a}),r=await n.json();if(!n.ok||!1===r.success)throw new Error(r.message||`HTTP ${n.status}`);if(r.id&&t.id!==r.id){const e=this.getQueue(r.id);e?(e.data=window.deepMerge(e.data,t.data),e.status="pending",e.serverData=r,this.updateOperationStatus(e.id,e.status),this.setQueue(e),this.removeOperationFromUI(t.id),t=e):(this.clearQueue(t.id),t.id=r.id,t.status="pending",t.serverData=r,this.updateOperationStatus(t.id,t.status),this.setQueue(t))}else t.status="pending",t.serverData=r,this.updateOperationStatus(t.id,"pending"),this.setQueue(t);this.a11y.announce(`${t.title} sent to server for processing.`)}catch(e){console.error("Operation failed:",e),t.retries++,t.lastError=e.message,t.retries>=this.config.maxRetries?t.status="failed_permanent":(t.status="failed",t.nextRetry=Date.now()+1e3*Math.pow(2,t.retries)),this.updateOperationStatus(t.id,t.status),this.setQueue(t)}}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{await this.store.fetch();0===this.getOperationsByStatus(["completed","failed_permanent"],!1).length&&(this.stopPolling(),this.updateStatusPanel("synced"))}catch(t){console.error("Polling error:",t)}}),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(t,e){if(0!==(t=(t=Array.isArray(t)?t:t.includes(",")?t.split(","):[t]).filter((t=>{let s=this.getQueue(t);return this.getAllowedActions(s.status).includes(e)}))).length){["cancel","dismiss"].includes(e)&&t.forEach((t=>{this.removeOperationFromUI(t)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:t,action:e})});if(!i.ok){const t=await i.json().catch((()=>{}));throw new Error(t.message||`${e} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${e} operation failed`);return["cancel","dismiss"].includes(e)?t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),this.clearQueue(t)})):(t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:e,operationIds:t,itemCount:t.length},(()=>this.updateServerOperations(t,e)));if(i.retried)return i;throw s}}}getAllowedActions(t){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[t]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=t=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return t.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(t){if(t.target.closest(this.selectors.panel,this.selectors.toggle))if(t.target.closest(this.selectors.refreshButton))this.store.fetch();else if(t.target.closest(this.selectors.clearButton)){const t=this.getOperationsByStatus("completed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"dismiss")}}else if(t.target.closest(this.selectors.retryButton)){const t=this.getOperationsByStatus("failed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"retry")}}else if(t.target.closest("[data-action]")){const e=t.target.closest("[data-action]"),s=e.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,e.dataset.action)}else if(t.target.closest(".filters [data-filter]")){const e=t.target.closest("[data-filter]").dataset.filter;this.setFilter(e)}}handleChange(t){}initUI(){if(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={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[t,e]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(t))if("object"==typeof e){this.ui[t]={};for(let[s,i]of Object.entries(e))this.ui[t][s]=this.ui.panel.querySelector(i)}else this.ui[t]=this.ui.panel.querySelector(e)}else this.canUpdateUI=!1}updateUI(){if(!this.canUpdateUI)return;const t=this.getQueueStats();if(this.ui.count){const e=t.total-t.completed;this.ui.count.textContent=e>0?e:"",this.ui.count.style.display=e>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)}let e=this.getOperationsByStatus("failed"),s=this.getOperationsByStatus("completed");this.ui.clearButton.disabled=0===s.length,this.ui.retryButton.disabled=0===e.length,Object.entries(this.ui.filters).forEach((([e,s])=>{const i="all"===e?t.total:t[e]||0,a=s.querySelector(".count");a&&(a.textContent=i>0?i:""),s.setAttribute("data-count",i)})),this.renderOperations()}getStatusLabel(t){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[t]||t}getItemMessage(t){if(t.message)return t.message;if(t.error_message)return t.error_message;switch(t.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return t.position?`Position ${t.position} in queue`:"In server queue";case"processing":return t.progress?`${t.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${t.lastError||"Unknown error"} (Retry ${t.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${t.lastError||"Unknown error"}`;default:return""}}calculateProgress(t){if(t.progress)return t.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[t.status]||0}getQueueStats(){const t={};return this.statuses.forEach((e=>{t[e]=0})),Array.from(this.store.data.values()).forEach((e=>{t.hasOwnProperty(e.status)&&t[e.status]++})),t.total=Array.from(this.store.data.values()).length,t}renderOperations(){if(!this.ui.itemsContainer)return;const t=this.getActiveFilter(),e=this.getFilteredOperations(t);if(window.removeChildren(this.ui.itemsContainer),0===e.length){let t=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(t),this.a11y.announce("Nothing queued.")}else{let t=this.ui.itemsContainer.querySelector(".emptyQueue");t&&t.remove(),e.forEach((t=>{const e=this.createOperationUI(t);this.ui.itemsContainer.appendChild(e)}))}}createOperationUI(t){const e=window.getTemplate("queueItem");return e.dataset.id=t.id,this.updateOperationUI(t,e),e}updateOperationUI(t,e=null){e||(e=this.ui.itemsContainer?.querySelector(`[data-id="${t.id}"]`)),e||(e=this.createOperationUI(t)),this.statuses.forEach((t=>e.classList.remove(t))),e.classList.add(t.status);let s="";t.updated_at?s=window.formatTimeAgo(new Date(t.updated_at)):t.created_at&&(s=window.formatTimeAgo(new Date(t.created_at)));const i=this.calculateProgress(t),a=e.querySelector(".type"),n=e.querySelector(".status"),r=e.querySelector(".info .details"),o=e.querySelector(".info .time"),l=e.querySelector(".progress .fill");if(a&&(a.textContent=t.title),n){n.querySelector(".icon")?.remove();let e=this.getStatusLabel(t.status);n.title=e,n.prepend(window.getIcon(this.icons[t.status])),n.querySelector("span").textContent=e}r&&(r.textContent=this.getItemMessage(t)),o&&(o.textContent=s),l&&(l.style.width=`${i}%`);const u=e.querySelector(".actions");u&&this.updateActionButtons(t,u)}updateActionButtons(t,e){switch(window.removeChildren(e),t.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",e.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=t.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",e.appendChild(i),e.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",e.appendChild(n)}}removeOperationFromUI(t){const e=this.ui.itemsContainer?.querySelector(`[data-id="${t}"]`);e&&(e.style.opacity="0",e.style.transform="scale(0.9)",setTimeout((()=>e.remove()),300))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let t=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{t--,this.ui.countdown.textContent=t,t<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}updateStatusPanel(t){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(t)&&this.ui.panel?.classList.add(t)}setFilter(t){Object.values(this.ui.filters).forEach((e=>{e&&e.classList.toggle("active",e.dataset.filter===t)})),this.activeFilter=t,this.renderOperations()}getActiveFilter(){const t=this.ui.panel?.querySelector(".filter.active");return t?.dataset.filter||"all"}getFilteredOperations(t){const e=Array.from(this.store.data.values());return"all"===t?e:e.filter((e=>e.status===t))}showPopup(t,e="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=t),this.ui.popup.className=`popup ${e} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}getOperationsByStatus(t,e=!0){return Array.isArray(t)||"string"!=typeof t||(t=[t]),e?Array.from(this.store.data.values()).filter((e=>t.includes(e.status))):Array.from(this.store.data.values()).filter((e=>!t.includes(e.status)))}async hasQueuedOperations(){return(await this.store.query("status","queued")).length>0}subscribe(t){return this.subscribers.add(t),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((s=>s(t,e)))}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",(function(){window.jvbQueue=new t}))})();
\ No newline at end of file
+(()=>{class e{constructor(e={}){this.canUpdateUI=!0,this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...e},this.user=jvbSettings.currentUser,this.headers={"X-WP-Nonce":jvbSettings.nonce,...e.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError,this.store=window.jvbStore.register("queue",{keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,delayFetch:!1}),this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}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.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)}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()}async processOperation(e){try{this.updateOperationStatus(e.id,"uploading"),e=this.getQueue(e.id);const t=`${this.config.apiBase}${e.endpoint}`;let s;console.log(e.data),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="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="pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e))}else e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,"pending"),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){["cancel","dismiss"].includes(t)&&e.forEach((e=>{this.removeOperationFromUI(e)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:e,action:t,user:jvbSettings.currentUser})});if(!i.ok){const e=await i.json().catch((()=>{}));throw new Error(e.message||`${t} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${t} operation failed`);return["cancel","dismiss"].includes(t)?e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),this.clearQueue(e)})):(e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.updateServerOperations(e,t)));if(i.retried)return i;throw s}}}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),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),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)}}handleChange(e){}initUI(){if(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={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[e,t]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(e))if("object"==typeof t){this.ui[e]={};for(let[s,i]of Object.entries(t))this.ui[e][s]=this.ui.panel.querySelector(i)}else this.ui[e]=this.ui.panel.querySelector(t)}else 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))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let e=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{e--,this.ui.countdown.textContent=e,e<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}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)}showPopup(e,t="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=e),this.ui.popup.className=`popup ${t} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}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){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",(function(){window.jvbQueue=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/referral.min.js b/assets/js/min/referral.min.js
index d4800d3..08fc78b 100644
--- a/assets/js/min/referral.min.js
+++ b/assets/js/min/referral.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector(".jvb-referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.initElements(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copy:"button.copy",login:".login",submit:"[type=submit]"},this.forms=this.container.querySelectorAll("form"),console.log(this.forms),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.forms.forEach((e=>{e.addEventListener("submit",this.submitHandler)})),this.container.addEventListener("click",this.clickHandler),this.container.addEventListener("input",this.inputHandler)},onClose:()=>{this.forms.forEach((e=>{e.removeEventListener("submit",this.submitHandler)})),this.container.removeEventListener("click",this.clickHandler),this.container.removeEventListener("input",this.inputHandler)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this),this.changeHandler=this.handleChange.bind(this)}handleClick(e){if(e.target.classList.contains(".copy")){let t=e.target.dataset.target,r=this.container.querySelector(`#${t}`);r=!!r&&r.textContent,r&&this.handleCopy(e.target,r)}}handleChange(e){"referral-code"===e.target.id&&window.debouncer.schedule("check-referral",(()=>this.makeRequest("referrals/check-code",{code:e.target.value})))}handleInput(e){"referral-code"===e.target.id&&(e.target.value=e.target.value.toUpperCase())}initShareWidget(){this.initCopyButton(),this.loadStats()}async checkForReferral(){const e=this.getUrlParameter("seeReferral"),t=this.getUrlParameter("ref");if(!e&&!t)return;if(!t)return void this.popup.openPopup();const r=this.container.querySelector("#referral-code-input");if(!r)return;const n=t.toUpperCase();r.value=n,r.readOnly=!0,this.popup.togglePopup();try{const e=await this.validateCodeOnly(n);if(e.success){this.showReferrerBanner(e.referrer_name,n);const t=this.container.querySelector("#referral-name");t&&t.focus()}else r.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),r.readOnly=!1}this.removeUrlParameter("ref")}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/check-code`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e})});return await t.json()}showReferrerBanner(e,t){const r=this.container.querySelector(".referral-header");if(!r)return;const n=document.createElement("div");n.className="referrer-banner",n.innerHTML=`\n <div class="banner-icon">🎉</div>\n <div class="banner-content">\n <strong>${window.escapeHtml(e)}</strong> referred you!\n <div class="banner-code">Code: <code>${window.escapeHtml(t)}</code></div>\n </div>\n `,r.parentNode.insertBefore(n,r.nextSibling);const a=r.querySelector("h3");a&&(a.textContent="Complete Your Registration");const o=r.querySelector("p");o&&(o.textContent="Enter your details below to claim your welcome reward!")}handleCopy(e,t=""){if(""===t||"string"!=typeof t)return;let r=e.textContent;(navigator.clipboard||navigator.clipboard.writeText)&&navigator.clipboard.writeText(t).then((()=>{e.textContent="Copied!",e.style.background="#00a32a",setTimeout((()=>{e.textContent=r,e.style.background=""}),2e3)}))}async loadStats(){if(this.container.querySelector(".referral-stats"))try{const e=await fetch(`${jvbSettings.api}/referrals/stats`,{headers:{"X-WP-Nonce":jvbSettings.nonce}}),t=await e.json();t.success&&t.stats&&this.updateStats(t.stats)}catch(e){console.error("Error loading 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))}async handleFormSubmit(e){console.log("Form Submission!"),window.debouncer.cancel("check-referral"),e.preventDefault(),console.log("Still working?");const t=e.target,r=new FormData(t);let n={};this.setFormLoading(!0,t);try{let e={success:!1,message:""};console.log(t),console.log(t.id),"referral-code-form"===t.id?(r.get("name")||(e.message+="We need your name to know who you are."),r.get("email")||(e.message+="We need your email to confirm you have access to it."),r.get("referral_code")||(e.message+="We need the referral code to know who sent you."),r.get("name")&&r.get("email")&&r.get("referral_code")&&(n.name=r.get("name"),n.email=r.get("email"),n.code=r.get("referral_code"),e=await this.makeRequest("referrals/register",n))):"login-form"===t.id&&r.get("login-email")&&(n.type="login",n.email=r.get("login-email"),n.context={},n.context.redirect_to=window.location.href+"?seeReferral=1",console.log("Making Request with: ",n),e=await this.makeRequest("magic",n)),e.success?this.handleSuccess(e):(this.showMessage(e.message||"Something went wrong. Please try again.","error"),this.setFormLoading(!1,t))}catch(e){console.error("Error registering:",e),this.showMessage("Something went wrong. Please try again.","error"),this.setFormLoading(!1,t)}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","referrals/register","referrals/check-code"].includes(e))return{success:!1,message:"Something went wrong (Invalid endpoint)."};console.log("Endpoint: ",e),console.log("Data: ",t);const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(t)});return await r.json()}handleSuccess(e){this.container.querySelectorAll("form").forEach((e=>{window.fade(e,!1)}));const t=this.container.querySelector(".success-message");t&&(t.hidden=!1,t.scrollIntoView({behavior:"smooth",block:"center"}),this.dispatchEvent("emailSent",{email:e.email}))}setFormLoading(e,t){t.querySelectorAll("input").forEach((t=>t.disabled=e));let r=t.querySelector(".status"),n=r.querySelector(".message");r.hidden=e,r.classList.toggle("loading",e),n.textContent=e?"Checking with server...":""}showMessage(e,t="success"){const r=this.container.querySelector("#referral-message");r&&(r.textContent=e,r.className="message "+t,r.style.display="block","error"===t&&setTimeout((()=>{r.style.display="none"}),5e3))}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbReferral=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector(".jvb-referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.initElements(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copy:"button.copy",login:".login",submit:"[type=submit]"},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.forms.forEach((e=>{e.addEventListener("submit",this.submitHandler)})),this.container.addEventListener("click",this.clickHandler),this.container.addEventListener("input",this.inputHandler)},onClose:()=>{this.forms.forEach((e=>{e.removeEventListener("submit",this.submitHandler)})),this.container.removeEventListener("click",this.clickHandler),this.container.removeEventListener("input",this.inputHandler)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this),this.changeHandler=this.handleChange.bind(this)}handleClick(e){if(e.target.classList.contains(".copy")){let t=e.target.dataset.target,r=this.container.querySelector(`#${t}`);r=!!r&&r.textContent,r&&this.handleCopy(e.target,r)}}handleChange(e){"referral-code"===e.target.id&&window.debouncer.schedule("check-referral",(()=>this.makeRequest("referrals/check-code",{code:e.target.value})))}handleInput(e){"referral-code"===e.target.id&&(e.target.value=e.target.value.toUpperCase())}initShareWidget(){this.initCopyButton(),this.loadStats()}async checkForReferral(){const e=this.getUrlParameter("seeReferral"),t=this.getUrlParameter("ref");if(!e&&!t)return;if(!t)return void this.popup.openPopup();const r=this.container.querySelector("#referral-code-input");if(!r)return;const n=t.toUpperCase();r.value=n,r.readOnly=!0,this.popup.togglePopup();try{const e=await this.validateCodeOnly(n);if(e.success){this.showReferrerBanner(e.referrer_name,n);const t=this.container.querySelector("#referral-name");t&&t.focus()}else r.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),r.readOnly=!1}this.removeUrlParameter("ref")}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/check-code`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e})});return await t.json()}showReferrerBanner(e,t){const r=this.container.querySelector(".referral-header");if(!r)return;const n=document.createElement("div");n.className="referrer-banner",n.innerHTML=`\n <div class="banner-icon">🎉</div>\n <div class="banner-content">\n <strong>${window.escapeHtml(e)}</strong> referred you!\n <div class="banner-code">Code: <code>${window.escapeHtml(t)}</code></div>\n </div>\n `,r.parentNode.insertBefore(n,r.nextSibling);const a=r.querySelector("h3");a&&(a.textContent="Complete Your Registration");const s=r.querySelector("p");s&&(s.textContent="Enter your details below to claim your welcome reward!")}handleCopy(e,t=""){if(""===t||"string"!=typeof t)return;let r=e.textContent;(navigator.clipboard||navigator.clipboard.writeText)&&navigator.clipboard.writeText(t).then((()=>{e.textContent="Copied!",e.style.background="#00a32a",setTimeout((()=>{e.textContent=r,e.style.background=""}),2e3)}))}async loadStats(){if(this.container.querySelector(".referral-stats"))try{const e=await fetch(`${jvbSettings.api}/referrals/stats`,{headers:{"X-WP-Nonce":jvbSettings.nonce}}),t=await e.json();t.success&&t.stats&&this.updateStats(t.stats)}catch(e){console.error("Error loading 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))}async handleFormSubmit(e){window.debouncer.cancel("check-referral"),e.preventDefault();const t=e.target,r=new FormData(t);let n={};this.setFormLoading(!0,t);try{let e={success:!1,message:""};"referral-code-form"===t.id?(r.get("name")||(e.message+="We need your name to know who you are."),r.get("email")||(e.message+="We need your email to confirm you have access to it."),r.get("referral_code")||(e.message+="We need the referral code to know who sent you."),r.get("name")&&r.get("email")&&r.get("referral_code")&&(n.name=r.get("name"),n.email=r.get("email"),n.code=r.get("referral_code"),e=await this.makeRequest("referrals/register",n))):"login-form"===t.id&&r.get("login-email")&&(n.type="login",n.email=r.get("login-email"),n.context={},n.context.redirect_to=window.location.href+"?seeReferral=1",console.log("Making Request with: ",n),e=await this.makeRequest("magic",n)),e.success?this.handleSuccess(e):(this.showMessage(e.message||"Something went wrong. Please try again.","error"),this.setFormLoading(!1,t))}catch(e){console.error("Error registering:",e),this.showMessage("Something went wrong. Please try again.","error"),this.setFormLoading(!1,t)}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","referrals/register","referrals/check-code"].includes(e))return{success:!1,message:"Something went wrong (Invalid endpoint)."};console.log("Endpoint: ",e),console.log("Data: ",t);const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(t)});return await r.json()}handleSuccess(e){this.container.querySelectorAll("form").forEach((e=>{window.fade(e,!1)}));const t=this.container.querySelector(".success-message");t&&(t.hidden=!1,t.scrollIntoView({behavior:"smooth",block:"center"}),this.dispatchEvent("emailSent",{email:e.email}))}setFormLoading(e,t){t.querySelectorAll("input").forEach((t=>t.disabled=e));let r=t.querySelector(".status"),n=r.querySelector(".message");r.hidden=e,r.classList.toggle("loading",e),n.textContent=e?"Checking with server...":""}showMessage(e,t="success"){const r=this.container.querySelector("#referral-message");r&&(r.textContent=e,r.className="message "+t,r.style.display="block","error"===t&&setTimeout((()=>{r.style.display="none"}),5e3))}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbReferral=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index e7339bb..b94488e 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.hasAutocomplete=!1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.store=new window.jvbStore({name:"taxonomies",storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:72e5,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy"}),this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.activeStore=null,this.disabled=!1,this.searchHandler=null,this.autocompleteHandler=null,this.isAutocompleteActive=!1,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}handleStoreEvent(e,t){switch(e){case"data-loaded":if(this.modal?.open&&this.handleTermsLoaded(t),this.isAutocompleteActive&&this.activeField){const e=this.fields.get(this.activeField),i=t.data?.items||[],s=t.filters?.search||"";this.showAutocompleteResults(e,i,s),this.isAutocompleteActive=!1}break;case"filters-changed":this.modal?.open&&this.showLoading();break;case"fetch-error":this.isAutocompleteActive&&this.activeField&&(this.showAutocompleteError(this.activeField),this.isAutocompleteActive=!1),this.handleFetchError(t.error)}}handleTermsLoaded(e){this.hideLoading();const t=e.data?.items||[],i=e.data?.pagination||{},s=e.filters?.search&&e.filters.search.length>0,o=e.filters?.page>1;0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),this.currentTerms=t,i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldsForTaxonomy(e,t){this.fields.forEach((i=>{i.taxonomy===e&&i.selectedTerms.size>0&&i.selectedTerms.forEach((e=>{const s=t.find((t=>t.id===e));if(s){const t=i.selectedContainer.querySelector(`[data-id="${e}"]`);t&&(t.dataset.path=s.path,t.querySelector("span").textContent=s.path)}}))}))}scanExistingFields(e=null){e||(e=document.body);e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,o=e.querySelector("button.taxonomy-toggle"),n={id:s,input:i,container:e,taxonomy:o.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(o.dataset.max)||0,canSearch:"search"in o.dataset,hasAutocomplete:"autocomplete"in o.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")??!1,canCreate:"creatable"in o.dataset,isRequired:"required"in o.dataset,selectedTerms:new Set,toggle:o,selectedContainer:e.querySelector(".selected-items"),...t};!this.hasAutocomplete&&n.hasAutocomplete&&(this.hasAutocomplete=!0,this.initAutocomplete());const a=i.value.trim();if(""!==a){a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>n.selectedTerms.add(e)))}return this.fields.set(s,n),this.isInitializing?this.taxonomiesToFetch.add(n.taxonomy):this.store.setFilter("taxonomy",n.taxonomy),n.selectedTerms.size>0&&this.initFieldDisplay(s),s}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),console.log(`Batch fetching ${e.length} unique taxonomies:`,e);for(const t of e)await this.store.setFilters({taxonomy:t,page:1,search:"",parent:0});this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)}))}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;const i=Array.from(t.selectedTerms),s=[];i.forEach((e=>{const t=this.store.data.get(e);t&&s.push(t)})),s.forEach((t=>{this.addTermToDisplay(e,t.id,t.name,t.path)}))}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":console.log(t),this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.activeStore&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),this.hasAutocomplete&&this.initAutocomplete()}initAutocomplete(){console.log("Autocomplete init"),this.autocompleteHandler=window.debounce((e=>this.handleAutocomplete(e)),300),document.addEventListener("input",this.autocompleteHandler),document.addEventListener("blur",this.cleanupAutocomplete.bind(this))}handleClick(e){const t=window.targetCheck(e,".taxonomy-toggle");if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t),this.modalInstance.handleOpen()}catch(e){console.error("Error handling toggle click:",e),this.error?.handleError(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e){if(this.activeField=e,this.currentConfig=this.fields.get(e),this.currentSingular=jvbSettings.labels[this.currentConfig.taxonomy].single,this.currentPlural=jvbSettings.labels[this.currentConfig.taxonomy].plural,this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms){let e=[];if(this.currentConfig.selectedTerms.forEach((t=>{const i=this.store.getItem(t);i?this.selectedTerms.set(t,{id:t,name:i.name,path:i.path}):e.push(t)})),e.length>0){this.fetchSpecificTerms(e).forEach((e=>{this.selectedTerms.set(e.id,{id:e.id,name:e.name,path:e.path})}))}}}fetchSpecificTerms(e){return[]}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,hasAutocomplete:!1,autocompleteDropdown:document.querySelector(".autocomplete-dropdown")??!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s),this.modalInstance.handleOpen()}openModal(e){this.activeField=e.fieldId,this.currentConfig=e,e.canCreate&&"jvbTaxCreator"in window?this.creator=new window.jvbTaxCreator(this):this.creator&&delete this.creator,this.selectedTerms=new Set(e.selectedTerms);this.store.filters.taxonomy!==e.taxonomy&&this.store.setFilters({taxonomy:e.taxonomy,page:1,search:"",parent:0}),window.removeChildren(this.ui.termsList),this.ui.search.value="",this.updateSelectionCount(),this.modalInstance.open()}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}this.fields.delete(this.activeField)}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),this.creator&&delete this.creator,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const o=i.selectedContainer.querySelector(`[data-id="${s}"]`);o&&o.remove();const n=Array.from(i.selectedTerms);i.input.value=n.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const o=this.fields.get(e);if(!o||o.selectedContainer.querySelector(`[data-id="${t}"]`))return;const n=window.getTemplate("selectedTerm").cloneNode(!0);n.dataset.id=t,n.dataset.path=s,n.dataset.name=i,n.dataset.taxonomy=o.taxonomy,n.querySelector("span").textContent=s,n.querySelector("button").title=`Remove ${i}`,o.selectedContainer.appendChild(n)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(e){const t=e.target.value.trim();this.searchHandler&&clearTimeout(this.searchHandler),this.searchHandler=setTimeout((()=>{this.store.setFilters({search:t,page:1,parent:t?0:this.store.filters.parent||0}),window.removeChildren(this.ui.termsList)}),300)}async handleAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);if(!i)return;const s=e.target.value.trim();if(i.currentAutocompleteQuery=s,s.length<2)return i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),void(this.isAutocompleteActive=!1);this.activeField=t,this.isAutocompleteActive=!0,i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!1),this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1})}cleanupAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target);this.fields.get(t)&&this.creator&&delete this.creator}showAutocompleteError(e){const t=this.fields.get(e);if(!t)return;t.config.autocompleteDropdown||(t.config.autocompleteDropdown=t.element.querySelector(".autocomplete-dropdown"));const i=t.config.autocompleteDropdown;i&&(window.removeChildren(i),this.showEmptyState("Hmmm... something went wrong",i))}showAutocompleteResults(e,t,i){if(!e||!e.autocompleteDropdown)return;const s=e.autocompleteDropdown;window.removeChildren(s),0===t.length?this.showEmptyState("No items found.",s):t.forEach((t=>{const i=this.createAutocompleteTermElement(e,t);i&&s.appendChild(i)}));const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o&&window.jvbTaxCreator){const e=this.createNewTermOption(o);s.appendChild(e)}s.hidden=!1}createNewTermOption(e){const t=document.createElement("button");return t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e,t.innerHTML=`<strong>Create:</strong> "${e}"`,t}createAutocompleteTermElement(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermToDisplay(e.id,t.id,t.name,t.path),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.store.setFilters({parent:t,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===t}loadMoreTerms(){if(!this.activeStore)return;const e=this.activeStore.filters.page||1;this.store.setFilter("page",e+1)}renderTerms(e,t=!1,i=!1){if(t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s,e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&this.ui.termsList.appendChild(t)}))}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),n=t.querySelector("span, .term-name");if(s&&o&&n&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,n.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store?.filters?.search||"",t=this.store?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults").cloneNode(!0);e&&i.querySelector("span")&&(i.querySelector("span").textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.hasAutocomplete=!1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.triggers=new Set([".taxonomy-toggle"]),this.subscribers=new Set,this.store=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0}),this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.disabled=!1,this.searchHandler=null,this.autocompleteHandler=null,this.isAutocompleteActive=!1,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.hasAutocomplete&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}handleStoreEvent(e,t){switch(e){case"data-loaded":const e=this.store.filters.taxonomy;if(e?.includes(",")&&this.handleBatchDataLoaded(e,t),e){(e.includes(",")?e.split(",").map((e=>e.trim())):[e]).forEach((e=>{this.updateFieldsForTaxonomy(e)}))}if(this.modal?.open&&this.handleTermsLoaded(t),this.isAutocompleteActive&&this.activeField){const e=this.fields.get(this.activeField),i=t.data?.items||[],s=t.filters?.search||"";this.showAutocompleteResults(e,i,s),this.isAutocompleteActive=!1}break;case"filters-changed":this.modal?.open&&this.showLoading();break;case"fetch-error":this.isAutocompleteActive&&this.activeField&&(this.showAutocompleteError(this.activeField),this.isAutocompleteActive=!1),this.handleFetchError(t.error)}}handleTermsLoaded(e){this.hideLoading();const t=this.store.getFiltered(),i=this.store.lastResponse?.page||{},s=e.filters?.search&&e.filters.search.length>0,o=i.page>1;this.notify("terms-loaded",{terms:t,filters:e.filters}),0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldButtonState(e){const t=this.fields.get(e);if(!t)return;const i=Array.from(this.store.data.values()).some((e=>e.taxonomy===t.taxonomy));t.toggle&&(t.toggle.disabled=!i&&!t.canCreate,t.toggle.title=i?`Select ${this.getPlural(t.taxonomy)}`:`No ${this.getSingular(t.taxonomy)} available`)}updateFieldsForTaxonomy(e){this.getFieldsForTaxonomy(e).forEach((e=>{this.updateFieldButtonState(e.id)}))}getFieldsForTaxonomy(e){return Array.from(this.fields.values()).filter((t=>t.taxonomy===e))}scanExistingFields(e=null){e||(e=document.body);e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return!1;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,o=Object.hasOwn(t,"button")?t.button:e.querySelector("button.taxonomy-toggle");Object.hasOwn(t,"buttonSelector")&&this.triggers.add(t.buttonSelector);let r={id:s,input:i,container:e,taxonomy:o.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(o.dataset.max)||0,canSearch:"search"in o.dataset,hasAutocomplete:"autocomplete"in o.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")??!1,canCreate:"creatable"in o.dataset,isRequired:"required"in o.dataset,selectedTerms:new Set,toggle:o,selectedContainer:Object.hasOwn(t,"selected")?t.selected:e.querySelector(".selected-items"),...t};!this.hasAutocomplete&&r.hasAutocomplete&&(this.hasAutocomplete=!0,this.initAutocomplete());const a=i.value.trim();if(""!==a){a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>r.selectedTerms.add(e)))}return Object.hasOwn(t,"selectedItems")&&t.selectedItems.forEach((e=>{r.selectedTerms.add(e)})),this.fields.set(s,r),this.isInitializing&&this.taxonomiesToFetch.add(r.taxonomy),r.selectedTerms.size>0&&this.initFieldDisplay(s),s}registerFilterButton(e,t={}){const i=this.createFieldId(e);e.dataset.fieldId=i,t.buttonSelector&&this.triggers.add(t.buttonSelector);const s={id:i,input:null,container:t.container||e.closest(".filters")||e.parentElement,taxonomy:e.dataset.taxonomy,name:`filter_${e.dataset.taxonomy}`,maxSelection:parseInt(e.dataset.max)||0,canSearch:"search"in e.dataset,hasAutocomplete:!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(t.selectedItems||[]),toggle:e,selectedContainer:t.selected||null,isFilterMode:!0,...t};return this.fields.set(i,s),this.isInitializing?this.taxonomiesToFetch.add(s.taxonomy):this.store.setFilter("taxonomy",s.taxonomy),i}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;Array.from(t.selectedTerms).forEach((t=>{const i=this.store.get(t);i&&this.addTermToDisplay(e,i.id,i.name,i.path)}))}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),this.hasAutocomplete&&this.initAutocomplete()}initAutocomplete(){this.autocompleteHandler=window.debounce((e=>this.handleAutocomplete(e)),300),document.addEventListener("input",this.autocompleteHandler),document.addEventListener("blur",this.cleanupAutocomplete.bind(this)),document.addEventListener("focus",(e=>{if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);i&&this.preloadTaxonomy(i.taxonomy)}),!0)}handleClick(e){const t=window.targetCheck(e,Array.from(this.triggers));if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t,!0)}catch(e){console.error("Error handling toggle click:",e),this.error?.log&&this.error.log(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e,t=!1){this.activeField=e,this.currentConfig=this.fields.get(e),this.currentSingular=this.getSingular(this.currentConfig.taxonomy),this.currentPlural=this.getPlural(this.currentConfig.taxonomy),t&&this.modalInstance.handleOpen(),this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms.forEach((e=>{const t=this.store.get(e);t&&this.selectedTerms.set(e,{id:e,name:t.name,path:t.path})}))}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,hasAutocomplete:!1,autocompleteDropdown:document.querySelector(".autocomplete-dropdown")??!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s,!0),this.modalInstance.handleOpen()}openModal(){this.currentConfig?(!this.creator&&this.currentConfig.canCreate&&"jvbTaxCreator"in window&&(this.creator=new window.jvbTaxCreator(this)),this.updateModalForTaxonomy(),this.updateModalSelections(),this.updateSelectionCount(),window.removeChildren(this.ui.termsList),this.showLoading()):console.error("No active field set")}updateSelectionCount(){if(!this.currentConfig)return;const e=this.selectedTerms.size,t=this.currentConfig.maxSelection,i=this.modal?.querySelector(".selection-count");i&&(i.textContent=t>0?`${e} of ${t} selected`:`${e} selected`)}getSingular(e){return jvbSettings.labels[e]?.single||e}getPlural(e){return jvbSettings.labels[e]?.plural||e}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.notify("selected-terms",{terms:this.selectedTerms,taxonomy:this.currentConfig.taxonomy}),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),!this.hasAutocomplete&&this.creator&&delete this.creator,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const o=i.selectedContainer.querySelector(`[data-id="${s}"]`);o&&o.remove();const r=Array.from(i.selectedTerms);i.input.value=r.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const o=this.fields.get(e);if(!o||o.selectedContainer.querySelector(`[data-id="${t}"]`))return;const r=window.getTemplate("selectedTerm").cloneNode(!0);r.dataset.id=t,r.dataset.path=s,r.dataset.name=i,r.dataset.taxonomy=o.taxonomy,r.querySelector("span").textContent=s,r.querySelector("button").title=`Remove ${i}`,o.selectedContainer.appendChild(r)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(e){const t=e.target.value.trim();this.searchHandler&&clearTimeout(this.searchHandler),this.searchHandler=setTimeout((()=>{this.store.setFilters({search:t,page:1,parent:t?0:this.store.filters.parent||0}),window.removeChildren(this.ui.termsList)}),300)}async handleAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);if(!i)return;const s=e.target.value.trim();if(i.currentAutocompleteQuery=s,s.length<2)return i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),void(this.isAutocompleteActive=!1);this.activeField=t,this.isAutocompleteActive=!0,i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!1),this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1})}cleanupAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target);this.fields.get(t)&&this.creator&&delete this.creator}showAutocompleteError(e){const t=this.fields.get(e);if(!t)return;t.config.autocompleteDropdown||(t.config.autocompleteDropdown=t.element.querySelector(".autocomplete-dropdown"));const i=t.config.autocompleteDropdown;i&&(window.removeChildren(i),this.showEmptyState("Hmmm... something went wrong",i))}showAutocompleteResults(e,t,i){if(!e||!e.autocompleteDropdown)return;const s=e.autocompleteDropdown;window.removeChildren(s),0===t.length?this.showEmptyState("No items found.",s):t.forEach((t=>{const i=this.createAutocompleteTermElement(e,t);i&&s.appendChild(i)}));const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o&&window.jvbTaxCreator){const e=this.createNewTermOption(o);s.appendChild(e)}s.hidden=!1}createNewTermOption(e){const t=document.createElement("button");return t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e,t.innerHTML=`<strong>Create:</strong> "${e}"`,t}createAutocompleteTermElement(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermToDisplay(e.id,t.id,t.name,t.path),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.store.setFilters({parent:t,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===t}loadMoreTerms(){const e=this.store.filters.page||1;this.store.setFilter("page",e+1)}renderTerms(e=null,t=!1,i=!1){if(e||(e=this.store.getFiltered()),t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s;const o=document.createDocumentFragment();e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&o.appendChild(t)})),this.ui.termsList.appendChild(o)}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),r=t.querySelector("span, .term-name");if(s&&o&&r&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,r.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store?.filters?.search||"",t=this.store?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults").cloneNode(!0);e&&i.querySelector("span")&&(i.querySelector("span").textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}handleBatchDataLoaded(e,t){const i=e.split(",").map((e=>e.trim())),s=this.store.getStore();i.forEach((e=>{const t={taxonomy:e,page:1,search:"",parent:0},i=this.generateCacheKeyForFilters(t),o={key:i,items:Array.from(this.store.data.values()).filter((t=>t.taxonomy===e)).map((e=>e.id)),timestamp:Date.now(),endpoint:s.config.endpoint,filters:t};if(s.cache.set(i,o),s.db?.objectStoreNames.contains("cache")){s.db.transaction(["cache"],"readwrite").objectStore("cache").put(o)}this.updateFieldsForTaxonomy(e)})),this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)}))}generateCacheKeyForFilters(e){const t=Object.keys(e).sort().reduce(((t,i)=>(t[i]=e[i],t)),{});return JSON.stringify(t)}async preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}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(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/swiper.min.js b/assets/js/min/swiper.min.js
new file mode 100644
index 0000000..10b3a47
--- /dev/null
+++ b/assets/js/min/swiper.min.js
@@ -0,0 +1 @@
+window.jvbSwiper=new class{constructor(){this.isInitialized=!1,this.initSubscribers(),this.initHandlers(),this.swipe={startX:null,endX:null,startY:null,endY:null,minSwipe:50},this.pinch={active:!1,startDistance:0,lastDistance:0,scale:1}}initHandlers(){this.touchStartHandler=this.handleTouchStart.bind(this),this.touchMoveHandler=this.handleTouchMove.bind(this),this.touchEndHandler=this.handleTouchEnd.bind(this)}initListeners(){this.isInitialized||(this.isInitialized=!0,document.addEventListener("touchstart",this.touchStartHandler),document.addEventListener("touchmove",this.touchMoveHandler),document.addEventListener("touchend",this.touchEndHandler))}cleanupListeners(){this.subscribers.size>0||(this.isInitialized=!1,document.removeEventListener("touchstart",this.touchStartHandler),document.removeEventListener("touchmove",this.touchMoveHandler),document.removeEventListener("touchend",this.touchEndHandler))}handleTouchStart(t){if(2===t.touches.length){const i=t.touches[0].clientX-t.touches[1].clientX,s=t.touches[0].clientY-t.touches[1].clientY,e=Math.sqrt(i*i+s*s);return this.pinch.active=!0,this.pinch.startDistance=this.pinch.lastDistance=e,void this.notify("pinch-start",{distance:e})}this.swipe.startX=t.touches[0].clientX,this.swipe.startY=t.touches[0].clientY}handleTouchMove(t){if(this.pinch.active&&2===t.touches.length){const i=t.touches[0].clientX-t.touches[1].clientX,s=t.touches[0].clientY-t.touches[1].clientY,e=Math.sqrt(i*i+s*s),n=e/this.pinch.startDistance;return this.pinch.lastDistance=e,this.pinch.scale=n,this.notify("pinch-move",{e:t,distance:e,scale:n}),void(e>this.pinch.startDistance?this.notify("pinch-out",{scale:n}):this.notify("pinch-in",{scale:n}))}this.swipe.endX=t.touches[0].clientX,this.swipe.endY=t.touches[0].clientY}handleTouchEnd(t){if(this.pinch.active)return this.notify("pinch-end",{finalScale:this.pinch.scale}),void(this.pinch.active=!1);if(!(this.swipe.startX&&this.swipe.endX&&this.swipe.startY&&this.swipe.endY))return;const i=this.swipe.startX-this.swipe.endX,s=this.swipe.startY-this.swipe.endY;if(Math.abs(i)>this.swipe.minSwipe){let t=i>0?"swipe-right":"swipe-left";this.notify(t)}if(Math.abs(s)>this.swipe.minSwipe){let t=s>0?"swipe-up":"swipe-down";this.notify(t)}this.swipe.startX=this.swipe.startY=this.swipe.endX=this.swipe.endY=null}initSubscribers(){this.subscribers=new Set}subscribe(t){return this.isInitialized||this.initListeners(),this.subscribers.add(t),()=>this.subscribers.delete(t)}unsubscribe(t){this.subscribers.delete(t),0===this.subscribers.size&&this.cleanupListeners()}notify(t,i={}){this.subscribers.forEach((s=>{try{s(t,i)}catch(t){console.error("Subscriber error:",t)}}))}destroy(){this.subscribers.clear(),this.cleanupListeners()}};
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index 8d83df7..51414d3 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStore=new window.jvbStore({name:"upload_fields",storeName:"fieldStates",keyPath:"id",version:2,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],stripDOMReferences:!0,TTL:6048e5}),this.uploadStore=new window.jvbStore({name:"uploads",storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}]}),window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.subscribers=new Set,this.dragController=null,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',hiddenValue:'input[type="hidden"]',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.sortableInstances=new Map,this.sortableConfig={animation:150,draggable:".item",handle:".select-item-label, img",ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>{this.handleReorder(e)}},this.init()}async init(){await this.loadFields(),await this.loadUploads(),this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if("uploads"!==t.endpoint&&"uploads/meta"!==t.endpoint)return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data.fieldId;switch(e){case"cancel-operation":s&&this.clearField(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":(t.result?.data||[]).forEach((e=>{const t=this.uploads.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploads.set(t.id,t))})),s&&this.cleanField(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={id:t,config:s,element:e,ui:this.buildFieldUI(e),uploads:new Set,groups:new Set,state:"ready"};return this.fields.set(t,o),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"post_group"!==s.destination||this.dragController||this.initGroupFeatures(),"single"!==s.type&&this.initSortable(o),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}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)}initGroupFeatures(){this.dragController=new window.jvbDragHandler({draggableSelector:this.selectors.items.item,dropTargetSelector:`${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,ignoreSelector:"input:not(.upload-select), button, select, textarea, details, summary, a",previewElement:"img, video, .icon",getItemId:e=>e.dataset.uploadId,getSelectedItems:e=>{const t=this.getFieldIdFromElement(e),s=e.dataset.uploadId,o=this.getCurrentSelection(t);return o&&o.includes(s)?o:[s]},validateDrop:(e,t)=>{const s=this.getFieldIdFromElement(t),o=document.querySelector(`[data-upload-id="${e[0]}"]`);return s===this.getFieldIdFromElement(o)},onDrop:(e,t)=>{this.handleItemDrop(e,t),t.scrollIntoView({behavior:"smooth",block:"center"})},onDragStart:e=>{},onDragEnd:(e,t)=>{if(t){const t=document.querySelector(`[data-upload-id="${e[0]}"]`),s=this.getFieldIdFromElement(t),o=this.selectionHandlers.get(s);o?.clearSelection()}},previewOptions:{multiOffset:{x:-60,y:-80},singleOffset:{x:-50,y:-60},showCount:!0}})}initSortable(e){if(!window.Sortable)return;const t=e.element.querySelector(".item-grid:not(.group)");t&&this.sortableInstances.set(`${e.id}-main`,new Sortable(t,{...this.sortableConfig,group:{name:e.id,pull:!0,put:!0}}));e.element.querySelectorAll(".item-grid.group").forEach(((t,s)=>{this.sortableInstances.set(`${e.id}-group-${s}`,new Sortable(t,{...this.sortableConfig,group:{name:e.id,pull:!0,put:!0}}))}))}handleReorder(e){const t=e.to.closest(".field, .upload");if(!t)return;const s=t.closest("form");if(!s)return;const o=s.dataset.formId;if(o&&window.jvbForms){const e=window.jvbForms.forms?.get(o);e?.options.autosave&&window.jvbForms.scheduleSave(e,1e3)}window.jvbA11y&&window.jvbA11y.announce("Item reordered"),t.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex},bubbles:!0}))}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`)):console.error("No field ID found for drop zone")}handleItemDrop(e,t){const s=t.classList.contains("preview");let o=t;if(t.classList.contains("empty-group")){const e=this.getFieldIdFromElement(t),s=this.createGroup(e);if(!s)return void console.error("Failed to create group");o=s.grid}e.forEach((e=>{s?this.removeFromGroup(e):this.addToGroup(e,o)}));const r=this.getFieldIdFromElement(t);this.schedulePersistance(r);const i=e.length>1?`Moved ${e.length} items`:"Moved item";this.a11y.announce(i)}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 t=this.getFieldIdFromElement(e.target),s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}t&&("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e))}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}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){switch(e){case"local_processing":return 28;case"queued":return 50;case"uploading":return 66;case"pending":return 75;case"processing":return 89;case"completed":return 100;default:return 0}}getModalType(e){if(void 0!==e._cachedModalType)return e._cachedModalType;if(!e||!e.element)return e._cachedModalType=null,null;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}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":this.fields.get(s).element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s);break;case"restore":this.handleRestoreUploads().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.fields.get(e);if(!t)return;const s=t.ui.field;if(!s)return;const o=new window.jvbHandleSelection({container:s,ui:{selectAll:s.querySelector('[name="select-all-uploads"]'),bulkControls:s.querySelector(".selection-actions"),count:s.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return o.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(e,o),o}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const o=this.groups.get(t);if(!o)return;const r=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 r.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,r),r}handleSelectAll(e,t){}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",store:this.fields},upload:{selector:this.selectors.items.item,key:"uploadId",store:this.uploads},group:{selector:this.selectors.groups.container,key:"groupId",store:this.groups}}[t];if(!s)return null;const o=e.closest(s.selector);if(!o)return null;const r=o.dataset[s.key];return s.store.get(r)}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){return this.getFromElement(e,"field")?.id??null}getUploadIdFromElement(e){return this.getFromElement(e,"upload")?.id??null}getGroupIdFromElement(e){return this.getFromElement(e,"group")?.id??null}async processFiles(e,t){const s=this.fields.get(e);if(!s)return;s.ui.dropZone&&(s.ui.dropZone.hidden=!0),s.ui.groups.display&&(s.ui.groups.display.hidden=!1);const o=t.length;let r=0;this.updateUploadProgress(e,0,o,"Processing files..."),s.uploads||(s.uploads=new Set);const i=Array.from(t).map((async(t,i)=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,a={id:i,attachment_id:null,fieldId:e,originalFile:t,processedFile:null,preview:null,status:"local_processing",element:null,location:null,meta:{originalName:t.name,size:t.size,type:t.type}};a.preview=this.createPreviewUrl(t),t.type.startsWith("image/")?a.processedFile=await this.processImage(t,s.subtype):a.processedFile=t,await this.uploadStore.saveBlob(i,a.processedFile||t);const l=this.getSubtypeFromMime(t.type);return a.element=this.createUploadElement({...a,subtype:l},"post_group"===s.config.destination),this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),s.ui.preview&&(s.ui.preview.appendChild(a.element),a.location=s.ui.preview),this.uploads.set(i,a),s.uploads.add(i),r++,this.updateUploadProgress(e,r,o,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),a.status="processed",setTimeout((()=>{this.showUploadProgress(i,!1)}),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),r++,this.updateUploadProgress(e,r,o,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),await this.schedulePersistance(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}updateFieldState(e){const t=this.fields.get(e);if(!t||!t.ui.field)return;const s=t.ui.field,o=t.uploads?.size||0,r=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;s.dataset.hasUploads=o>0?"true":"false",s.dataset.uploadCount=o.toString(),s.dataset.hasGroups=r?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${o} item${1!==o?"s":""}`)}updateUploadProgress(e,t,s,o){const r=this.fields.get(e);if(!r?.ui?.progress?.progress)return;const i=r.ui.progress,a=s>0?t/s*100:0;i.fill&&(i.fill.style.width=`${a}%`),i.text&&(i.text.textContent=o),i.count&&(i.count.textContent=`${t}/${s}`),i.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.fields.get(e);s&&(s.state=t)}updateUploadStatus(e,t){const s=this.uploads.get(e);s&&(s.status=t,this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploads.get(e);if(!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${t.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(t.status),t.status)}showUploadProgress(e,t=!0){const s=this.uploads.get(e);if(!s||!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.uploads.get(e);if(!o||!o.element)return;const r=o.element.querySelector(".progress");if(!r)return;const i=r.querySelector(".fill"),a=r.querySelector(".details"),l=r.querySelector(".icon");i&&(i.style.width=`${t}%`),s&&a&&(a.textContent=this.getStatusText(s)),s&&l&&(l.innerHTML=this.getStatusIcon(s).outerHTML)}checkFieldLimits(e,t){const s=this.fields.get(e);if(!s)return!1;return(s.uploads?.size||0)+t<=s.maxFiles}validateFile(e,t){return this.settings.allowedTypes.includes(e.type)?!(e.size>this.settings.maxFileSize)||(this.notify(`File too large: ${this.formatBytes(e.size)}`,"error"),!1):(this.notify(`Invalid file type: ${e.type}`,"error"),!1)}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]}shouldProcessClientSide(e,t){return!("image"!==t||!e.type.startsWith("image/"))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((o,r)=>{let i,a=!1;i=setTimeout((()=>{a||(a=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),r(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:i}),this.handleProcess(e,t).then((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),o(e))})).catch((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),r(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,r)=>{const i=new Image,a=document.createElement("canvas"),l=a.getContext("2d");let n=null;const d=()=>{i.onload=null,i.onerror=null,n&&(URL.revokeObjectURL(n),n=null),a.width=1,a.height=1,l.clearRect(0,0,1,1)};i.onload=()=>{try{const{width:n,height:c}=this.calculateOptimalDimensions(i,t);a.width=n,a.height=c,l.imageSmoothingEnabled=!0,l.imageSmoothingQuality="high",l.drawImage(i,0,0,n,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);a.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});o(s)}else r(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),r(new Error(`Canvas processing failed: ${e.message}`))}},i.onerror=()=>{d(),r(new Error(`Failed to load image: ${e.name}`))};try{n=this.createPreviewUrl(e),i.src=n}catch(e){d(),r(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(((r,i)=>{if(!this.worker.worker)return void i(new Error("Worker not available"));const a=`${t}_${Date.now()}`,l=t=>{if(t.data.messageId===a)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()});r(s)}else i(new Error(t.data.error||"Worker processing failed"))},n=e=>{this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),i(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",l),this.worker.worker.addEventListener("error",n),this.worker.worker.postMessage({messageId:a,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 self.onmessage = async function(e) {\n const { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\n try {\n // Create ImageBitmap from file\n const bitmap = await createImageBitmap(file);\n\n // Calculate dimensions\n const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n const width = Math.round(bitmap.width * scale);\n const height = Math.round(bitmap.height * scale);\n\n // Create OffscreenCanvas\n const canvas = new OffscreenCanvas(width, height);\n const ctx = canvas.getContext('2d');\n\n // Draw and resize\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n ctx.drawImage(bitmap, 0, 0, width, height);\n\n // Clean up bitmap\n bitmap.close();\n\n // Convert to blob\n const blob = await canvas.convertToBlob({\n type: outputFormat,\n quality: quality\n });\n\n self.postMessage({\n messageId,\n success: true,\n blob: blob,\n format: outputFormat\n });\n\n } catch (error) {\n self.postMessage({\n messageId,\n success: false,\n error: error.message\n });\n }\n };\n "],{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 r=Math.min(t/s,t/o);return{width:Math.round(s*r),height:Math.round(o*r)}}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))}maybeLockUploads(e){const t=this.fields.get(e);if(!t?.ui?.dropZone)return;if("post_group"===t.config.destination)return;const s=t.uploads?.size||0,o=t.config?.maxFiles||999;t.ui.dropZone.hidden=s>=o,t.element.classList.toggle("at-max-uploads",s>=o)}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return void console.error("Image template not found");s.dataset.uploadId=e.id,e.originalFile&&(s.dataset.subtype=this.getSubtypeFromMime(e.originalFile.type)),s.querySelector('[name="featured"]').value=e.id;let[o,r,i,a,l]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch([o.value,r.src,r.alt]=[e.id,e.preview,e.originalFile?.name??e.meta?.originalName??""],s.dataset.subtype){case"image":[r.src,r.alt]=[e.preview,e.originalFile?.name??e.meta?.originalName??""],i.remove(),a.remove();break;case"video":i.src=e.preview,r.remove(),a.remove();break;case"document":const t=e.originalFile?.name??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");a.innerText=e.originalFile.name,a.prepend(l),r.remove(),i.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,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),s}async submitUploads(e){const t=this.fields.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 r=[],i=new FormData;let a=[];s=s.map((e=>this.uploads.get(e))),o.forEach(((e,t)=>{const o={images:[],fields:{}};for(let[t,s]of Object.entries(e.changes))o.fields[t]=s;s.filter((t=>t.groupId===e.id)).forEach((e=>{if(e){const t=e.processedFile||e.originalFile;if(t){i.append("files[]",t);const s={upload_id:e.id,index:a.length};o.images.push(s),a.push(e.id)}}})),r.push(o)})),s.filter((e=>!Object.hasOwn(e,"groupId"))).forEach((e=>{if(e){const t={images:[],fields:{}},s=e.processedFile||e.originalFile;if(s){i.append("files[]",s);const o={upload_id:e.id,index:a.length};t.images.push(o),a.push(e.id)}r.push(t)}})),i.append("content",t.config.content),i.append("user",t.config.itemID),i.append("posts",JSON.stringify(r)),i.append("upload_ids",JSON.stringify(a));for(const[e,t]of i.entries())console.log(e,t);const l={endpoint:"uploads/groups",method:"POST",data:i,title:`Creating ${r.length} ${t.config.content}${r.length>1?"s":""} from uploads...`,popup:`Creating ${r.length} post${r.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(l);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,this.a11y.announce(`Creating ${r.length} post${r.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}finally{this.schedulePersistance(t.id)}}async queueUpload(e){const t=this.fields.get(e);if(!t?.uploads)return;const s=Array.from(t.uploads);if(0===s.length)return;const o=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");let r=1===s.length?"file":"files";const i={endpoint:"uploads",method:"POST",data:o,title:`Uploading ${s.length} ${r} to server...`,popup:`Uploading ${s.length} ${r}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(i);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,e}catch(e){throw e}finally{this.schedulePersistance(t.id)}}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 r=this.getFieldGroups(e.id);if("post_group"===e.config.destination&&r.length>0){let e=[],t=[],i=[];r.forEach((r=>{let a=[],l=null;r.uploads.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;if(e){s.append("files[]",e);o.length;o.push(t.id),a.push(t.id);const r=t.element?.querySelector('[name="featured"]');r?.checked&&(l=t.id)}}})),e.push(a),t.push(r.title||""),i.push(l)})),s.append("groups",JSON.stringify(e)),s.append("group_titles",JSON.stringify(t)),s.append("featured_images",JSON.stringify(i))}else t.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;e&&(s.append("files[]",e),o.push(t.id))}}));return s.append("upload_ids",JSON.stringify(o)),s}getFieldGroups(e){const t=[];return this.groups.forEach(((s,o)=>{if(s.fieldId===e){const r=this.fields.get(e),i=r?.ui?.groups?.groups?.get(o);t.push({id:o,uploads:Array.from(s.uploads||new Set),changes:s.changes||{},element:i||null})}})),t}async queueUploadMeta(e){const t=this.getUploadFromElement(e.target);if(!t)return;if(!this.fields.get(t.fieldId))return;if(!e.target.closest(".upload-meta"))return;let s={};s[e.target.name]=e.target.value,t.meta={...t.meta,...s};let o={};o[t.attachmentId??t.id]=t.meta;const r={endpoint:"uploads/meta",method:"POST",data:o,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(r)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:t.id})}}createGroup(e,t=null){const s=this.fields.get(e);if(!s)return console.error("Field not found:",e),null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const o=this.createGroupElement(t,e);if(!o)return console.error("Failed to create group element"),null;s.ui.groups||(s.ui.groups={groups:new Map,container:null,empty:null,display:null}),s.ui.groups.groups.set(t,o),s.ui.groups.container&&s.ui.groups.empty?s.ui.groups.container.insertBefore(o,s.ui.groups.empty):s.ui.groups.container&&s.ui.groups.container.appendChild(o);const r={id:t,fieldId:e,element:o,grid:o.querySelector(".item-grid.group"),uploads:new Set,changes:{}};return this.groups.set(t,r),this.addGroupSelectionHandler(e,t),this.schedulePersistance(e),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 r=s.querySelector(".fields");if(r&&o){r.append(o);const i=r.querySelector('[name="post_title"]'),a=r.querySelector('[name="post_excerpt"]');i&&(i.id=`${e}_title`,i.name=`${e}[post_title]`),a&&(a.id=`${e}_excerpt`,a.name=`${e}[post_excerpt]`);let l=this.fields.get(t);if(""!==l.config.content){s.querySelector("summary").textContent=l.config.content+" Fields"}}else s.querySelector("details").remove();const i=s.querySelector(".item-grid.group");return i&&(i.dataset.groupId=e),s}deleteGroup(e,t=!0){let s=this.groups.get(e);if(!s)return;let o=!0;t&&s.uploads&&s.uploads.size>0&&(o=!window.confirm("Delete uploads in group?")),t&&o&&s.uploads&&s.uploads.size>0&&Array.from(s.uploads).forEach((e=>{this.addImageToGroup(e,null,!1)})),this.groups.delete(e);let r=s.element;r&&(r.remove(),this.a11y.announce("Group removed")),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){let o=this.uploads.get(e);if(!o)return;let r=this.fields.get(o.fieldId);if(!r)return;if(!t&&o.location===r.ui.preview||t===o.location)return;if(o.location){let t=o.location.dataset.groupId;if(t){let s=this.groups.get(t);s&&s.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t))}}const i=o.element.querySelector('[name*="select-item"]');i&&(i.checked=!1);let a=o.element.querySelector('[name="featured"]');if(a.hidden=!t,t){if(!t.classList.contains("item-grid")||!t.classList.contains("preview")){let s=t.dataset.groupId;a.name=s+"_"+a.name;let r=this.groups.get(s);r||(r=this.createGroup(o.fieldId),t=r.grid,s=r.id),r&&(r.uploads.add(e),o.groupId=s)}}else t=r.ui.preview,o.groupId=null;o.location=t,t.append(o.element),s&&this.schedulePersistance(r.id)}removeFromGroup(e){const t=this.uploads.get(e);if(!t)return;const s=this.fields.get(t.fieldId);if(!s)return;if(t.groupId){const s=this.groups.get(t.groupId);s?.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t.groupId,!1)),t.groupId=null}s.ui?.preview&&(s.ui.preview.appendChild(t.element),t.location=s.ui.preview);const o=t.element.querySelector('[name="featured"]');o&&(o.hidden=!0,o.checked=!1)}removeUpload(e,t){const s=this.fields.get(e),o=this.uploads.get(t);if(!s||!o)return;if(s.uploads?.delete(t),o.groupId){const e=this.groups.get(o.groupId);e&&e.uploads&&(e.uploads.delete(t),0===e.uploads.size&&this.removeGroup(o.groupId))}o.element?.remove(),this.clearUpload(t),this.updateFieldState(e),this.maybeLockUploads(e);const r=this.selectionHandlers.get(s.id);r&&r.deselect(t),this.a11y.announce("Upload removed")}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),1e3)}async persistFieldState(e){const t=this.fields.get(e);if(!t)return;const s={...t,id:e,fieldId:e,uploads:Array.from(t.uploads||[]).map((e=>this.uploads.get(e))),groups:Array.from(this.groups.entries()).filter((([t,s])=>s.fieldId===e&&s.uploads&&s.uploads.size>0)).map((([e,t])=>({id:t.id,uploads:Array.from(t.uploads),changes:t.changes||{}}))),context:{url:this.normalizeUrl(window.location.href),fullUrl:window.location.href,modalType:this.getModalType(t),formId:t.formId,fieldSelector:`.field.upload[data-field="${t.config.name}"]`},timestamp:Date.now()};await this.fieldStore.save(s)}normalizeUrl(e){try{const t=new URL(e);return t.origin+t.pathname}catch(t){return e}}getFieldUploads(e,t=!1){const s=this.fields.get(e);return s&&s.uploads?Array.from(s.uploads).map((e=>{const s=this.uploads.get(e);return s?t?{id:s.id,fieldId:s.fieldId,status:s.status,attachmentId:s.attachmentId,operationId:s.operationId,groupId:s.groupId||null,changes:s.changes||{},meta:{originalName:s.meta?.originalName||s.originalFile?.name,size:s.meta?.size||s.originalFile?.size,type:s.meta?.type||s.originalFile?.type,title:s.meta?.title,alt:s.meta?.alt,caption:s.meta?.caption}}:s:null})).filter(Boolean):[]}async checkForStoredUploads(){if(!this.db)return;const e=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates"),t=(await new Promise((t=>{const s=e.getAll();s.onsuccess=()=>t(s.result)}))).filter((e=>e.uploads.some((e=>!e.operationId&&("completed"===e.status||"processed"===e.status||"local_processing"===e.status||"processed-original"===e.status)))));0!==t.length&&this.showRecoveryNotification(t)}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())}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}handleGroupMetaChange(e){let t=this.getGroupFromElement(e);if(!t)return;Object.hasOwn(t,"changes")||(t.changes={});let s=e.name;if(s.includes("group")){let e=t.id+"_",o=t.id+"[";s=s.replace(e,"").replace(o,"").replace("]","")}t.changes[`${s}`]=e.value,this.groups.set(t.id,t),this.schedulePersistance(t.fieldId)}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,r=window.getTemplate("restoreNotification");if(!r)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 i=r.querySelector(".restore-details");i&&(i.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(const e of t.uploads){let s=window.getTemplate("uploadItem");if(!s)continue;const r=await this.uploadStore.getBlob(e.id);if(r)try{const o=new Blob([r.data],{type:r.type}),i=this.createPreviewUrl(o);let[a,l,n,d,c]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];s.dataset.uploadId=e.id,s.dataset.fieldId=t.id;let u=this.getSubtypeFromMime(r.type);switch(s.dataset.subtype=u,u){case"image":[l.src,l.alt]=[i,e.originalFile?.name??e.meta?.originalName??""],n.remove(),d.remove();break;case"video":n.src=i,l.remove(),d.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")}d.innerText=e.originalFile.name,d.prepend(t),l.remove(),n.remove()}s.dataset.previewUrl=i}catch(t){console.warn("Failed to create preview for upload:",e.id,t)}const i=s.querySelector("summary span");i&&(i.textContent=e.meta?.originalName||"Unknown file");const a=s.querySelector("details");a&&e.meta&&(a.textContent=`${this.formatBytes(e.meta.size)} • ${e.meta.type}`),s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),o&&o.appendChild(s)}r.querySelector(".wrap").appendChild(o)}document.querySelector(".field.upload").appendChild(r),r=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection({container:r,ui:{selectAll:r.querySelector("#select-all-restore"),count:r.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async restoreSelectedUploads(e){const t=new Map;if(e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)})),!this.db)return;const s=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates");for(const[e,o]of t.entries()){const t=s.get(e),r=await new Promise((e=>{t.onsuccess=()=>e(t.result),t.onerror=()=>e(null)}));r&&(r.uploads=r.uploads.filter((e=>o.includes(e.id))),await this.restoreField(r))}}async restoreField(e){const{config:t,context:s,uploads:o,groups:r,id:i}=e;s.modalType&&await this.openModalForRestore(s);let a=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!a){const e=`${t.content}_${t.itemID}_${t.name}`;a=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!a)return void console.warn(`Field ${t.name} not found for restoration`,t);let l=a.dataset.uploader;l&&this.fields.has(l)||(l=this.registerUploader(a,t));const n=this.fields.get(l);if(n){n.state=e.state||"ready",n.ui=this.buildFieldUI(a),n.ui.groups?.display&&(n.ui.groups.display.hidden=!1),r&&r.length>0&&await this.restoreGroups(l,r);for(const e of o)await this.restoreUpload(n,e);this.updateFieldState(l),this.maybeLockUploads(l),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(l)}else console.error("Failed to register field for restoration")}async restoreUpload(e,t){const s=await this.uploadStore.getBlob(t.id);if(!s)return void console.warn("Blob data not found for upload:",t.id);{const e=s.data instanceof File?s.data:new File([s.data],s.name,{type:s.type,lastModified:s.lastModified});t.originalFile=e,t.processedFile=e,t.preview=this.createPreviewUrl(e)}e.uploads||(e.uploads=new Set),e.uploads.add(t.id);const o=this.getSubtypeFromMime(t.originalFile.type);let r;if(t.element=this.createUploadElement({...t,subtype:o},"post_group"===e.config.destination),r=t.groupId&&e.ui.groups.groups.has(t.groupId)?e.ui.groups.groups.get(t.groupId).querySelector(".item-grid"):e.ui.preview,r&&(r.appendChild(t.element),t.location=r),this.uploads.set(t.id,t),t.groupId){const e=this.groups.get(t.groupId);e&&e.uploads&&e.uploads.add(t.id)}}async restoreGroups(e,t){for(const s of t){const t=this.createGroup(e,s.id);if(t&&(s.meta&&(t.meta={...s.meta}),s.changes&&(t.changes={...s.changes}),s.title)){const e=t.element.querySelector('[name*="post_title"]');e&&(e.value=s.title)}}}async openModalForRestore(e){const{modalType:t,formId:s}=e;let o=null;switch(t){case"create":o=document.querySelector('[data-action="create"]');break;case"edit":o=document.querySelector(`[data-action="edit"][data-id="${e.itemId}"]`);break;case"bulkEdit":o=document.querySelector('[data-action="bulk-edit"]')}o&&(o.click(),await new Promise((e=>setTimeout(e,300))))}handleFieldStoreEvent(e,t){switch(e){case"data-loaded":break;case"item-saved":console.log(`Field state saved: ${t.key}`)}}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.checkForStoredUploads();break;case"item-saved":this.showSaveIndicator(t.key)}}async saveUpload(e){const t=e.processedFile||e.originalFile||e.file;if(t instanceof File||t instanceof Blob){await this.uploadStore.saveBlob(e.id,t);const{file:s,originalFile:o,processedFile:r,...i}=e;await this.uploadStore.save(i)}else await this.uploadStore.save(e)}async loadFields(){(await this.fieldStore.getAll()).forEach((e=>{e.uploads&&Array.isArray(e.uploads)&&(e.uploads=new Set(e.uploads.map((e=>e.id)))),this.fields.set(e.fieldId,e)}))}async loadUploads(){(await this.uploadStore.getAll()).forEach((e=>{this.uploads.set(e.id,e)}))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}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.fields.clear(),this.uploads.clear(),this.groups.clear(),this.selected.clear(),this.subscribers.clear()}destroySortable(e){Array.from(this.sortableInstances.keys()).filter((t=>t.startsWith(e))).forEach((e=>{const t=this.sortableInstances.get(e);t?.destroy&&t.destroy(),this.sortableInstances.delete(e)}))}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){this.fieldStore.clear(),this.uploadStore.clear()}async clearField(e){await this.fieldStore.delete(e);const t=this.fields.get(e);if(t?.uploads)for(const e of t.uploads)await this.uploadStore.delete(e);this.fields.delete(e)}async clearUpload(e,t=!0){const s=this.uploads.get(e);if(s){if(this.revokePreviewUrl(s.preview),s.element){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}t&&await this.schedulePersistance(s.fieldId),this.uploads.delete(e),this.uploadStore.delete(e),this.uploadStore.delete(e,"blobs")}}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbUploads=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStore=window.jvbStore.register("upload_fields",{storeName:"fieldStates",keyPath:"id",version:2,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}),this.uploadStore=window.jvbStore.register("uploads",{name:"uploads",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}),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.dragController=null,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.sortableConfig={animation:150,draggable:".item",handle:".select-item, img",ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>this.handleReorder(e)},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),r={id:t,config:s,uploads:new Set,groups:[],state:"ready",timestamp:Date.now()};return this.fieldStore.save(r),this.fieldElements.set(t,{element:e,ui:o,config:s}),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"post_group"!==s.destination||this.dragController||this.initGroupFeatures(),"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;const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((t=>{if(t.sortableInstance)return;const s=t.classList.contains("group")?`${e}-group-${t.closest(".upload-group")?.dataset.groupId}`:`${e}-preview`,o=new Sortable(t,{...this.sortableConfig,group:{name:e,pull:!0,put:!0},onAdd:e=>{this.updateSortableState(e.to)},onRemove:e=>{this.updateSortableState(e.from)}});t.sortableInstance=o,this.sortableInstances.set(s,o)}))}updateSortableState(e){const t=e?.sortableInstance;if(!t)return;const s=e.querySelectorAll(".item").length>0;t.option("disabled",!s)}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.closest(".field, .upload");if(!t)return;let s=t.querySelector('input[type="hidden"]'),o=Array.from(t.querySelectorAll(".item")).map((e=>e.dataset.id));console.log(o),s.value=o.join(","),window.jvbA11y&&window.jvbA11y.announce("Item reordered"),t.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex},bubbles:!0}))}initGroupFeatures(){this.dragController=new window.jvbDragHandler({draggableSelector:this.selectors.items.item,dropTargetSelector:`${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,ignoreSelector:"input:not(.upload-select), button, select, textarea, details, summary, a",previewElement:"img, video, .icon",getItemId:e=>e.dataset.uploadId,getSelectedItems:e=>{const t=this.getFieldIdFromElement(e),s=e.dataset.uploadId,o=this.getCurrentSelection(t);return o&&o.includes(s)?o:[s]},validateDrop:(e,t)=>{const s=this.getFieldIdFromElement(t),o=document.querySelector(`[data-upload-id="${e[0]}"]`);return s===this.getFieldIdFromElement(o)},onDrop:(e,t)=>{this.handleItemDrop(e,t),t.scrollIntoView({behavior:"smooth",block:"center"})},onDragEnd:(e,t)=>{if(t){const t=document.querySelector(`[data-upload-id="${e[0]}"]`),s=this.getFieldIdFromElement(t),o=this.selectionHandlers.get(s);o?.clearSelection()}},previewOptions:{multiOffset:{x:-60,y:-80},singleOffset:{x:-50,y:-60},showCount:!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`))}handleItemDrop(e,t){const s=t.classList.contains("preview");let o=t;if(t.classList.contains("empty-group")){const e=this.getFieldIdFromElement(t),s=this.createGroup(e);if(!s)return;o=s.grid}e.forEach((e=>{s?this.removeFromGroup(e):this.addToGroup(e,o)}));const r=this.getFieldIdFromElement(t);this.schedulePersistance(r);const a=e.length>1?`Moved ${e.length} items`:"Moved item";this.a11y.announce(a)}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 r=t.length;let a=0;this.updateUploadProgress(e,0,r,"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}},l=this.createPreviewUrl(t),d=t.type.startsWith("image/")?await this.processImage(t,s.config.subtype):t;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"===s.config.destination);return this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),o.ui.preview&&(o.ui.preview.appendChild(u),this.uploadElements.set(i,{element:u,preview:l,location:o.ui.preview})),n.status="processed",await this.uploadStore.save(n),s.uploads.add(i),await this.saveFieldData(s),a++,this.updateUploadProgress(e,a,r,"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),a++,this.updateUploadProgress(e,a,r,"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,r)=>{let a,i=!1;a=setTimeout((()=>{i||(i=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),r(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:a}),this.handleProcess(e,t).then((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),o(e))})).catch((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),r(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,r)=>{const a=new Image,i=document.createElement("canvas"),n=i.getContext("2d");let l=null;const d=()=>{a.onload=null,a.onerror=null,l&&(URL.revokeObjectURL(l),l=null),i.width=1,i.height=1,n.clearRect(0,0,1,1)};a.onload=()=>{try{const{width:l,height:c}=this.calculateOptimalDimensions(a,t);i.width=l,i.height=c,n.imageSmoothingEnabled=!0,n.imageSmoothingQuality="high",n.drawImage(a,0,0,l,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 r(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),r(new Error(`Canvas processing failed: ${e.message}`))}},a.onerror=()=>{d(),r(new Error(`Failed to load image: ${e.name}`))};try{l=this.createPreviewUrl(e),a.src=l}catch(e){d(),r(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(((r,a)=>{if(!this.worker.worker)return void a(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 s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});r(s)}else a(new Error(t.data.error||"Worker processing failed"))},l=e=>{this.worker.worker.removeEventListener("message",n),this.worker.worker.removeEventListener("error",l),a(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: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 r=Math.min(t/s,t/o);return{width:Math.round(s*r),height:Math.round(o*r)}}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 r=[],a=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){a.append("files[]",s);const o={upload_id:e,index:i.length},r=this.uploadElements.get(e),n=r?.element?.querySelector('[name="featured"]');n?.checked&&(t.fields.featured=e),t.images.push(o),i.push(e)}}r.push(t)}const n=s.filter((e=>{const t=this.uploadStore.get(e);return!t?.groupId}));for(const e of n){const t={images:[],fields:{}},s=await this.getBlobData(e);if(s){a.append("files[]",s);const o={upload_id:e,index:i.length};t.images.push(o),i.push(e)}r.push(t)}a.append("content",t.config.content),a.append("user",t.config.itemID),a.append("posts",JSON.stringify(r)),a.append("upload_ids",JSON.stringify(i));const l={endpoint:"uploads/groups",method:"POST",data:a,title:`Creating ${r.length} ${t.config.content}${r.length>1?"s":""} from uploads...`,popup:`Creating ${r.length} post${r.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(l);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 ${r.length} post${r.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 r={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:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(r);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 r=t.map((async e=>{const t=this.uploadStore.get(e);if(!t)return;const r=await this.getBlobData(e);r&&(s.append("files[]",r),o.push(t.id))}));return await Promise.all(r),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 r={};r[s.attachmentId??s.id]=s.meta;const a={endpoint:"uploads/meta",method:"POST",data:r,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(a)}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:r,id:a}=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 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),r&&r.length>0&&await this.restoreGroups(n,r);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(n,t)}await this.saveFieldData(d),this.updateFieldState(n),this.maybeLockUploads(n),this.refreshSortable(n),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(n)}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 r=await this.getBlobData(t.id);if(!r)return void console.warn("Blob data not found for upload:",t.id);const a=this.createPreviewUrl(r),i=this.getSubtypeFromMime(r.type),n=this.createUploadElement({id:t.id,preview:a,meta:t.meta||{originalName:r.name,size:r.size,type:r.type},subtype:i},"post_group"===o.config.destination);let l;if(t.groupId){const e=this.groupElements.get(t.groupId);if(e?.grid){l=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 l=s.ui.preview,t.groupId=null}else l=s.ui.preview;l?l.appendChild(n):s.ui.preview&&(s.ui.preview.appendChild(n),l=s.ui.preview),this.uploadElements.set(t.id,{element:n,preview:a,location:l}),o.uploads||(o.uploads=new Set),o.uploads.add(t.id),t.status="processed",await this.uploadStore.save(t),l&&this.updateSortableState(l)}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 r=o.groups?.find((e=>e.id===s.id));if(r&&(s.changes&&(r.changes={...s.changes}),s.uploads&&(r.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,r=s.uploads?.size||0,a=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;o.dataset.hasUploads=r>0?"true":"false",o.dataset.uploadCount=r.toString(),o.dataset.hasGroups=a?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${r} item${1!==r?"s":""}`)}updateUploadProgress(e,t,s,o){const r=this.fieldElements.get(e);if(!r?.ui?.progress?.progress)return;const a=r.ui.progress,i=s>0?t/s*100:0;a.fill&&(a.fill.style.width=`${i}%`),a.text&&(a.text.textContent=o),a.count&&(a.count.textContent=`${t}/${s}`),a.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 r=o.element.querySelector(".progress");if(!r)return;const a=r.querySelector(".fill"),i=r.querySelector(".details"),n=r.querySelector(".icon");a&&(a.style.width=`${t}%`),s&&i&&(i.textContent=this.getStatusText(s)),s&&n&&(n.innerHTML=this.getStatusIcon(s).outerHTML)}maybeLockUploads(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t?.ui?.dropZone||!s)return;if("post_group"===s.config.destination)return;const o=s.uploads?.size||0,r=s.config?.maxFiles||999;t.ui.dropZone.hidden=o>=r,t.element.classList.toggle("at-max-uploads",o>=r)}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 r=this.createGroupElement(t,e);if(!r)return null;o.ui.groups||(o.ui.groups={groups:new Map,container:null,empty:null,display:null}),o.ui.groups.groups.set(t,r),o.ui.groups.container&&o.ui.groups.empty?o.ui.groups.container.insertBefore(r,o.ui.groups.empty):o.ui.groups.container&&o.ui.groups.container.appendChild(r);const a=r.querySelector(".item-grid.group");if(this.groupElements.set(t,{element:r,grid:a,fieldId:e}),s.groups||(s.groups=[]),s.groups.push({id:t,uploads:[],changes:{}}),this.saveFieldData(s),this.addGroupSelectionHandler(e,t),a){const s=new Sortable(a,{...this.sortableConfig,group:{name:e,pull:!0,put:!0},disabled:!0,onAdd:e=>this.updateSortableState(e.to),onRemove:e=>this.updateSortableState(e.from)});a.sortableInstance=s,this.sortableInstances.set(`${e}-group-${t}`,s)}return{id:t,element:r,grid:a}}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 r=s.querySelector(".fields");if(r&&o){r.append(o);const a=r.querySelector('[name="post_title"]'),i=r.querySelector('[name="post_excerpt"]');a&&(a.id=`${e}_title`,a.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=s.querySelector("summary");e&&(e.textContent=n.config.content+" Fields")}}else s.querySelector("details")?.remove();const a=s.querySelector(".item-grid.group");return a&&(a.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 r=o.groups?.find((t=>t.id===e));let a=!0;t&&r?.uploads?.length>0&&(a=!window.confirm("Delete uploads in group?")),t&&a&&r?.uploads&&r.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}`,n=this.sortableInstances.get(i);n?.destroy&&n.destroy(),this.sortableInstances.delete(i),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){const o=this.uploadStore.get(e),r=this.uploadElements.get(e);if(!o||!r)return;const a=this.getFieldData(o.fieldId),i=this.fieldElements.get(o.fieldId);if(!a||!i)return;if(!t&&r.location===i.ui.preview||t===r.location)return;if(o.groupId){const t=a.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 n=r.element.querySelector('[name*="select-item"]');n&&(n.checked=!1);let l=r.element.querySelector('[name="featured"]');if(l&&(l.hidden=!t),!t||t.classList.contains("preview"))t=i.ui.preview,o.groupId=null;else{const s=t.dataset.groupId;l&&(l.name=s+"_"+l.name);const r=a.groups?.find((e=>e.id===s));r&&(r.uploads||(r.uploads=[]),r.uploads.push(e),o.groupId=s)}r.location=t,t.append(r.element),this.uploadStore.save(o),s&&this.saveFieldData(a),this.updateSortableState(t),r.location&&r.location!==t&&this.updateSortableState(r.location)}removeFromGroup(e){const t=this.uploadStore.get(e),s=this.uploadElements.get(e);if(!t||!s)return;const o=this.getFieldData(t.fieldId),r=this.fieldElements.get(t.fieldId);if(!o||!r)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}r.ui?.preview&&(r.ui.preview.appendChild(s.element),s.location=r.ui.preview);const a=s.element.querySelector('[name="featured"]');a&&(a.hidden=!0,a.checked=!1),this.uploadStore.save(t),this.updateSortableState(r.ui.preview)}removeUpload(e,t){const s=this.getFieldData(e),o=this.uploadStore.get(t),r=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))}r?.element?.remove(),this.clearUpload(t),this.saveFieldData(s),this.updateFieldState(e),this.maybeLockUploads(e);const a=this.selectionHandlers.get(e);a&&a.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 r=e.name;r.includes("group")&&(r=r.replace(`${o.id}_`,"").replace(`${o.id}[`,"").replace("]","")),o.changes[r]=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"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.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 r=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 r.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,r),r}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 r=e.closest(o.selector);if(!r)return null;const a=r.dataset[o.key];return{...o.getRuntimeData(a),...o.getStoreData(a)}}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,r,a,i,n]=[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":r&&(r.src=e.preview,r.alt=e.meta?.originalName||""),a?.remove(),i?.remove();break;case"video":a&&(a.src=e.preview),r?.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"},n=window.getIcon(o[s]||"file");i&&(i.innerText=t,i.prepend(n)),r?.remove(),a?.remove()}if(n){let e=window.getTemplate("uploadMeta");e&&n.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.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)),1e3)}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.checkForStoredUploads()}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":break;case"item-saved":this.showSaveIndicator(t.key)}}async checkForStoredUploads(){const e=this.fieldStore.getAll();console.log("Checking for stored uploads...",{fieldStates:e.length,uploadStoreSize:this.uploadStore.data.size});const t=e.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)}))}));console.log("Found pending fields:",t.length),0!==t.length&&this.showRecoveryNotification(t)}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,r=window.getTemplate("restoreNotification");if(!r)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 a=r.querySelector(".restore-details");a&&(a.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(const e of t.uploads){let s=window.getTemplate("uploadItem");if(!s)continue;const r=await this.getBlobData(e.id);if(r)try{const o=this.createPreviewUrl(r);let[a,i,n,l,d]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];s.dataset.uploadId=e.id,s.dataset.fieldId=t.id;let c=this.getSubtypeFromMime(r.type);switch(s.dataset.subtype=c,c){case"image":[i.src,i.alt]=[o,r.name??e.meta?.originalName??""],n.remove(),l.remove();break;case"video":n.src=o,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=e.originalFile.name,l.prepend(t),i.remove(),n.remove()}s.dataset.previewUrl=o}catch(t){console.warn("Failed to create preview for upload:",e.id,t)}const a=s.querySelector("summary span");a&&(a.textContent=e.meta?.originalName||"Unknown file");const i=s.querySelector("details");i&&e.meta&&(i.textContent=`${this.formatBytes(e.meta.size)} • ${e.meta.type}`),s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),o&&o.appendChild(s)}r.querySelector(".wrap").appendChild(o)}document.querySelector(".field.upload").appendChild(r),r=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection({container:r,ui:{selectAll:r.querySelector("#select-all-restore"),count:r.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())}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",(()=>{window.jvbUploads=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/utility.min.js b/assets/js/min/utility.min.js
index c79b1bb..bcac47e 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),a=Math.floor(r/24);return r<24?0===r?0===o?"Just now":`${o} ${1===o?"minute":"minutes"} ago`:`${r} ${1===r?"hour":"hours"} ago`:a<7?`${a} ${1===a?"day":"days"} ago`:e.toLocaleDateString()},window.formatTimeSoon=function(t){const e=t instanceof Date?t:new Date(t),n=new Date;if(e<=n)return"Just now";const i=Math.floor((e-n)/1e3),o=Math.floor(i/60);return i<60?"In a moment":o<5?"In a few minutes":o<20?"Coming up soon":o<60?"In about half an hour":"Later today"},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((t=>{const e=Array.from(t.classList);if(e.length>0){const n=t.content.cloneNode(!0).firstElementChild;e.forEach((t=>{window.templates.has(t)||window.templates.set(t,n)}))}}))},window.getTemplate=function(t){return 0===window.templates.size&&loadTemplates(),!!window.templates.has(t)&&window.templates.get(t).cloneNode(!0)},window.formatVote=function(t,e){let n=window.getTemplate("voteButton");n.dataset.itemId=t.id,n.dataset.content=t.content;let i=n.querySelector("button.up"),o=n.querySelector("button.down");return"up"===e&&i.classList.add("voted"),"down"===e&&o.classList.add("voted"),t.upvotes>0&&(i.querySelector(".count").textContent=t.upvotes),t.downvotes>0&&(o.querySelector(".count").textContent="-"+t.downvotes),n},window.checkVoteStatus=function(t,e){if(!jvbSettings.currentUser)return"";let n="";return window.userVotes&&window.userVotes[t]?.has(e)&&(n=window.userVotes[t].get(e)),n},window.icon=document.querySelector(".icon").cloneNode(!0),window.icon.className="icon",window.getIcon=function(t){if(void 0===t)return"";console.log("Getting icon: "+t);let e=window.icon.cloneNode(!0);return e.classList.add("icon-"+t),e},window.isEmptyObject=function(t){return 0===Object.keys(t).length},window.formatNumber=function(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(t,e="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:e}).format(t)},window.escapeHtml=function(t){return t?("string"==typeof t||t instanceof String||(t=String(t)),t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(t){return t instanceof Date&&!isNaN(t)||(t=new Date(t)),window.dateFormatter.format(t)},window.typeText=function(t,e,n=50){return t.classList.add("typeText"),new Promise((i=>{let o=0;t.textContent="";const r=setInterval((()=>{o<e.length?(t.textContent+=e.charAt(o),o++):(clearInterval(r),i())}),n)}))},window.eraseText=function(t,e=10){return new Promise((n=>{let i=t.textContent,o=i.length;const r=setInterval((()=>{o>0?(o--,t.textContent=i.substring(0,o)):(clearInterval(r),n())}),e)}))},window.typeLoop=function(t,e,n=50,i=10,o=1e3,r=250){let a=!0;return async function(){for(;a;)await window.typeText(t,e,n),await new Promise((t=>setTimeout(t,o))),await window.eraseText(t,i),await new Promise((t=>setTimeout(t,r)))}(),function(){a=!1}},window.toCamelCase=function(t){return t.replace(/-([a-z])/g,(function(t){return t[1].toUpperCase()}))},window.targetCheck=function(t,e){return"string"==typeof e&&(t.target.closest(e)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(t,e){if(this.isFunction(t)||this.isFunction(e))throw"Invalid argument. Function given, object expected.";if(this.isFile(t)||this.isFile(e)){const n=this.compareFiles(t,e);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===t?e:t}}if(this.isValue(t)||this.isValue(e)){const n=this.compareValues(t,e);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=e;break;case this.VALUE_DELETED:i=this.getEmptyValue(t);break;case this.VALUE_UPDATED:default:i=e}return{type:n,data:i}}let n={},i=!1;for(let o in t)if(!this.isFunction(t[o])){let r;e&&void 0!==e[o]&&(r=e[o]);const a=this.map(t[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(e)for(let o in e)if(!this.isFunction(e[o])&&(void 0===t||void 0===t[o])){const t=this.map(void 0,e[o]);null!==t&&(t.hasOwnProperty("type")&&t.hasOwnProperty("data")?n[o]=t.data:n[o]=t,i=!0)}return i?n:null},getEmptyValue:function(t){return this.isArray(t)?[]:this.isObject(t)?{}:"number"==typeof t?0:"boolean"!=typeof t&&""},compareValues:function(t,e){return t===e||this.isDate(t)&&this.isDate(e)&&t.getTime()===e.getTime()?this.VALUE_UNCHANGED:void 0===t?this.VALUE_CREATED:void 0===e?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(t){return"[object Function]"===Object.prototype.toString.call(t)},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isDate:function(t){return"[object Date]"===Object.prototype.toString.call(t)},isObject:function(t){return"[object Object]"===Object.prototype.toString.call(t)},isFile:function(t){return t instanceof File},isValue:function(t){return!this.isObject(t)&&!this.isArray(t)},compareFiles:function(t,e){return!this.isFile(t)&&this.isFile(e)?this.VALUE_CREATED:this.isFile(t)&&!this.isFile(e)?this.VALUE_DELETED:this.isFile(t)&&this.isFile(e)?t.name===e.name&&t.size===e.size&&t.type===e.type&&t.lastModified===e.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(t,e){if(null==t)return e;if(null==e)return t;if(this.isFunction(t)||this.isFunction(e))return e;if(this.isFile(t)||this.isFile(e))return e;if(this.isValue(t)||this.isValue(e)||this.isArray(t)||this.isArray(e))return e;if(this.isObject(t)&&this.isObject(e)){let n={};for(let e in t)this.isFunction(t[e])||(n[e]=t[e]);for(let i in e)this.isFunction(e[i])||(void 0!==t[i]?n[i]=this.merge(t[i],e[i]):n[i]=e[i]);return n}return e}},window.deepMerge=function(t,e){return window.getDifferences.merge(t,e)},window.isInt=function(t){return!isNaN(parseInt(t))&&isFinite(t)},window.isNumeric=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},window.handleListField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("li");e.forEach((e=>{let i=n.cloneNode(!0);i.textContent=e,t.append(i)})),n.remove()},window.handleTextField=function(t,e){"string"==typeof e?t.textContent=e:t.remove()},window.handleImageField=function(t,e){if(!Array.isArray(e)||0===e)return void t.remove();let n="IMG"===t.tagName?t:t.querySelector("img");n?(n.alt=e.alt,n.src=e.thumbnail,n.dataset.small=e.small,n.dataset.medium=e.medium,n.dataset.large=e.full):t.remove()},window.handleGalleryField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("img");e.forEach((e=>{let i=n.cloneNode(!0);window.handleImageField(i,e),t.append(i)})),n.remove()},window.uiFromSelectors=function(t,e=null){let n={};for(let[i,o]of Object.entries(t))n[i]="object"==typeof o?window.uiFromSelectors(o,e):e?e.querySelector(o):document.querySelector(o);return n};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(t,e,n=1e3){this.cancel(t),this.timeouts.set(t,setTimeout((()=>{e(),this.timeouts.delete(t)}),n))}cancel(t){this.timeouts.has(t)&&(clearTimeout(this.timeouts.get(t)),this.timeouts.delete(t))}cleanup(){for(let t of this.timeouts.values())clearTimeout(t);this.timeouts.clear()}}})();
\ No newline at end of file
+(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),a=Math.floor(r/24);return r<24?0===r?0===o?"Just now":`${o} ${1===o?"minute":"minutes"} ago`:`${r} ${1===r?"hour":"hours"} ago`:a<7?`${a} ${1===a?"day":"days"} ago`:e.toLocaleDateString()},window.formatTimeSoon=function(t){const e=t instanceof Date?t:new Date(t),n=new Date;if(e<=n)return"Just now";const i=Math.floor((e-n)/1e3),o=Math.floor(i/60);return i<60?"In a moment":o<5?"In a few minutes":o<20?"Coming up soon":o<60?"In about half an hour":"Later today"},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((t=>{const e=Array.from(t.classList);if(e.length>0){const n=t.content.cloneNode(!0).firstElementChild;e.forEach((t=>{window.templates.has(t)||window.templates.set(t,n)}))}}))},window.getTemplate=function(t){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(t)&&window.templates.get(t).cloneNode(!0)},window.formatVote=function(t,e){let n=window.getTemplate("voteButton");n.dataset.itemId=t.id,n.dataset.content=t.content;let i=n.querySelector("button.up"),o=n.querySelector("button.down");return"up"===e&&i.classList.add("voted"),"down"===e&&o.classList.add("voted"),t.upvotes>0&&(i.querySelector(".count").textContent=t.upvotes),t.downvotes>0&&(o.querySelector(".count").textContent="-"+t.downvotes),n},window.checkVoteStatus=function(t,e){if(!jvbSettings.currentUser)return"";let n="";return window.userVotes&&window.userVotes[t]?.has(e)&&(n=window.userVotes[t].get(e)),n},window.icon=null,window.getIcon=function(t,e=""){if(void 0===t)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return e=""!==e&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${e.slice(0,2)}`:"",n.classList.add(`icon-${t}${e}`),n},window.isEmptyObject=function(t){return 0===Object.keys(t).length},window.formatNumber=function(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(t,e="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:e}).format(t)},window.escapeHtml=function(t){return t?("string"==typeof t||t instanceof String||(t=String(t)),t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(t){return t instanceof Date&&!isNaN(t)||(t=new Date(t)),window.dateFormatter.format(t)},window.typeText=function(t,e,n=50){return t.classList.add("typeText"),new Promise((i=>{let o=0;t.textContent="";const r=setInterval((()=>{o<e.length?(t.textContent+=e.charAt(o),o++):(clearInterval(r),i())}),n)}))},window.eraseText=function(t,e=10){return new Promise((n=>{let i=t.textContent,o=i.length;const r=setInterval((()=>{o>0?(o--,t.textContent=i.substring(0,o)):(clearInterval(r),n())}),e)}))},window.typeLoop=function(t,e,n=50,i=10,o=1e3,r=250){let a=!0;return async function(){for(;a;)await window.typeText(t,e,n),await new Promise((t=>setTimeout(t,o))),await window.eraseText(t,i),await new Promise((t=>setTimeout(t,r)))}(),function(){a=!1}},window.toCamelCase=function(t){return t.replace(/-([a-z])/g,(function(t){return t[1].toUpperCase()}))},window.targetCheck=function(t,e){return Array.isArray(e)&&(e=e.join(",")),"string"==typeof e&&(t.target.closest(e)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(t,e){if(this.isFunction(t)||this.isFunction(e))throw"Invalid argument. Function given, object expected.";if(this.isFile(t)||this.isFile(e)){const n=this.compareFiles(t,e);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===t?e:t}}if(this.isValue(t)||this.isValue(e)){const n=this.compareValues(t,e);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=e;break;case this.VALUE_DELETED:i=this.getEmptyValue(t);break;case this.VALUE_UPDATED:default:i=e}return{type:n,data:i}}let n={},i=!1;for(let o in t)if(!this.isFunction(t[o])){let r;e&&void 0!==e[o]&&(r=e[o]);const a=this.map(t[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(e)for(let o in e)if(!this.isFunction(e[o])&&(void 0===t||void 0===t[o])){const t=this.map(void 0,e[o]);null!==t&&(t.hasOwnProperty("type")&&t.hasOwnProperty("data")?n[o]=t.data:n[o]=t,i=!0)}return i?n:null},getEmptyValue:function(t){return this.isArray(t)?[]:this.isObject(t)?{}:"number"==typeof t?0:"boolean"!=typeof t&&""},compareValues:function(t,e){return t===e||this.isDate(t)&&this.isDate(e)&&t.getTime()===e.getTime()?this.VALUE_UNCHANGED:void 0===t?this.VALUE_CREATED:void 0===e?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(t){return"[object Function]"===Object.prototype.toString.call(t)},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isDate:function(t){return"[object Date]"===Object.prototype.toString.call(t)},isObject:function(t){return"[object Object]"===Object.prototype.toString.call(t)},isFile:function(t){return t instanceof File},isValue:function(t){return!this.isObject(t)&&!this.isArray(t)},compareFiles:function(t,e){return!this.isFile(t)&&this.isFile(e)?this.VALUE_CREATED:this.isFile(t)&&!this.isFile(e)?this.VALUE_DELETED:this.isFile(t)&&this.isFile(e)?t.name===e.name&&t.size===e.size&&t.type===e.type&&t.lastModified===e.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(t,e){if(null==t)return e;if(null==e)return t;if(this.isFunction(t)||this.isFunction(e))return e;if(this.isFile(t)||this.isFile(e))return e;if(this.isValue(t)||this.isValue(e)||this.isArray(t)||this.isArray(e))return e;if(this.isObject(t)&&this.isObject(e)){let n={};for(let e in t)this.isFunction(t[e])||(n[e]=t[e]);for(let i in e)this.isFunction(e[i])||(void 0!==t[i]?n[i]=this.merge(t[i],e[i]):n[i]=e[i]);return n}return e}},window.deepMerge=function(t,e){return window.getDifferences.merge(t,e)},window.isInt=function(t){return!isNaN(parseInt(t))&&isFinite(t)},window.isNumeric=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},window.handleListField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("li");e.forEach((e=>{let i=n.cloneNode(!0);i.textContent=e,t.append(i)})),n.remove()},window.handleTextField=function(t,e){"string"==typeof e?t.textContent=e:t.remove()},window.handleImageField=function(t,e){if(!Array.isArray(e)||0===e)return void t.remove();let n="IMG"===t.tagName?t:t.querySelector("img");n?(n.alt=e.alt,n.src=e.thumbnail,n.dataset.small=e.small,n.dataset.medium=e.medium,n.dataset.large=e.full):t.remove()},window.handleGalleryField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("img");e.forEach((e=>{let i=n.cloneNode(!0);window.handleImageField(i,e),t.append(i)})),n.remove()},window.uiFromSelectors=function(t,e=null){let n={};for(let[i,o]of Object.entries(t))n[i]="object"==typeof o?window.uiFromSelectors(o,e):e?e.querySelector(o):document.querySelector(o);return n};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(t,e,n=1e3){this.cancel(t),this.timeouts.set(t,setTimeout((()=>{e(),this.timeouts.delete(t)}),n))}cancel(t){this.timeouts.has(t)&&(clearTimeout(this.timeouts.get(t)),this.timeouts.delete(t))}cleanup(){for(let t of this.timeouts.values())clearTimeout(t);this.timeouts.clear()}}})();
\ No newline at end of file
diff --git a/assets/js/min/view.min.js b/assets/js/min/view.min.js
index 4b63dae..7f934f3 100644
--- a/assets/js/min/view.min.js
+++ b/assets/js/min/view.min.js
@@ -1 +1 @@
-window.jvbViews=class{constructor(e,t){this.a11y=window.jvbA11y,this.error=window.jvbError,this.container=e,this.initElements(),this.settings=window.jvbUserSettings,this.store=t,this.items={list:new Map,grid:new Map,table:new Map},this.currentView="grid",this.selectedItems=new Set,this.init()}initElements(){this.selectors={grid:".item-grid",table:{table:"table",body:"table body",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"}},this.ui=window.uiFromSelectors(this.selectors,this.container)}init(){this.store.subscribe(((e,t)=>{switch(e){case"items-saved":case"item-saved":case"item-deleted":break;case"data-loaded":this.handleItemsUpdate()}})),this.setupViewSwitcher(),this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.lastSelected=null,document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler)}handleClick(e){e.target.closest(".select-item-label")&&(e.shiftKey?(e.preventDefault(),this.handleRangeSelection(e.target)):this.lastSelected=e.target.closest(".item"))}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(".item"));const t=e.closest(".item"),i=Array.from(this.container.querySelectorAll(".item")),s=i.indexOf(this.lastSelected),l=i.indexOf(t);if(-1===s||-1===l)return void(this.lastSelected=t);const a=Math.min(s,l),r=Math.max(s,l);let d=0;for(let e=a;e<=r;e++){let t=i[e];this.selectedItems.add(t.dataset.id);let s=t.querySelector(".select-item");s&&!s.checked&&(s.checked=!0,d++)}this.updateSelectionUI(),window.jvbA11y.announce(`Selected ${d} items in range.`)}handleChange(e){e.target.closest(".select-all")?this.selectAll(e.target.checked):e.target.closest(".select-item")?this.toggleSelection(e.target.closest(".item").dataset.id):e.target.closest("details.multi-select")&&this.toggleColumns(e.target.id,e.target.checked)}toggleColumns(e,t){this.ui.table.columns.filter((t=>t.className===e))}setupViewSwitcher(){document.querySelectorAll("[data-view]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("click",(()=>{this.currentView=e.dataset.view,this.render()}))}))}handleDataUpdate(e){console.log(e);const t=e.data?.items||e.items||[];this.render(t)}handleItemsUpdate(){console.log(this.store.data),this.render(this.store.data)}render(e=[]){if(this.store)if(console.log(e),0!==e.length){switch(this.currentView){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e);break;case"list":this.renderList(e)}this.updateSelectionUI()}else this.renderEmpty();else console.error("No store connected to renderer")}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.getTemplate("emptyState");e&&(this.ui.grid.appendChild(e),this.a11y?.announce("No items found"))}renderGrid(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view");const t=document.createDocumentFragment();e.forEach((e=>{let i;this.store.renderOrRetrieve?i=this.store.renderOrRetrieve(e,"grid",this.renderGridItem.bind(this)):this.items.grid.has(e.id)?i=this.items.grid.get(e.id):(i=this.renderGridItem(e),this.items.grid.set(e.id,i)),t.appendChild(i)})),this.ui.grid.appendChild(t)}renderGridItem(e){const t=window.getTemplate("gridView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let[i,s,l,a,r]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("img"),t.querySelector('[data-action="edit"]'),t.querySelector('[data-action="trash"]')];return[i.value,i.id,i.checked,s.htmlFor,a.dataset.id,r.dataset.id]=[e.id,`select-${e.id}`,this.selectedItems.has(`${e.id}`),`select-${e.id}`,e.id,e.id],[l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""],t}toggleTable(e){if(this.ui.table.selectedColumns.hidden=!e,e&&!this.ui.table.table){let e=window.getTemplate("contentTable");this.container.append(e),this.ui.table.table=this.container.querySelector("form.table"),this.ui.table.body=this.ui.table.table.querySelector("tbody"),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.table&&(this.ui.table.table.hidden=!e,window.removeChildren(this.ui.table.body)),this.ui.table.selectedColumns.hidden=!e}toggleGrid(){window.removeChildren(this.ui.grid)}renderTable(e){this.toggleTable(!0),this.toggleGrid(),e.forEach((e=>{let t;this.items.table.has(e.id)?t=this.items.table.get(e.id):(t=this.store.renderOrRetrieve(e,"table",this.renderTableItem.bind(this)),this.items.table.set(e.id,t)),this.ui.table.body.append(t)})),window.jvbSelector.scanExistingFields()}renderTableItem(e){let t=["",0];const i=window.getTemplate("tableView");return i.dataset.id=e.id,[i.querySelector(".select-item").id,i.querySelector(".select-item").value,i.querySelector(".select-item").checked,i.querySelector(".select-item + label").htmlFor,i.querySelector(`input[name="post_status"][value="${e.status}"]`).checked]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id,e.status],i.querySelectorAll("td[data-field]").forEach((i=>{let s,l=e.fields[i.dataset.field],a=i.querySelector("label"),r=t.includes(l);switch(i.dataset.fieldType){case"text":case"number":case"url":case"tel":case"email":r||(i.querySelector("input").value=l),a.remove();break;case"textarea":r||(i.querySelector("textarea").value=l),a.remove();break;case"taxonomy":a.remove(),r||(s=i.querySelector("input[type=hidden]"),s.value=l);break;case"image":if(!r){let t=window.getTemplate("uploadItem"),s=t.querySelector("img");[s.src,s.alt]=[e.images[l].medium??"",e.images[l].alt??""],i.querySelector(".item-grid").append(t),i.querySelector("input[type=hidden]").value=l}i.querySelectorAll(".progress,label,.upload-select,.status,details").forEach((e=>{e.remove()}));break;case"true_false":r||(i.querySelector("input").checked=1===parseInt(l)),i.querySelector(".toggle-label")?.remove();break;case"select":a.remove();case"radio":case"checkbox":i.querySelector(".label")?.remove(),r||(l=l.split(","),l.forEach((e=>{s=i.querySelector(`[value="${e}"]`),s&&(s.checked=!0)})));break;default:r||console.log(l)}})),i}renderList(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),e.forEach((e=>{let t;this.items.list.has(e.id)?t=this.items.list.get(e.id):(t=this.store.renderOrRetrieve(e,"list",this.renderListItem.bind(this)),this.items.list.set(e.id,t)),this.ui.grid.appendChild(t)}))}renderListItem(e){const t=window.getTemplate("listView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let i=t.querySelector(".select-item"),s=t.querySelector(".select-item + label");[i.id,i.value,i.checked,s.htmlFor]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id],t.querySelectorAll("[data-attr]").forEach((t=>{""!==e[t.dataset.attr]?t.textContent=e[t.dataset.attr]:t.remove()})),t.querySelectorAll("[data-field]").forEach((t=>{let i=e.fields[t.dataset.field];""!==i?"DIV"===t.tagName?t.innerHTML=i:t.textContent=i:t.remove()}));let l=t.querySelector("img");return l&&([l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""]),t}toggleSelection(e){this.selectedItems.has(e)?this.selectedItems.delete(e):this.selectedItems.add(e),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(".item");e||(this.selectedItems.clear(),this.ui.bulk.selectAll.checked=!1,this.ui.bulk.select.value=""),t.forEach((t=>{e&&this.selectedItems.add(t.dataset.id),t.querySelector(".select-item").checked=e})),this.updateSelectionUI()}clearSelection(){this.selectAll(!1),this.ui.bulk.select.value=""}updateSelectionUI(){const e=this.selectedItems.size;if(this.ui.bulk.control&&(this.ui.bulk.control.hidden=0===e),this.ui.bulk.count){let t=1===e?"item":"items";this.ui.bulk.count.hidden=0===e,this.ui.bulk.count.textContent=0===e?"":`${e} ${t} selected`}}};
\ No newline at end of file
+window.jvbViews=class{constructor(e,t){this.a11y=window.jvbA11y,this.error=window.jvbError,this.container=e,this.initElements(),this.settings=window.jvbUserSettings,this.store=t,this.isTimeline=!!document.querySelector("[data-timeline]"),this.items={list:new Map,grid:new Map,table:new Map},this.currentView="grid",this.selectedItems=new Set,this.subscribers=new Set,this.init()}initElements(){this.selectors={grid:".item-grid",table:{table:"form.table",form:"table",body:"table body",header:"table thead",footer:"table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"}},this.ui=window.uiFromSelectors(this.selectors,this.container)}init(){this.store.subscribe(((e,t)=>{switch(e){case"items-saved":case"item-saved":case"item-deleted":break;case"data-loaded":this.handleItemsUpdate()}})),this.setupViewSwitcher(),this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.lastSelected=null,document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler)}handleClick(e){e.target.closest(".select-item-label")&&(e.shiftKey?(e.preventDefault(),this.handleRangeSelection(e.target)):this.lastSelected=e.target.closest(".item"))}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(".item"));const t=e.closest(".item"),i=Array.from(this.container.querySelectorAll(".item")),s=i.indexOf(this.lastSelected),l=i.indexOf(t);if(-1===s||-1===l)return void(this.lastSelected=t);const r=Math.min(s,l),a=Math.max(s,l);let d=0;for(let e=r;e<=a;e++){let t=i[e];this.selectedItems.add(t.dataset.id);let s=t.querySelector(".select-item");s&&!s.checked&&(s.checked=!0,d++)}this.updateSelectionUI(),window.jvbA11y.announce(`Selected ${d} items in range.`)}handleChange(e){e.target.closest(".select-all")?this.selectAll(e.target.checked):e.target.closest(".select-item")?this.toggleSelection(e.target.closest(".item").dataset.id):e.target.closest("details.multi-select")&&this.toggleColumns(e.target.id,e.target.checked)}toggleColumns(e,t){this.ui.table.columns.filter((t=>t.className===e))}setupViewSwitcher(){document.querySelectorAll("[data-view]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("click",(()=>{this.currentView=e.dataset.view,this.render()}))}));const e=document.querySelector("[data-view]:checked");e&&(this.currentView=e.dataset.view)}handleDataUpdate(e){console.log(e);const t=e.data?.items||e.items||[];this.render(t)}handleItemsUpdate(){this.render()}render(){if(!this.store)return void console.error("No store connected to renderer");const e=this.store.getFiltered();if(0!==e.length){switch(this.currentView){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e);break;case"list":this.renderList(e)}this.updateSelectionUI()}else this.renderEmpty()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.getTemplate("emptyState");e&&(this.ui.grid.appendChild(e),this.a11y?.announce("No items found"))}renderGrid(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view");const t=document.createDocumentFragment();e.forEach((e=>{let i=this.renderGridItem(e);t.appendChild(i)})),this.ui.grid.appendChild(t)}renderGridItem(e){if(this.items.grid.has(e.id))return this.items.grid.get(e.id);const t=window.getTemplate("gridView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let[i,s,l,r,a]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("img"),t.querySelector('[data-action="edit"]'),t.querySelector('[data-action="trash"]')];return[i.value,i.id,i.checked,s.htmlFor,r.dataset.id,a.dataset.id]=[e.id,`select-${e.id}`,this.selectedItems.has(`${e.id}`),`select-${e.id}`,e.id,e.id],[l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""],this.items.grid.set(e.id,t),t}toggleTable(e){if(this.ui.table.selectedColumns.hidden=!e,e&&!this.ui.table.table){let e=window.getTemplate("contentTable");this.container.append(e),this.ui.table.table=this.container.querySelector("form.table"),this.ui.table.form=this.ui.table.table.querySelector("table"),this.ui.table.header=this.ui.table.form.querySelector("thead"),this.ui.table.footer=this.ui.table.form.querySelector("tfoot"),this.ui.table.body=this.ui.table.form.querySelector("tbody"),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.table&&(this.ui.table.table.hidden=!e,e?this.notify("table-view",this.ui.table.table):this.notify("not-table-view",this.ui.table.table),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.ui.table.selectedColumns.hidden=!e}toggleGrid(){window.removeChildren(this.ui.grid)}renderTable(e){this.toggleTable(!0),this.toggleGrid(),e.forEach((e=>{let t=this.isTimeline?this.renderTimelineTableItem(e):this.renderTableItem(e);this.ui.table.body?this.ui.table.body.append(t):(this.ui.table.footer||(this.ui.table.footer=this.ui.table.table.querySelector("tfoot")),this.ui.table.form.insertBefore(t,this.ui.table.footer))})),window.jvbSelector.scanExistingFields()}renderTableItem(e){if(this.items.table.has(e.id))return this.items.table.get(e.id);const t=window.getTemplate("tableView");return t.dataset.id=e.id,[t.querySelector(".select-item").id,t.querySelector(".select-item").value,t.querySelector(".select-item").checked,t.querySelector(".select-item + label").htmlFor,t.querySelector(`input[name="post_status"][value="${e.status}"]`).checked]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id,e.status],new window.jvbPopulate(t,e.fields,e.images),this.cleanupTableRow(t),this.items.table.set(e.id,t),t}renderTimelineTableItem(e){if(this.items.table.has(e.id))return this.items.table.get(e.id);const t=window.getTemplate("tableView");t.dataset.id=e.id,[t.querySelector(".select-item").id,t.querySelector(".select-item").value,t.querySelector(".select-item").checked,t.querySelector(".select-item + label").htmlFor]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id];let i=t.querySelector(".timeline-point"),s=t,l=t.querySelector("tr.shared");return new window.jvbPopulate(l,e.fields,e.images),this.prefixTimelineFieldNames(l,e.id),this.cleanupTableRow(l),e.fields.timeline&&"object"==typeof e.fields.timeline&&Object.entries(e.fields.timeline).forEach((([t,l],r)=>{let a=i.cloneNode(!0);a.dataset.index=r,a.dataset.imageId=t,new window.jvbPopulate(a,l,e.images),this.cleanupTableRow(a);let d=e.images[l.post_thumbnail];d&&(a.querySelector(".field.upload").title=d["image-title"]),this.prefixTimelineFieldNames(a,l.id),s.insertBefore(a,i)})),i.remove(),this.items.table.set(e.id,t),t}prefixTimelineFieldNames(e,t){e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name;if(!i||i.startsWith("[")||"form-id"===i||i.startsWith("_"))return;let s=e.nextElementSibling;e.name=`[${t}]${i}`,s&&"LABEL"===s.tagName&&(e.id=`[${t}]${e.id}`,s.htmlFor=e.id)}))}cleanupTableRow(e){e.querySelectorAll("td[data-field]").forEach((e=>{e.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach((e=>{e.closest(".radio-options")||e.remove()})),"true_false"===e.dataset.fieldType&&e.querySelector(".toggle-label")?.remove(),["checkbox","radio","select"].includes(e.dataset.fieldType)&&e.querySelector(".label")?.remove()}))}renderList(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),e.forEach((e=>{let t=this.renderListItem(e);this.ui.grid.appendChild(t)}))}renderListItem(e){if(this.items.list.has(e.id))return this.items.list.get(e.id);const t=window.getTemplate("listView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let i=t.querySelector(".select-item"),s=t.querySelector(".select-item + label");[i.id,i.value,i.checked,s.htmlFor]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id],t.querySelectorAll("[data-attr]").forEach((t=>{""!==e[t.dataset.attr]?t.textContent=e[t.dataset.attr]:t.remove()})),t.querySelectorAll("[data-field]").forEach((t=>{let i=e.fields[t.dataset.field];""!==i?"DIV"===t.tagName?t.innerHTML=i:t.textContent=i:t.remove()}));let l=t.querySelector("img");return l&&([l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""]),this.items.list.set(e.id,t),t}setupTimelineDragHandler(){this.isTimeline&&"table"===this.currentView&&(this.timelineDragHandler&&this.timelineDragHandler.destroy(),this.timelineDragHandler=new window.jvbDragHandler({draggableSelector:".timeline-point",dropTargetSelector:".timeline-point",handleSelector:".drag-handle",getItemId:e=>e.dataset.imageId,getSelectedItems:()=>[],validateDrop:(e,t)=>{const i=document.querySelector(`.timeline-point[data-image-id="${e[0]}"]`);return!!i&&i.closest("tbody")===t.closest("tbody")},onDragStart:(e,t)=>{t.classList.add("is-dragging")},onDrop:(e,t)=>{const i=document.querySelector(`.timeline-point[data-image-id="${e[0]}"]`);if(!i)return;document.querySelectorAll(".drop-above, .drop-below").forEach((e=>{e.classList.remove("drop-above","drop-below")}));const s=i.closest("tbody");"above"===t.dataset.dropPosition?s.insertBefore(i,t):s.insertBefore(i,t.nextSibling),i.classList.remove("is-dragging"),this.updateTimelineOrder(s)},onDragEnd:(e,t)=>{document.querySelectorAll(".is-dragging, .drop-above, .drop-below").forEach((e=>{e.classList.remove("is-dragging","drop-above","drop-below")}))},previewElement:".drag-handle",previewOptions:{offset:{x:-20,y:-20},showCount:!1}}),this.addTimelineDragHoverLogic())}addTimelineDragHoverLogic(){let e=null;document.addEventListener("pointermove",(t=>{if(!document.querySelector(".timeline-point.is-dragging"))return;const i=t.target.closest(".timeline-point:not(.is-dragging)");if(!i)return void(e&&(e.classList.remove("drop-above","drop-below"),delete e.dataset.dropPosition,e=null));const s=i.getBoundingClientRect(),l=s.top+s.height/2,r=t.clientY<l;e&&e!==i&&(e.classList.remove("drop-above","drop-below"),delete e.dataset.dropPosition),i.classList.remove("drop-above","drop-below"),i.classList.add(r?"drop-above":"drop-below"),i.dataset.dropPosition=r?"above":"below",e=i}))}updateTimelineOrder(e){const t=parseInt(e.dataset.id),i=Array.from(e.querySelectorAll(".timeline-point")),s=this.store.get(t);if(!s)return;let l={};i.forEach(((e,t)=>{const i=e.dataset.imageId;l[i]=s.fields.timeline[i]})),s.fields.timeline=l,this.store.save(s),this.notify("order-changed",t),this.a11y?.announce(`Timeline order updated. ${i.length} steps reordered.`)}extractRowFields(e){const t={};return e.querySelectorAll("[data-field]").forEach((e=>{const i=e.dataset.field,s=e.querySelector("input, textarea, select");s&&("checkbox"===s.type?t[i]=s.checked:t[i]=s.value)})),t}toggleSelection(e){this.selectedItems.has(e)?this.selectedItems.delete(e):this.selectedItems.add(e),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(".item");e||(this.selectedItems.clear(),this.ui.bulk.selectAll.checked=!1,this.ui.bulk.select.value=""),t.forEach((t=>{e&&this.selectedItems.add(t.dataset.id),t.querySelector(".select-item").checked=e})),this.updateSelectionUI()}clearSelection(){this.selectAll(!1),this.ui.bulk.select.value=""}updateSelectionUI(){const e=this.selectedItems.size;if(this.ui.bulk.control&&(this.ui.bulk.control.hidden=0===e),this.ui.bulk.count){let t=1===e?"item":"items";this.ui.bulk.count.hidden=0===e,this.ui.bulk.count.textContent=0===e?"":`${e} ${t} selected`}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((i=>i(e,t)))}};
\ No newline at end of file
diff --git a/base/_setup.php b/base/_setup.php
index 9855a72..e8d7871 100644
--- a/base/_setup.php
+++ b/base/_setup.php
@@ -12,3 +12,9 @@
$base = apply_filters('jvb_base', 'jvb_');
$base = (str_ends_with($base, '_')) ? $base : $base.'_';
define('BASE', $base);
+
+$childPath = apply_filters('jvbChildDir', JVB_DIR);
+define('JVB_CHILD_DIR', $childPath);
+
+$childURL = apply_filters('jvbChildUrl', JVB_URL);
+define('JVB_CHILD_URL', $childURL);
diff --git a/build/feed/block.json b/build/feed/block.json
index 17327ca..d8b7aff 100644
--- a/build/feed/block.json
+++ b/build/feed/block.json
@@ -67,13 +67,8 @@
"message": "This is a notice!"
}
},
- "render": "file:./render.php",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
- "viewScript": "file:./view.js",
- "dependencies": [
- "jvb-taxonomy-selector",
- "jvb-client-queue"
- ]
+ "viewScript": "file:./view.js"
}
\ No newline at end of file
diff --git a/build/feed/index-rtl.css b/build/feed/index-rtl.css
deleted file mode 100644
index 3adeaff..0000000
--- a/build/feed/index-rtl.css
+++ /dev/null
@@ -1 +0,0 @@
-.feed-content-types .components-checkbox-control{margin-bottom:8px}.feed-content-types .components-checkbox-control:last-child{margin-bottom:0}.feed-content-types .components-checkbox-control .components-checkbox-control__input-container{margin-left:8px}.feed-content-types{margin-bottom:16px}.feed-content-types .components-base-control__label{font-weight:500;margin-bottom:8px}.feed-content-types .checkbox-list{background:#fff;border:1px solid #ddd;border-radius:4px;max-height:200px;overflow-y:auto;padding:8px}.feed-content-types .checkbox-list .components-checkbox-control{margin:4px 0}.feed-content-types .checkbox-list .components-checkbox-control:first-child{margin-top:0}.feed-content-types .checkbox-list .components-checkbox-control:last-child{margin-bottom:0}.feed-content-types .select-all-wrapper{border-top:1px solid #ddd;margin-top:8px;padding-top:8px}.feed-content-types .components-checkbox-control__input-container{margin-left:8px}.feed-block{background:#fff;border:1px solid #ddd;padding:20px}.feed-block .feed-block-preview .filter-preview{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}.feed-block .feed-block-preview .filter-preview .content-type-badge{background:#f0f0f0;border-radius:4px;font-size:12px;padding:4px 8px}.feed-block .feed-block-preview .feed-grid-placeholder{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));margin-top:20px}.feed-block .feed-block-preview .feed-grid-placeholder .grid-item-placeholder{aspect-ratio:1;background:#f0f0f0;border-radius:4px}
diff --git a/build/feed/index.asset.php b/build/feed/index.asset.php
index e640b6a..4220db5 100644
--- a/build/feed/index.asset.php
+++ b/build/feed/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'eb5002a905c1285d41c3');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'c4a784b7836c150eb94e');
diff --git a/build/feed/index.css b/build/feed/index.css
deleted file mode 100644
index bb67595..0000000
--- a/build/feed/index.css
+++ /dev/null
@@ -1 +0,0 @@
-.feed-content-types .components-checkbox-control{margin-bottom:8px}.feed-content-types .components-checkbox-control:last-child{margin-bottom:0}.feed-content-types .components-checkbox-control .components-checkbox-control__input-container{margin-right:8px}.feed-content-types{margin-bottom:16px}.feed-content-types .components-base-control__label{font-weight:500;margin-bottom:8px}.feed-content-types .checkbox-list{background:#fff;border:1px solid #ddd;border-radius:4px;max-height:200px;overflow-y:auto;padding:8px}.feed-content-types .checkbox-list .components-checkbox-control{margin:4px 0}.feed-content-types .checkbox-list .components-checkbox-control:first-child{margin-top:0}.feed-content-types .checkbox-list .components-checkbox-control:last-child{margin-bottom:0}.feed-content-types .select-all-wrapper{border-top:1px solid #ddd;margin-top:8px;padding-top:8px}.feed-content-types .components-checkbox-control__input-container{margin-right:8px}.feed-block{background:#fff;border:1px solid #ddd;padding:20px}.feed-block .feed-block-preview .filter-preview{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}.feed-block .feed-block-preview .filter-preview .content-type-badge{background:#f0f0f0;border-radius:4px;font-size:12px;padding:4px 8px}.feed-block .feed-block-preview .feed-grid-placeholder{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));margin-top:20px}.feed-block .feed-block-preview .feed-grid-placeholder .grid-item-placeholder{aspect-ratio:1;background:#f0f0f0;border-radius:4px}
diff --git a/build/feed/index.js b/build/feed/index.js
index 816bd42..c1546b7 100644
--- a/build/feed/index.js
+++ b/build/feed/index.js
@@ -1 +1 @@
-(()=>{"use strict";var e,t={111:(e,t,n)=>{const l=window.wp.blocks,r=window.wp.i18n,s=window.wp.blockEditor,a=window.wp.components,o=window.wp.element,i=window.wp.apiFetch;var c=n.n(i);const d=window.ReactJSXRuntime;(0,l.registerBlockType)("jvb/feed",{edit:function({attributes:e,setAttributes:t}){const n=(0,s.useBlockProps)(),[l,i]=(0,o.useState)({});return(0,o.useEffect)((()=>{c()({path:"/jvb/v1/types"}).then((e=>{i(JSON.parse(e))}))}),[]),(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(s.InspectorControls,{children:(0,d.jsxs)(a.PanelBody,{title:(0,r.__)("Feed Settings","jvb"),children:[(0,d.jsx)(a.TextControl,{label:(0,r.__)("Title","jvb"),value:e.title,onChange:e=>t({title:e})}),(0,d.jsx)(a.ToggleControl,{label:(0,r.__)("Inherit Current Query","jvb"),help:(0,r.__)("Inherit filters from the current archive or taxonomy query","jvb"),checked:e.inheritQuery,onChange:e=>t({inheritQuery:e})}),!e.inheritQuery&&(0,d.jsxs)("div",{className:"feed-content-types",children:[(0,d.jsx)("p",{className:"components-base-control__label",children:(0,r.__)("Content Types","jvb")}),(0,d.jsx)("div",{className:"checkbox-list",children:Object.entries(l).map((([n,l])=>(0,d.jsx)(a.CheckboxControl,{label:l,checked:e.contentTypes.includes(n),onChange:l=>{const r=l?[...e.contentTypes,n]:e.contentTypes.filter((e=>e!==n));t({contentTypes:r})}},n)))}),(0,d.jsx)("div",{className:"select-all-wrapper",children:(0,d.jsx)(a.CheckboxControl,{label:(0,r.__)("Select All","jvb"),checked:e.contentTypes.length===Object.keys(l).length,onChange:e=>{t({contentTypes:e?Object.keys(l):[]})}})})]}),(0,d.jsx)(a.SelectControl,{label:(0,r.__)("Items Per Page","jvb"),value:e.itemsPerPage,options:[{label:"12",value:12},{label:"24",value:24},{label:"36",value:36}],onChange:e=>t({itemsPerPage:parseInt(e)})}),(0,d.jsx)(a.SelectControl,{label:(0,r.__)("Default Order","jvb"),value:e.defaultOrder,options:[{label:(0,r.__)("Newest First","jvb"),value:"date_desc"},{label:(0,r.__)("Oldest First","jvb"),value:"date_asc"},{label:(0,r.__)("Random","jvb"),value:"random"}],onChange:e=>t({defaultOrder:e})})]})}),(0,d.jsx)("div",{...n,children:(0,d.jsxs)("div",{className:"feed-block-preview",children:[(0,d.jsx)("h2",{children:e.title}),(0,d.jsx)("div",{className:"feed-filters",children:(0,d.jsx)("div",{className:"filter-preview",children:e.contentTypes.map((e=>(0,d.jsx)("span",{className:"content-type-badge",children:l[e]},e)))})}),(0,d.jsx)("div",{className:"feed-grid-placeholder",children:[...Array(6)].map(((e,t)=>(0,d.jsx)("div",{className:"grid-item-placeholder"},t)))})]})})]})},save:function(){return null}})}},n={};function l(e){var r=n[e];if(void 0!==r)return r.exports;var s=n[e]={exports:{}};return t[e](s,s.exports,l),s.exports}l.m=t,e=[],l.O=(t,n,r,s)=>{if(!n){var a=1/0;for(d=0;d<e.length;d++){for(var[n,r,s]=e[d],o=!0,i=0;i<n.length;i++)(!1&s||a>=s)&&Object.keys(l.O).every((e=>l.O[e](n[i])))?n.splice(i--,1):(o=!1,s<a&&(a=s));if(o){e.splice(d--,1);var c=r();void 0!==c&&(t=c)}}return t}s=s||0;for(var d=e.length;d>0&&e[d-1][2]>s;d--)e[d]=e[d-1];e[d]=[n,r,s]},l.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return l.d(t,{a:t}),t},l.d=(e,t)=>{for(var n in t)l.o(t,n)&&!l.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={814:0,138:0};l.O.j=t=>0===e[t];var t=(t,n)=>{var r,s,[a,o,i]=n,c=0;if(a.some((t=>0!==e[t]))){for(r in o)l.o(o,r)&&(l.m[r]=o[r]);if(i)var d=i(l)}for(t&&t(n);c<a.length;c++)s=a[c],l.o(e,s)&&e[s]&&e[s][0](),e[s]=0;return l.O(d)},n=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];n.forEach(t.bind(null,0)),n.push=t.bind(null,n.push.bind(n))})();var r=l.O(void 0,[138],(()=>l(111)));r=l.O(r)})();
\ No newline at end of file
+(()=>{"use strict";var e,t={111:(e,t,n)=>{const o=window.wp.blocks,l=window.wp.element,i=window.wp.blockEditor,r=window.wp.components,s=window.wp.apiFetch;var c=n.n(s);const a=window.wp.i18n,d=window.ReactJSXRuntime;(0,o.registerBlockType)("jvb/feed",{edit:function({attributes:e,setAttributes:t}){const[n,o]=(0,l.useState)(null),[s,h]=(0,l.useState)(!0),[p,j]=(0,l.useState)(null),x=(0,i.useBlockProps)({className:"feed-block-editor"});(0,l.useEffect)((()=>{c()({path:"/jvb/v1/feed/types",headers:{"If-Modified-Since":localStorage.getItem("feed_types_modified")}}).then((n=>{if(o(n),h(!1),!e.contentTypes&&!e.inheritQuery){const e=Object.keys(n)[0];e&&t({contentTypes:[e]})}})).catch((e=>{console.error("Error loading feed types:",e),j(e.message),h(!1)}))}),[e.inheritQuery]);const y=(n,o)=>{const l=e.contentTypes||[],i=o?[...l,n]:l.filter((e=>e!==n));t({contentTypes:i})},b=(e,t)=>`${t.plural} (${t.type})`,g=n?{content:Object.entries(n).filter((([e,t])=>"content"===t.type)),taxonomy:Object.entries(n).filter((([e,t])=>"taxonomy"===t.type))}:{content:[],taxonomy:[]};return(0,d.jsxs)("div",{...x,children:[(0,d.jsxs)(i.InspectorControls,{children:[(0,d.jsxs)(r.PanelBody,{title:(0,a.__)("Feed Settings","jvb"),initialOpen:!0,children:[(0,d.jsx)(r.ToggleControl,{label:(0,a.__)("Inherit from Page Context","jvb"),help:e.inheritQuery?(0,a.__)("Feed will adapt to the current page (profile, taxonomy, etc.)","jvb"):(0,a.__)("Manually select content types to display","jvb"),checked:e.inheritQuery,onChange:e=>t({inheritQuery:e})}),!e.inheritQuery&&(0,d.jsxs)(d.Fragment,{children:[s&&(0,d.jsxs)("div",{style:{textAlign:"center",padding:"20px"},children:[(0,d.jsx)(r.Spinner,{}),(0,d.jsx)("p",{children:(0,a.__)("Loading feed types...","jvb")})]}),p&&(0,d.jsxs)(r.Notice,{status:"error",isDismissible:!1,children:[(0,a.__)("Error loading feed types: ","jvb")," ",p]}),!s&&!p&&n&&(0,d.jsxs)(d.Fragment,{children:[g.content.length>0&&(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)("h4",{children:(0,a.__)("Content Types","jvb")}),g.content.map((([t,n])=>(0,d.jsx)(r.CheckboxControl,{label:b(0,n),checked:e.contentTypes?.includes(t)||!1,onChange:e=>y(t,e),help:n.taxonomies?.length>0?`Filters: ${n.taxonomies.join(", ")}`:null},t)))]}),g.taxonomy.length>0&&(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)("h4",{style:{marginTop:"20px"},children:(0,a.__)("Content Taxonomies","jvb")}),(0,d.jsx)("p",{style:{fontSize:"12px",color:"#757575"},children:(0,a.__)("These are collections that group other content","jvb")}),g.taxonomy.map((([t,n])=>(0,d.jsx)(r.CheckboxControl,{label:b(0,n),checked:e.contentTypes?.includes(t)||!1,onChange:e=>y(t,e),help:n.for_content?.length>0?`Contains: ${n.for_content.join(", ")}`:null},t)))]}),!e.contentTypes?.length&&(0,d.jsx)(r.Notice,{status:"warning",isDismissible:!1,children:(0,a.__)("Please select at least one content type","jvb")})]})]})]}),(0,d.jsx)(r.PanelBody,{title:(0,a.__)("Display Settings","jvb"),initialOpen:!1,children:(0,d.jsx)(r.ToggleControl,{label:(0,a.__)("Show Gallery View","jvb"),help:(0,a.__)("Enable lightbox for images","jvb"),checked:e.enableGallery||!1,onChange:e=>t({enableGallery:e})})})]}),(0,d.jsxs)("div",{className:"feed-block-placeholder",children:[(0,d.jsx)("div",{className:"feed-block-icon",children:(0,d.jsxs)("svg",{width:"48",height:"48",viewBox:"0 0 24 24",fill:"none",children:[(0,d.jsx)("rect",{x:"3",y:"3",width:"7",height:"7",fill:"currentColor",opacity:"0.3"}),(0,d.jsx)("rect",{x:"13",y:"3",width:"7",height:"7",fill:"currentColor",opacity:"0.3"}),(0,d.jsx)("rect",{x:"3",y:"13",width:"7",height:"7",fill:"currentColor",opacity:"0.3"}),(0,d.jsx)("rect",{x:"13",y:"13",width:"7",height:"7",fill:"currentColor",opacity:"0.3"})]})}),(0,d.jsx)("h3",{children:(0,a.__)("Feed Block","jvb")}),e.inheritQuery?(0,d.jsx)("p",{className:"feed-block-description",children:(0,a.__)("📍 Inheriting from page context","jvb")}):(0,d.jsx)("div",{className:"feed-block-description",children:e.contentTypes?.length>0?(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)("p",{children:(0,d.jsx)("strong",{children:(0,a.__)("Showing:","jvb")})}),(0,d.jsx)("ul",{style:{listStyle:"none",padding:"0",margin:"8px 0"},children:e.contentTypes.map((e=>{const t=n?.[e];return(0,d.jsxs)("li",{style:{padding:"4px 0",color:"#2271b1"},children:["✓ ",t?.plural||e]},e)}))})]}):(0,d.jsx)("p",{style:{color:"#d63638"},children:(0,a.__)("⚠️ No content types selected","jvb")})}),(0,d.jsx)("p",{className:"feed-block-note",children:(0,a.__)("Feed will be displayed on the frontend","jvb")})]})]})},save:function(){return null}})}},n={};function o(e){var l=n[e];if(void 0!==l)return l.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,o),i.exports}o.m=t,e=[],o.O=(t,n,l,i)=>{if(!n){var r=1/0;for(d=0;d<e.length;d++){for(var[n,l,i]=e[d],s=!0,c=0;c<n.length;c++)(!1&i||r>=i)&&Object.keys(o.O).every((e=>o.O[e](n[c])))?n.splice(c--,1):(s=!1,i<r&&(r=i));if(s){e.splice(d--,1);var a=l();void 0!==a&&(t=a)}}return t}i=i||0;for(var d=e.length;d>0&&e[d-1][2]>i;d--)e[d]=e[d-1];e[d]=[n,l,i]},o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var n in t)o.o(t,n)&&!o.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={814:0,138:0};o.O.j=t=>0===e[t];var t=(t,n)=>{var l,i,[r,s,c]=n,a=0;if(r.some((t=>0!==e[t]))){for(l in s)o.o(s,l)&&(o.m[l]=s[l]);if(c)var d=c(o)}for(t&&t(n);a<r.length;a++)i=r[a],o.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return o.O(d)},n=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];n.forEach(t.bind(null,0)),n.push=t.bind(null,n.push.bind(n))})();var l=o.O(void 0,[138],(()=>o(111)));l=o.O(l)})();
\ No newline at end of file
diff --git a/build/feed/render.php b/build/feed/render.php
deleted file mode 100644
index 7af8d29..0000000
--- a/build/feed/render.php
+++ /dev/null
@@ -1,252 +0,0 @@
-<?php
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-/**
- * Feed block render callback
- *
- * @param array $attributes Block attributes.
- * @param string $content Block content.
- * @param WP_Block $block Block instance.
- * @return string Rendered block type output.
- */
-function jvbRenderFeedBlock($attributes, $content = '', $block = null)
-{
-
- ob_start();
- // Get block settings
- global $jvb_taxonomy_for;
- global $jvb_everything;
- $context = '';
-
- $taxonomies = [];
-
- foreach ($jvb_taxonomy_for as $tax => $for) {
- if (array_intersect($for, $attributes['contentTypes'])) {
- $taxonomies[] = $tax;
- }
- }
-
- $settings = [
- 'content' => $attributes['contentTypes'][0] ?? 'tattoo',
- 'contentTypes' => $attributes['contentTypes'],
- 'taxonomies' => $taxonomies,
- 'defaultOrder' => $attributes['defaultOrder'],
- 'itemsPerPage' => $attributes['itemsPerPage'],
- ];
-
- $isGallery = false;
- if ($attributes['inheritQuery']) {
- $obj = get_queried_object();
- $taxonomies = $contentTypes = [];
- if (is_singular()) {
- switch ($obj->post_type) {
- case BASE.'artist':
- case BASE.'partner':
- $contentTypes = jvbGetUserContentTypes(get_the_ID());
- break;
- }
-
- if ($obj->post_type == BASE.'artist') {
- $isGallery = true;
- $context = 'artist';
- $name = get_post_meta($obj->id, 'jvb_first_name', true);
- $feed_data['title'] = ($name== '') ? $obj->post_title : $name;
- } elseif ($obj->post_type == BASE.'partner') {
- $context = 'partner';
- $feed_data['title'] = 'Offers:';
- }
- $type = 'post';
- } elseif (is_tax('jvb_shop')) {
- $context = 'shop';
- $feed_data['title'] = 'At the Shop:';
- $contentTypes = [
- 'artist',
- 'tattoo',
- 'piercing',
-// 'event'
- ];
- $type = 'term';
- } elseif (is_tax()) {
- $context = str_replace(BASE, '', $obj->taxonomy);
-
- foreach ($jvb_taxonomy_for as $tax => $for) {
- if ($context === $tax) {
- $contentTypes = $for['types'];
- }
- }
- $type = 'term';
- $context = 'taxonomy:'.$context;
- }
- jvbDump($settings);
- jvbDump($contentTypes);
- if (empty($contentTypes)) {
- return;
- }
- foreach ($contentTypes as $index => $type) {
- $contentTypes[$index] = str_replace(BASE, '', $type);
- }
- foreach ($jvb_taxonomy_for as $tax => $for) {
- if (array_intersect($for, $contentTypes)) {
- $taxonomies[] = $tax;
- }
- }
-
- $feed_data = [
- 'content' => $contentTypes[0],
- 'contentTypes' => $contentTypes,
- 'taxonomies' => $taxonomies,
- 'context' => $context,
- ];
- if ($isGallery) {
- $feed_data['isGallery'] = true;
- }
-
- $settings = array_merge($settings, $feed_data);
- $title = !array_key_exists('title', $settings) ? '' : '<h2 id="work">'.$feed_data['title'].'</h2>';
-
- } else {
- $title = $attributes['title'] ? "<h2>{$attributes['title']}</h2>" : '';
- }
-
- $many = count($settings['contentTypes']) > 1;
- $work = is_singular(BASE.'artist') ? ' id="work"' : '';
-
- $source = get_queried_object_id();
- global $jvb_feed;
-
- if (empty($settings['contentTypes'])) {
- return;
- }
- ?>
-
- <section <?= $work ?> class="feed-block"
- data-source="<?= get_queried_object_id(); ?>"<?= ($context !== '') ? '
- data-context="'.$context.'"' : '' ?><?= ($isGallery) ? ' data-gallery="true"' : ''?>>
- <?= $title ?>
-
- <form class="feed-filters">
- <?= (!$many) ? '' : '<details>' ?>
- <?php if ($many) : ?>
- <summary class="row btw">
- <div class="type-filter label">
- <span>SHOW:</span>
- </div>
- <?php
- foreach ($settings['contentTypes'] as $i => $type) :
- $checked = $i === 0 ? ' checked' : '';
-
- $label = $jvb_everything[$type]['plural'];
- ?>
- <div class="type-filter">
- <input type="radio"
- id="filter-<?= esc_attr($type) ?>"
- class="btn"
- name="content"
- value="<?= esc_attr($type) ?>"
- <?= $checked ?>>
- <label for="filter-<?= esc_attr($type) ?>" title="Show <?= $label ?>">
- <?= jvbIcon($type, ['title'=> $label]) ?>
- <span class="label"><?= $label ?></span>
- </label>
- </div>
- <?php endforeach; ?>
- <?php endif; ?>
-
- <?php if (is_user_logged_in()) : ?>
- <div class="type-filter favourites-toggle">
- <input type="checkbox" id="favourites_only" class="btn" name="favourites_only">
- <label for="favourites_only" title="Show Favourites">
- <?= jvbIcon('heart', ['title' =>'Favourites']) ?>
- <span class="label">Favourites</span>
- </label>
- </div>
- <?php endif; ?>
-
- <?php if ($many) {
- echo '</summary>';
- } ?>
-
- <div class="filters">
- <div class="filter-group">
- <div class="type-filter label">
- <span>FILTER:</span>
- </div>
-
- <?php foreach ($jvb_taxonomy_for as $tax => $items) :
- $label = $jvb_everything[$tax]['plural'];
- $hidden = !in_array($tax, $settings['taxonomies']) ? ' hidden' : '';
-
- $tax = new JVBase\Forms\TaxonomySelector(
- $tax,
- BASE.$tax,
- [
- 'label' => $label,
- 'types' => $items,
- 'hidden'=> $hidden
- ]
- );
- echo $tax->renderFeed();
- endforeach; ?>
- </div>
- <div class="selected-items-section">
- <div class="selected-items"></div>
- <div class="filter-actions">
- <?= jvbRenderToggleTextField('match', 'Match', 'Filters', 'ALL', 'ANY', true) ?>
- <button type="button" class="clear-filters row" hidden>
- <?= jvbIcon('x', ['title' => 'Clear']) ?>
- Clear All Filters
- </button>
- </div>
- </div>
- </div>
-
- <div class="order-options">
- <div class="order-by">
- <span class="type-filter label">ORDER:</span>
- <div class="radio-group-label">
- <input type="radio" id="order-title" class="btn" name="orderby" value="title" data-for="artist,shop" hidden>
- <label for="order-title" title="Order by Name">
- <?= jvbIcon('alphabetical') ?>
- <span class="label">Name</span>
- </label>
-
- <input type="radio" id="order-date" class="btn" name="orderby" value="date" checked>
- <label for="order-date" title="Order by Date">
- <?= jvbIcon('calendar', ['title'=>'Date']) ?>
- <span class="label">Date</span>
- </label>
-
- <input type="radio" id="order-random" class="btn" name="orderby" value="random">
- <label for="order-random" title="Random Order">
- <?= jvbIcon('shuffle') ?>
- <span class="label">Random</span>
- </label>
- </div>
- </div>
-
- <div class="order-direction radio-group-label" data-for-order="date,title">
- <input type="radio" id="order-desc" class="btn" name="order" value="desc" checked>
- <label for="order-desc" title="Newest First">
- <?= jvbIcon('sort-descending') ?>
- </label>
-
- <input type="radio" id="order-asc" class="btn" name="order" value="asc">
- <label for="order-asc" title="Oldest First">
- <?= jvbIcon('sort-ascending') ?>
- </label>
- </div>
- </div>
- <?= (!$many) ? '' : '</details>' ?>
- </form>
-
- <div class="feed-grid"></div>
-
-
- </section>
- <?= jvbGetFeedItemTemplate(); ?>
- <?= jvbGetFeedArtistTemplate(); ?>
- <?php
- return ob_get_clean();
-}
diff --git a/build/feed/style-index-rtl.css b/build/feed/style-index-rtl.css
index 6e3c414..425fcc7 100644
--- a/build/feed/style-index-rtl.css
+++ b/build/feed/style-index-rtl.css
@@ -1 +1 @@
-.feed-block{margin:0 auto;max-width:var(--full)}.feed-block:target{scroll-snap-margin-top:5rem;border-radius:v0;outline:0;padding:0;scroll-margin-top:5rem}.feed-block:target .feed-item{outline:double var(--pink-0)}.loading .feed-block{opacity:.7}.label{align-items:center;display:flex;font-size:.9rem;gap:.25rem}.label a:hover{color:var(--pink-0)}.feed-filters{background:var(--overlay-heavy);margin:2rem 0;padding:.25rem 3rem;position:sticky;top:3rem;z-index:15}.feed-filters details[open] summary{background-color:var(--overlay)}.feed-filters summary{justify-content:flex-start}.feed-filters summary>*{order:3}.feed-filters summary .label{order:1}.feed-filters summary .filter-label{order:2}.feed-filters summary:after{order:4}.feed-filters summary #favourites+label{margin-right:auto}.feed-filters summary #favourites+label:hover,.feed-filters summary #favourites:checked+label{background-color:var(--pink-0);border-color:var(--pink-0);color:var(--white)}.feed-filters summary #favourites:checked+label{animation:pop .4s cubic-bezier(.25,.46,.45,.94)}.feed-filters details[open],.feed-filters summary:hover{background-color:var(--overlay-heavy)}.feed-filters:has(#favourites) summary:after{margin-right:1rem}summary>*{order:3}summary .label{order:1}.filter-label{display:inline-block;height:1.3em;margin:0;order:2;padding:0;vertical-align:middle}.filter-label li{height:0;list-style:none;overflow:hidden}.filter-label li.active{height:100%}.filter-group{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.filter-group label{font-weight:100}.filter-group:has(.order-by){justify-content:space-between}.filter-group .order-by,.filter-group .order-direction{display:flex;flex:1;flex-wrap:wrap;gap:.5rem;justify-content:flex-start}.filter-group .order-by .label,.filter-group .order-direction .label{width:100%}.filter-group .order-by label:has(.label),.filter-group .order-direction label:has(.label){gap:.5rem;padding:0 .35rem}.filter-group .order-by label:has(.label) .label,.filter-group .order-direction label:has(.label) .label{font-size:1rem}.filter-group>.label{width:100%}.feed-grid{display:grid;gap:.5rem;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));margin-bottom:2rem;padding:0 4rem;--delay:0s;--increase:.1s}.feed-empty-state{background:var(--base-100);border-radius:var(--innerRadius);grid-column:1/-1;margin:0 auto;max-width:600px;padding:2rem;text-align:center}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder .icon{--w:50%;color:var(--base-200)}.placeholder .icon svg{animation:dance 2.5s ease-in-out infinite}.feed-item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;opacity:0;overflow:hidden;padding:0;position:relative;transition:opacity var(--transition-base) var(--delay)}.feed-item img{filter:grayscale(.5) sepia(.3) blur(7px);opacity:.7}.feed-item[data-loaded=true] img{filter:none;opacity:1}.feed-item a:after,.feed-item a:before{display:none}.feed-item details a{font-size:clamp(1rem,.9306rem + .2222vw,1.125rem)}.feed-item[data-loaded]{opacity:1}.feed-item[data-loaded]+[data-loaded]{--delay:var(--delay) + var(--increase)}.feed-item.highlighted{animation:highlight-puls 2s ease-in-out;box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}.feed-item:hover .handle,.feed-item[open] .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-pink-medium)}.feed-item summary{aspect-ratio:1;height:100%;width:calc(100% - 1rem)}.feed-item summary .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-light);border-radius:var(--innerRadius);bottom:0;right:0;padding:.25rem 1.1rem .25rem .25rem;position:absolute;left:0;z-index:1}.feed-item summary:after{bottom:.35rem;cursor:pointer;height:1.5rem;position:absolute;left:.7rem;width:1.5rem;z-index:11}.feed-item label{font-weight:400;text-transform:none}.feed-item label .icon{--w:1.5em}.load-more{align-items:center;background:var(--base);border:2px solid transparent;border-radius:4px;color:var(--contrast);display:flex;font-size:var(--medium);gap:.5rem;margin:1rem auto;opacity:1;padding:.75rem 1.5rem;width:66.666%}.load-more,.load-more[hidden]{transition:all var(--transition-base)}.load-more[hidden]{opacity:0}.load-more:hover{background:var(--pink-50);border-color:var(--contrast);color:var(--white)}button.favourite{align-items:center;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background:var(--overlay-medium);border:none;border-radius:50%;box-shadow:var(--subtle);display:flex;height:2rem;justify-content:center;position:absolute;left:.5rem;top:.5rem;transition:all var(--transition-base);width:2rem;z-index:10}button.favourite:hover{background:var(--base);box-shadow:0 4px 8px rgba(0,0,0,.15);color:var(--pink-0);transform:scale(1.1)}button.favourite.favourited{animation:pop .4s cubic-bezier(.25,.46,.45,.94)}.feed-image{aspect-ratio:1;display:block;overflow:hidden}.feed-image,.feed-images{height:100%;width:100%}.feed-images.multi{display:grid;gap:4px;grid-auto-rows:1fr;grid-template-columns:repeat(3,1fr)}.feed-images.multi>a{aspect-ratio:1;height:100%;width:100%}.feed-images.multi .feed-image{grid-column:span 2;grid-row:span 2}.feed-images img{height:100%;-o-object-fit:cover;object-fit:cover;transition:transform var(--timing) var(--function);width:100%}.feed-images a:hover img{transform:scale(1.05)}.feed-item:nth-of-type(4n+2) .multi .feed-image{grid-column:2/span 2;grid-row:1/span 2}.feed-item:nth-of-type(4n+3) .multi .feed-image{grid-column:1/span 2;grid-row:2/span 2}.feed-item:nth-of-type(4n+4) .multi .feed-image{grid-column:2/span 2;grid-row:2/span 2}.item-info{border-right:1px solid var(--base-200);padding:.25rem}.item-info>div+div{margin-top:.5em;position:relative}.item-info>div+div:before{border-bottom:1px solid var(--base-200);content:"";display:block;right:-.25rem;position:absolute;top:-.3em;width:66.6%}.item-info h3{font-family:var(--body);font-size:1.1rem;font-weight:var(--bWeight);margin:0 0 .5em!important}.item-info span{align-items:center;display:flex;text-transform:uppercase}.item-info .icon{--w:1.1em;display:inline-block;margin-left:.5em;vertical-align:middle}.item-list ul{display:flex;flex-wrap:wrap;gap:.5rem;margin:0;padding:.5em 0}.item-list ul li{list-style:none}.item-list a{background-color:var(--pink-0);border:1px solid transparent;border-radius:4px;color:var(--light-0);line-height:.8;padding:.25em}.item-list a:visited{background-color:var(--pink-100);color:var(--white)}.item-list a:focus,.item-list a:hover,.item-list a:visited:focus,.item-list a:visited:hover{background-color:transparent;border-color:var(--contrast);color:var(--contrast)}.loading{opacity:.7}.loading-overlay{align-items:center;background-color:var(--overlay-medium);bottom:0;display:flex;justify-content:center;right:0;opacity:0;position:fixed;left:0;top:0;transition:opacity .3s ease,visibility .3s ease;visibility:hidden;z-index:9999}.loading .loading-overlay{opacity:1;visibility:visible}.loading .loading-overlay:after{animation:shimmer 3s ease-in-out infinite;background:linear-gradient(-90deg,var(--shimmer));content:"";inset:0;position:absolute;z-index:-1}.loading .loading-overlay .wrapper{align-items:center;background-color:var(--overlay-heavy);border-radius:var(--innerRadius);display:flex;height:300px;justify-content:center;max-width:90%;padding:2rem;position:relative;text-align:center;width:400px;z-index:5}.loading .loading-overlay .wrapper .spinner{--h:150px;--w:calc(var(--h)*2);animation:spin 1s var(--timing) infinite;border-radius:50%;border-top:5px solid var(--pink-0);height:var(--w);right:calc(50% - var(--h));opacity:.5;position:absolute;top:calc(50% - var(--h));width:var(--w);z-index:0}.loading .loading-overlay .wrapper div.icon{height:50px;width:50px}.loading .loading-overlay .wrapper div.icon .icon{--w:100%}.loading .loading-overlay .wrapper div.icon .icon svg{animation:dance 2s ease-in-out infinite;transition:color .3s ease}.loading .loading-overlay .wrapper .status{align-items:center;display:flex;flex-direction:column;height:200px;width:100%;z-index:5}.loading .loading-overlay .wrapper .status h3{color:var(--contrast-200);margin:1.5rem 0 .25rem!important}.loading .loading-overlay .wrapper .status .message{animation:flicker 2s infinite;color:var(--contrast-100);font-size:var(--small);margin:0;max-width:275px}@keyframes highlight{0%,to{box-shadow:none}50%{box-shadow:0 0 0 4px var(--pink-0)}}@keyframes pop{0%{transform:scale(1)}50%{transform:scale(1.3)}75%{transform:scale(.9)}to{transform:scale(1)}}@keyframes bubble{50%{box-shadow:-19px 0 0 3px,-38px 0 0 7px,-57px 0 0 3px}to{box-shadow:-19px 0 0 0,-38px 0 0 3px,-57px 0 0 7px}}@keyframes highlight-pulse{0%,to{box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}50%{box-shadow:0 0 0 8px #ff0080,0 12px 24px rgba(0,0,0,.15)}}@keyframes spin{to{transform:rotate(-1turn)}}@keyframes shimmer{0%{transform:translateX(100%)}50%,to{transform:translateX(-100%)}}@keyframes dance{0%,to{transform:rotate(5deg) scale(1)}50%{transform:rotate(-5deg) scale(1.1)}}@keyframes flicker{0%{opacity:.6}50%{opacity:1}to{opacity:.6}}.feed-item[tabindex="0"]{position:relative}.feed-item[tabindex="0"]:after{border:2px solid transparent;bottom:0;content:"";right:0;pointer-events:none;position:absolute;left:0;top:0;transition:border-color .2s ease}.feed-item[tabindex="0"]:focus:after{border-color:#ff0080}
+.feed-block .feed-filters{padding:1rem 0}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{right:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--bWeight);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item img{filter:grayscale(.5) sepia(.3) blur(7px);opacity:.7;transition:opacity var(--transition-base),filter var(--transition-base)}.feed.item img[data-loaded=true]{filter:none;opacity:1}.feed.item[data-timeline]{aspect-ratio:unset}.feed.item[data-timeline] summary{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] summary span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] summary span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] summary span:last-of-type{right:50%;top:0}.feed.item[data-timeline] summary>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-left:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item details a{font-size:clamp(1rem,.9306rem + .2222vw,1.125rem)}.feed.item.highlighted{animation:highlight-puls 2s ease-in-out;box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}.feed.item:hover .handle,.feed.item[open] .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-pink-medium)}.feed.item summary{aspect-ratio:1;height:100%;width:calc(100% - 1rem)}.feed.item summary .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-light);border-radius:var(--innerRadius);bottom:0;right:0;padding:.25rem 1.1rem .25rem .25rem;position:absolute;left:0;z-index:1}.feed.item summary:after{bottom:.35rem;cursor:pointer;height:1.5rem;position:absolute;left:.7rem;width:1.5rem;z-index:11}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}
diff --git a/build/feed/style-index.css b/build/feed/style-index.css
index 882d833..4ef7862 100644
--- a/build/feed/style-index.css
+++ b/build/feed/style-index.css
@@ -1 +1 @@
-.feed-block{margin:0 auto;max-width:var(--full)}.feed-block:target{scroll-snap-margin-top:5rem;border-radius:v0;outline:0;padding:0;scroll-margin-top:5rem}.feed-block:target .feed-item{outline:double var(--pink-0)}.loading .feed-block{opacity:.7}.label{align-items:center;display:flex;font-size:.9rem;gap:.25rem}.label a:hover{color:var(--pink-0)}.feed-filters{background:var(--overlay-heavy);margin:2rem 0;padding:.25rem 3rem;position:sticky;top:3rem;z-index:15}.feed-filters details[open] summary{background-color:var(--overlay)}.feed-filters summary{justify-content:flex-start}.feed-filters summary>*{order:3}.feed-filters summary .label{order:1}.feed-filters summary .filter-label{order:2}.feed-filters summary:after{order:4}.feed-filters summary #favourites+label{margin-left:auto}.feed-filters summary #favourites+label:hover,.feed-filters summary #favourites:checked+label{background-color:var(--pink-0);border-color:var(--pink-0);color:var(--white)}.feed-filters summary #favourites:checked+label{animation:pop .4s cubic-bezier(.25,.46,.45,.94)}.feed-filters details[open],.feed-filters summary:hover{background-color:var(--overlay-heavy)}.feed-filters:has(#favourites) summary:after{margin-left:1rem}summary>*{order:3}summary .label{order:1}.filter-label{display:inline-block;height:1.3em;margin:0;order:2;padding:0;vertical-align:middle}.filter-label li{height:0;list-style:none;overflow:hidden}.filter-label li.active{height:100%}.filter-group{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.filter-group label{font-weight:100}.filter-group:has(.order-by){justify-content:space-between}.filter-group .order-by,.filter-group .order-direction{display:flex;flex:1;flex-wrap:wrap;gap:.5rem;justify-content:flex-start}.filter-group .order-by .label,.filter-group .order-direction .label{width:100%}.filter-group .order-by label:has(.label),.filter-group .order-direction label:has(.label){gap:.5rem;padding:0 .35rem}.filter-group .order-by label:has(.label) .label,.filter-group .order-direction label:has(.label) .label{font-size:1rem}.filter-group>.label{width:100%}.feed-grid{display:grid;gap:.5rem;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));margin-bottom:2rem;padding:0 4rem;--delay:0s;--increase:.1s}.feed-empty-state{background:var(--base-100);border-radius:var(--innerRadius);grid-column:1/-1;margin:0 auto;max-width:600px;padding:2rem;text-align:center}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder .icon{--w:50%;color:var(--base-200)}.placeholder .icon svg{animation:dance 2.5s ease-in-out infinite}.feed-item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;opacity:0;overflow:hidden;padding:0;position:relative;transition:opacity var(--transition-base) var(--delay)}.feed-item img{filter:grayscale(.5) sepia(.3) blur(7px);opacity:.7}.feed-item[data-loaded=true] img{filter:none;opacity:1}.feed-item a:after,.feed-item a:before{display:none}.feed-item details a{font-size:clamp(1rem,.9306rem + .2222vw,1.125rem)}.feed-item[data-loaded]{opacity:1}.feed-item[data-loaded]+[data-loaded]{--delay:var(--delay) + var(--increase)}.feed-item.highlighted{animation:highlight-puls 2s ease-in-out;box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}.feed-item:hover .handle,.feed-item[open] .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-pink-medium)}.feed-item summary{aspect-ratio:1;height:100%;width:calc(100% - 1rem)}.feed-item summary .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-light);border-radius:var(--innerRadius);bottom:0;left:0;padding:.25rem .25rem .25rem 1.1rem;position:absolute;right:0;z-index:1}.feed-item summary:after{bottom:.35rem;cursor:pointer;height:1.5rem;position:absolute;right:.7rem;width:1.5rem;z-index:11}.feed-item label{font-weight:400;text-transform:none}.feed-item label .icon{--w:1.5em}.load-more{align-items:center;background:var(--base);border:2px solid transparent;border-radius:4px;color:var(--contrast);display:flex;font-size:var(--medium);gap:.5rem;margin:1rem auto;opacity:1;padding:.75rem 1.5rem;width:66.666%}.load-more,.load-more[hidden]{transition:all var(--transition-base)}.load-more[hidden]{opacity:0}.load-more:hover{background:var(--pink-50);border-color:var(--contrast);color:var(--white)}button.favourite{align-items:center;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background:var(--overlay-medium);border:none;border-radius:50%;box-shadow:var(--subtle);display:flex;height:2rem;justify-content:center;position:absolute;right:.5rem;top:.5rem;transition:all var(--transition-base);width:2rem;z-index:10}button.favourite:hover{background:var(--base);box-shadow:0 4px 8px rgba(0,0,0,.15);color:var(--pink-0);transform:scale(1.1)}button.favourite.favourited{animation:pop .4s cubic-bezier(.25,.46,.45,.94)}.feed-image{aspect-ratio:1;display:block;overflow:hidden}.feed-image,.feed-images{height:100%;width:100%}.feed-images.multi{display:grid;gap:4px;grid-auto-rows:1fr;grid-template-columns:repeat(3,1fr)}.feed-images.multi>a{aspect-ratio:1;height:100%;width:100%}.feed-images.multi .feed-image{grid-column:span 2;grid-row:span 2}.feed-images img{height:100%;-o-object-fit:cover;object-fit:cover;transition:transform var(--timing) var(--function);width:100%}.feed-images a:hover img{transform:scale(1.05)}.feed-item:nth-of-type(4n+2) .multi .feed-image{grid-column:2/span 2;grid-row:1/span 2}.feed-item:nth-of-type(4n+3) .multi .feed-image{grid-column:1/span 2;grid-row:2/span 2}.feed-item:nth-of-type(4n+4) .multi .feed-image{grid-column:2/span 2;grid-row:2/span 2}.item-info{border-left:1px solid var(--base-200);padding:.25rem}.item-info>div+div{margin-top:.5em;position:relative}.item-info>div+div:before{border-bottom:1px solid var(--base-200);content:"";display:block;left:-.25rem;position:absolute;top:-.3em;width:66.6%}.item-info h3{font-family:var(--body);font-size:1.1rem;font-weight:var(--bWeight);margin:0 0 .5em!important}.item-info span{align-items:center;display:flex;text-transform:uppercase}.item-info .icon{--w:1.1em;display:inline-block;margin-right:.5em;vertical-align:middle}.item-list ul{display:flex;flex-wrap:wrap;gap:.5rem;margin:0;padding:.5em 0}.item-list ul li{list-style:none}.item-list a{background-color:var(--pink-0);border:1px solid transparent;border-radius:4px;color:var(--light-0);line-height:.8;padding:.25em}.item-list a:visited{background-color:var(--pink-100);color:var(--white)}.item-list a:focus,.item-list a:hover,.item-list a:visited:focus,.item-list a:visited:hover{background-color:transparent;border-color:var(--contrast);color:var(--contrast)}.loading{opacity:.7}.loading-overlay{align-items:center;background-color:var(--overlay-medium);bottom:0;display:flex;justify-content:center;left:0;opacity:0;position:fixed;right:0;top:0;transition:opacity .3s ease,visibility .3s ease;visibility:hidden;z-index:9999}.loading .loading-overlay{opacity:1;visibility:visible}.loading .loading-overlay:after{animation:shimmer 3s ease-in-out infinite;background:linear-gradient(90deg,var(--shimmer));content:"";inset:0;position:absolute;z-index:-1}.loading .loading-overlay .wrapper{align-items:center;background-color:var(--overlay-heavy);border-radius:var(--innerRadius);display:flex;height:300px;justify-content:center;max-width:90%;padding:2rem;position:relative;text-align:center;width:400px;z-index:5}.loading .loading-overlay .wrapper .spinner{--h:150px;--w:calc(var(--h)*2);animation:spin 1s var(--timing) infinite;border-radius:50%;border-top:5px solid var(--pink-0);height:var(--w);left:calc(50% - var(--h));opacity:.5;position:absolute;top:calc(50% - var(--h));width:var(--w);z-index:0}.loading .loading-overlay .wrapper div.icon{height:50px;width:50px}.loading .loading-overlay .wrapper div.icon .icon{--w:100%}.loading .loading-overlay .wrapper div.icon .icon svg{animation:dance 2s ease-in-out infinite;transition:color .3s ease}.loading .loading-overlay .wrapper .status{align-items:center;display:flex;flex-direction:column;height:200px;width:100%;z-index:5}.loading .loading-overlay .wrapper .status h3{color:var(--contrast-200);margin:1.5rem 0 .25rem!important}.loading .loading-overlay .wrapper .status .message{animation:flicker 2s infinite;color:var(--contrast-100);font-size:var(--small);margin:0;max-width:275px}@keyframes highlight{0%,to{box-shadow:none}50%{box-shadow:0 0 0 4px var(--pink-0)}}@keyframes pop{0%{transform:scale(1)}50%{transform:scale(1.3)}75%{transform:scale(.9)}to{transform:scale(1)}}@keyframes bubble{50%{box-shadow:19px 0 0 3px,38px 0 0 7px,57px 0 0 3px}to{box-shadow:19px 0 0 0,38px 0 0 3px,57px 0 0 7px}}@keyframes highlight-pulse{0%,to{box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}50%{box-shadow:0 0 0 8px #ff0080,0 12px 24px rgba(0,0,0,.15)}}@keyframes spin{to{transform:rotate(1turn)}}@keyframes shimmer{0%{transform:translateX(-100%)}50%,to{transform:translateX(100%)}}@keyframes dance{0%,to{transform:rotate(-5deg) scale(1)}50%{transform:rotate(5deg) scale(1.1)}}@keyframes flicker{0%{opacity:.6}50%{opacity:1}to{opacity:.6}}.feed-item[tabindex="0"]{position:relative}.feed-item[tabindex="0"]:after{border:2px solid transparent;bottom:0;content:"";left:0;pointer-events:none;position:absolute;right:0;top:0;transition:border-color .2s ease}.feed-item[tabindex="0"]:focus:after{border-color:#ff0080}
+.feed-block .feed-filters{padding:1rem 0}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{left:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--bWeight);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item img{filter:grayscale(.5) sepia(.3) blur(7px);opacity:.7;transition:opacity var(--transition-base),filter var(--transition-base)}.feed.item img[data-loaded=true]{filter:none;opacity:1}.feed.item[data-timeline]{aspect-ratio:unset}.feed.item[data-timeline] summary{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] summary span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] summary span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] summary span:last-of-type{left:50%;top:0}.feed.item[data-timeline] summary>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-right:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item details a{font-size:clamp(1rem,.9306rem + .2222vw,1.125rem)}.feed.item.highlighted{animation:highlight-puls 2s ease-in-out;box-shadow:0 0 0 4px #ff0080,0 8px 16px rgba(0,0,0,.1)}.feed.item:hover .handle,.feed.item[open] .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-pink-medium)}.feed.item summary{aspect-ratio:1;height:100%;width:calc(100% - 1rem)}.feed.item summary .handle{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background-color:var(--overlay-light);border-radius:var(--innerRadius);bottom:0;left:0;padding:.25rem .25rem .25rem 1.1rem;position:absolute;right:0;z-index:1}.feed.item summary:after{bottom:.35rem;cursor:pointer;height:1.5rem;position:absolute;right:.7rem;width:1.5rem;z-index:11}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index 7427c60..2aa4bed 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => 'db2e1dcc97363f747c51');
+<?php return array('dependencies' => array(), 'version' => '40e69d90292156d1e263');
diff --git a/build/feed/view.js b/build/feed/view.js
index 79d9a63..de6b42c 100644
--- a/build/feed/view.js
+++ b/build/feed/view.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.cache=window.jvbCache,this.a11y=window.jvbA11y,this.loading=window.jvbLoading,this.error=window.jvbError,this.container=document.querySelector("section.feed-block"),this.container&&(this.openGallery=!1,this.initElements(),this.addPlaceholders(),this.config={api:feedSettings.apiUrl,nonce:feedSettings.nonce,user:jvbSettings.currentUser||null,source:"",context:"",highlight:null,gallery:!1,showAuthor:!0,showDate:!1,view:localStorage.getItem("feedViewMode")||"grid",...this.container.dataset},this.taxonomies={},this.rendered={},this.feed={imageLoadThreshold:5,lazyLoadOffset:"100px",gallery:[],loaded:0,intsersectionObserver:null,templates:new Map},this.isLoading=!1,this.hasMore=!0,this.retries={count:0,max:3,delay:1e3},this.page=1,this.order="DESC",this.orderby="date",this.gallery=!!this.config.gallery&&new window.jvbGallery(document.querySelector("dialog.gallery"),{imageWrapper:".item",loadMore:()=>this.fetchFeed.bind(this)}),this.initListeners(),1===this.page?this.processURLFilters():this.updateFilters())}initElements(){this.filterSelector="form.feed-filters",this.filterForm=this.container.querySelector(this.filterSelector),this.grid=this.container.querySelector(".item-grid"),this.loadMore=this.container.querySelector(".load-more"),this.filterControls=this.container.querySelector(".filter-actions"),this.contentTypes=Array.from(this.filterForm.querySelectorAll('input[name="content"]')).map((e=>e.value)),this.selectedTerms=this.container.querySelector(".selected-items-section .selected-items")}initListeners(){window.addEventListener("popstate",this.handlePopState.bind(this)),document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),"IntersectionObserver"in window&&(this.imageObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&(this.loadImage(e.target),this.imageObserver.unobserve(e.target))}))}),{rootMargin:"100px",threshold:.1})),"ResizeObserver"in window?(this.resizeObserver=new ResizeObserver(window.debounce((()=>{this.updateImageSizes()}),250)),this.resizeObserver.observe(this.container)):window.addEventListener("resize",window.debounce((()=>{this.updateImageSizes()}),250)),this.taxonomies={},this.container.querySelectorAll(".jvb-selector:not([hidden])").forEach((e=>{let t=e.dataset.taxonomy;Object.hasOwn(this.taxonomies,t)||(this.taxonomies[t]=new window.jvbTaxonomySelector(e,{multiple:!0,feed:!0,selected:{},onClose:()=>this.setSelectedTerms(t)}))}))}handlePopState(e){e.state&&e.state.filters&&this.processURLFilters()&&(this.resetPage(),this.fetchFeed(),this.a11y.announce("Feed filters updated from browser history."))}processURLFilters(){const e=new URLSearchParams(window.location.search);if(e.toString()){for(var[t,i]of(["content","order","orderby","favourites","match"].forEach((t=>{let i=e.get("f_"+t);e.delete("f_"+t),i&&this.filterForm.querySelector(`input[name="${t}"][value="${i}"]`)&&(this.filterForm.querySelector(`input[name="${t}"][value="${i}"]`).checked=!0)})),Object.entries(Object.fromEntries(e))))t=t.replace("f_",""),this.contentTypes.includes(t)?this.openGallery=i:(this.taxonomies[t].addTermsFromURL(i),this.setSelectedTerms(t));this.updateFilters()}else this.updateFilters()}handleClick(e){if(e.target.classList.contains("load-more")||e.target.closest(".load-more"))this.fetchFeed(!1),e.target.disabled=!0;else if(e.target.classList.contains("clear-filters")||e.target.closest(".clear-filters"))this.resetFilters();else if(this.config.gallery&&e.target.closest(".feed-image"))this.gallery.handleGalleryOpen(e);else if(e.target.classList.contains(".remove-item")||e.target.closest(".remove-item")){let t=e.target.closest(".selected-item"),i=t.dataset.taxonomy;this.taxonomies[i].removeSelectedTerm(t.dataset.id),this.setSelectedTerms(i),this.updateFilters()}}handleChange(e){e.target.closest(this.filterSelector)&&(this.resetPage(),window.removeChildren(this.grid),this.addPlaceholders(),this.updateFilters())}updateFilters(){this.page=1;const e=new URLSearchParams(window.location.search);let t=Object.fromEntries(new FormData(this.filterForm)),i=[];for(let[r,s]of Object.entries(t)){let a=!1;switch(r){case"content":s!==this.contentTypes[0]?a=!0:e.delete("f_"+r);break;case"orderby":"date"!==s&&(a=!0);break;case"order":"desc"!==s&&(a=!0);break;default:a=!0}a||e.delete("f_"+r),a&&!1!==s&&""!==s&&e.set("f_"+r,s),""!==s&&i.push(s);const o=`${window.location.pathname}?${e.toString()}`;history.pushState(t,"",o)}this.filters=t,this.updateContentFor(t.content),this.updateFilterControls(),this.loading.setContent(i),this.fetchFeed(!0)}updateFilterControls(){this.filterControls.hidden=this.selectedTerms.children.length<2}updateContentFor(e){this.filterForm.querySelectorAll(".jvb-selector").forEach((t=>{let i=t.dataset.for.includes(e);if(t.hidden=!i,!i){let e=t.dataset.taxonomy;this.clearSelectedTerms(e)}})),this.filterForm.querySelectorAll("input[data-for]").forEach((t=>{t.hidden=!t.dataset.for.includes(e)})),this.filterForm.querySelectorAll('input[name="order"]').forEach((e=>{e.hidden="random"===this.filters.order}))}clearSelectedTerms(e){this.filterForm.querySelector(`input[name="${e}"]`).value="",Object.hasOwn(this.taxonomies,e)&&(this.taxonomies[e].selectedItems={})}setSelectedTerms(e){let t=this.filterForm.querySelector(`input[name="${e}"]`);t.value="";let i=this.taxonomies[e].selectedTerms;if(!window.isEmptyObject(i)){let e=Object.keys(i);t.value=e.join(",")}this.updateFilters()}nextPage(){this.hasMore&&this.page++}resetPage(){this.page=1,this.hasMore=!0}resetState(){this.resetPage(!0),this.isLoading=!1,this.retries={count:0,max:3,delay:1e3}}resetFilters(){this.filterForm.reset(),this.filterForm.querySelector('input[name="content"]').checked=!0,this.filterForm.querySelector('input[name="orderby"][value="date"]').checked=!0,this.page=1,this.updateFilters()}buildFilterRequest(){let e={};for(let[t,i]of Object.entries(this.filters))!1!==i&&""!==i&&(e[t]=i);return e.page=parseInt(this.page),this.container.dataset.context&&(e.context=this.container.dataset.context),this.container.dataset.source&&(e.source=this.container.dataset.source),new URLSearchParams(e).toString()}async fetchFeed(e=!1,t=!1){if(this.isLoading)return!1;this.loading.showLoading(this.filters);try{1===this.page&&(window.removeChildren(this.grid),this.addPlaceholders());const e=await this.cache.fetchWithCache(`${this.config.api}feed?${this.buildFilterRequest()}`,{method:"GET"},{context:"feed",forceRefresh:!0});return e&&e.items&&0!==e.items.length?(this.hasMore=e.has_more,this.renderItems(e.items,this.page>1),this.hasMore&&this.nextPage(),!0):(1===this.page&&this.showEmptyState(),this.hasMore=!1,!1)}catch(e){this.handleError(e)}finally{this.loading.hideLoading(),!1!==this.openGallery&&(this.gallery.openWhenReady=this.openGallery,this.openGallery=!1),this.loadMore.disabled=!1,this.loadMore.hidden=!this.hasMore}}removePlaceholders(){this.grid.querySelector(".placeholder")&&window.removeChildren(this.grid)}showEmptyState(){window.removeChildren(this.grid);let e=window.getTemplate("emptyState"),t=Object.hasOwn(this.filters,"favourites")&&!0===this.filters.favourites;t&&([e.querySelector("h3").textContent,e.querySelector("p:first-of-type").textContent,e.querySelector("p:last-of-type").textContent]=["♡ BLANK CANVAS ♡","You haven't fallen in love with any pieces... yet!","Hit that heart icon when something stops your scroll — your dream collection is waiting to start."]),this.grid.append(e),this.a11y.announceEmpty(t)}handleError(e){return this.error.handleApiError(e,{component:"Feed Block",action:"loaditems"},(()=>this.fetchFeed()))}addPlaceholders(){let e=this.contentTypes.length-1;for(let t=0;t<9;t++){let t=window.getTemplate("placeholderTemplate"),i=Math.floor(Math.random()*e+1),r=window.getIcon(this.contentTypes[i]).cloneNode(!0);t.append(r),this.grid.append(t)}}renderItems(e,t=!1){if(t||(window.removeChildren(this.grid),this.addPlaceholders()),0===e.length)return void this.a11y.announceUpdate(0,t);const i=document.createDocumentFragment(),r=s=>{const a=Math.min(s+10,e.length);for(let t=s;t<a;t++){const r=e[t],s=this.createItemElement(r);i.appendChild(s),this.imageObserver.observe(s)}a<e.length?requestAnimationFrame((()=>{r(a)})):(this.removePlaceholders(),this.grid.appendChild(i),this.config.gallery&&this.gallery.updateGalleryItems(this.gallery.getGalleryItems()),this.a11y.makeNavigable(this.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,t,this.hasMore))};e.length>0?r(0):this.a11y.announceUpdate(0,t)}createItemElement(e){var t;if(this.rendered[e.icon]||(this.rendered[e.icon]=new Map),this.rendered[e.icon].has(e.id))return this.rendered[e.icon].get(e.id);const i=null!==(t=window.isFavourited(e.icon,e.id))&&void 0!==t&&t,r=window.getTemplate("feed-item");r.id=`${e.icon}-${e.id}`,r.dataset.id=e.id,r.classList.add(e.icon),e.umami_view&&this.buildUmamiData(r,e.umami_view);let s=r.querySelector("button.favourite");[s.dataset.id,s.dataset.type,s.dataset.artist,s.title]=[e.id,e.icon,e.user_id,i?"Remove from Favourites":"Add to Favourites"];let a=e.order,o=r.querySelector(".item"),n=r.querySelector(".item-list"),l=(r.querySelector(".feed-images"),r.querySelector("summary")),d=r.querySelector(".item-info");for(let[t,i]of Object.entries(a)){let t,s=e[i];if("title"===i)t=r.querySelector("h3 a"),""!==e.title?([t.textContent,t.href,t.url]=[e.title,e.url,`Learn more about this ${e.icon}`],""!==e.icon&&t.closest("h3").prepend(window.getIcon(e.icon)),e.umami_click&&this.buildUmamiData(t,e.umami_click)):t.remove();else if(Object.hasOwn(s,"terms")){if(0===s.terms.length)continue;let e=n.cloneNode(!0),t=e.querySelector(".label"),i=e.querySelector("ul"),r=e.querySelector("li");s.label&&(t.textContent=s.label),s.icon&&t.prepend(window.getIcon(s.icon)),s.label||s.icon||t.remove(),s.terms.forEach((e=>{let t=r.cloneNode(!0),s=t.querySelector("a");[s.href,s.title,s.textContent]=[e.url,`Learn more about ${e.title}`,e.title],e.umami_click.length>0&&this.buildUmamiData(s,e.umami_click),i.append(t)})),r.remove(),d.appendChild(e)}else if(Object.hasOwn(s,"value")&&""!==s.value){let e=o.cloneNode(!0),t=e.querySelector(".label"),i=e.querySelector("a"),r=e.querySelector("p");Object.hasOwn(s,"label")&&(t.textContent=s.label),Object.hasOwn(s,"icon")&&t.prepend(window.getIcon(s.icon)),Object.hasOwn(s,"icon")||Object.hasOwn(s,"label")||t.remove(),Object.hasOwn(s,"url")?(r.remove(),[i.textContent,i.href,i.title]=[s.value,s.url,`Learn more about ${s.value}`]):(i.remove(),r.textContent=s.value),d.appendChild(e)}else if("image"===i){let t=l.querySelector(".feed-images"),i=t.querySelector("a"),r=i.cloneNode(!0);this.config.gallery||(r.href=e.url),r.classList.add("feed-image"),this.buildImageData(r.querySelector("img"),e.image),t.append(r),e.content?.length>0&&(t.classList.add("multi"),e.content.forEach((e=>{let r=i.cloneNode(!0);this.config.gallery||(r.href=e.url);let s=r.querySelector("img");s.src=e.image.small,s.alt=e.image.alt,t.append(r)}))),i.remove()}}return o.remove(),n.remove(),this.rendered[e.icon].set(e.id,r),r}buildImageData(e,t){"string"==typeof t.tiny&&([e.src,e.dataset.small,e.dataset.medium,e.dataset.large,e.alt]=[t.tiny,t.small,t.medium,t.large,t.alt])}buildUmamiData(e,t){for(let[i,r]of Object.entries(t))e.dataset[i]=r}loadImage(e){const t=e.querySelector("img");if(!t)return;const i=this.getImageSize();t.src=t.dataset[i]||t.dataset.src,e.setAttribute("data-loaded","true")}updateImageSizes(){const e=this.getImageSize();this.grid.querySelectorAll(".item").forEach((t=>{const i=t.querySelector("img");i&&i.dataset[e]&&i.src!==i.dataset[e]&&(i.src=i.dataset[e])}))}getImageSize(){const e=window.innerWidth;return e>1024||e>500?"medium":"small"}}document.addEventListener("DOMContentLoaded",(()=>{window.feedBlock=new e}))})();
\ No newline at end of file
+(()=>{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-terms",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})})),setTimeout((()=>{this.selector.batchFetchTaxonomies(),this.selector.isInitializing=!1}),0),this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}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(){this.addPlaceholders(),this.store=window.jvbStore.register("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.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),this.addPlaceholders()),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=window.isEmptyObject(this.taxonomyFilters),this.ui.clearFilter.hidden=window.isEmptyObject(this.taxonomyFilters)}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,h,d,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,h.textContent,d.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}addPlaceholders(){let e=this.contentTypes.length;for(let t=0;t<12;t++){let t,i=window.getTemplate("placeholderTemplate"),s=Math.floor(Math.random()*e);t=this.ui.content.length>0?this.ui.content.filter((e=>e.value===this.contentTypes[s])).querySelector(".icon").cloneNode(!0):window.getIcon(this.container.dataset.icon),i.appendChild(t),this.ui.grid.appendChild(i)}}removePlaceholders(){this.ui.grid.querySelector(".placeholder")&&window.removeChildren(this.ui.grid)}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.debounce((()=>{this.updateImageSizes()}),250)):window.addEventListener("resize",window.debounce((()=>{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}))})();
\ No newline at end of file
diff --git a/build/gmbreviews/render.php b/build/gmbreviews/render.php
index 47ad2b5..7aa2dcb 100644
--- a/build/gmbreviews/render.php
+++ b/build/gmbreviews/render.php
@@ -108,6 +108,7 @@
<?php
foreach ($reviews as $review) {
$reviewer = $review['reviewer']['displayName'] ?? 'Anonymous';
+ $reviewer = strtok($reviewer, ' ');
$profilePhoto = $review['reviewer']['profilePhotoUrl'] ?? '';
$rating = $review['starRating'] ?? 0;
$rating = match($rating) {
@@ -122,20 +123,27 @@
$date = $review['updateTime'] ?? '';
?>
<li>
- <article class="review">
- <header class="row btw">
+ <blockquote class="review">
+ <?php
+ // Review text
+ if (!empty($comment)) { ?>
+ <div class="content review">
+ <?= apply_filters('wpautop', $comment) ?>
+ </div>
+ <?php } ?>
+ <cite class="row start nowrap">
<?php if (!empty($profilePhoto)) { ?>
<img src="<?=esc_url($profilePhoto)?>"
- alt="<?=esc_attr($reviewer)?>"
- 'loading="lazy">
+ alt="<?=esc_attr($reviewer)?>"
+ 'loading="lazy">
<?php } else { ?>
<div class="avatar">
- <?= jvbIcon('user-circle')?>
+ <?= jvbIcon('user-circle')?>
</div>
<?php } ?>
- <div class="col end">
- <h4><?= esc_html($reviewer)?></h4>
+ <div class="row start wrap">
+ <p><?= esc_html($reviewer)?></p>
<?php
// Date
if ($showDate && !empty($date)) {
@@ -154,16 +162,8 @@
</div>
<?php } ?>
</div>
-
- </header>
- <?php
- // Review text
- if (!empty($comment)) { ?>
- <div class="review">
- <?= apply_filters('the_content', $comment) ?>
- </div>
- <?php } ?>
- </article>
+ </cite>
+ </blockquote>
</li>
<?php
}
diff --git a/build/gmbreviews/style-index-rtl.css b/build/gmbreviews/style-index-rtl.css
index a413f26..66c34c0 100644
--- a/build/gmbreviews/style-index-rtl.css
+++ b/build/gmbreviews/style-index-rtl.css
@@ -1 +1 @@
-.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{display:inline-block;vertical-align:middle}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}.gmb-reviews ul li:nth-of-type(odd){right:-2rem}.gmb-reviews ul li:nth-of-type(2n){left:-2rem}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);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:var(--alignWide)}.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;padding:0}.gmb-reviews ul li{margin:2rem 0;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(--outerRadius);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%}
diff --git a/build/gmbreviews/style-index.css b/build/gmbreviews/style-index.css
index 963482d..d40b467 100644
--- a/build/gmbreviews/style-index.css
+++ b/build/gmbreviews/style-index.css
@@ -1 +1 @@
-.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{display:inline-block;vertical-align:middle}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}.gmb-reviews ul li:nth-of-type(odd){left:-2rem}.gmb-reviews ul li:nth-of-type(2n){right:-2rem}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);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:var(--alignWide)}.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;padding:0}.gmb-reviews ul li{margin:2rem 0;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(--outerRadius);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%}
diff --git a/build/timeline/block.json b/build/timeline/block.json
new file mode 100644
index 0000000..ae2a0f5
--- /dev/null
+++ b/build/timeline/block.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/timeline",
+ "version": "0.1.0",
+ "title": "Timeline",
+ "category": "jvb",
+ "icon": "shortcode",
+ "description": "Outputs a single timeline post in a cool way",
+ "example": {},
+ "supports": {
+ "html": false,
+ "align": [
+ "wide",
+ "full"
+ ]
+ },
+ "textdomain": "jvb",
+ "selectors": {
+ "root": ".timeline"
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
\ No newline at end of file
diff --git a/build/timeline/index-rtl.css b/build/timeline/index-rtl.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/timeline/index-rtl.css
@@ -0,0 +1 @@
+
diff --git a/build/timeline/index.asset.php b/build/timeline/index.asset.php
new file mode 100644
index 0000000..348680b
--- /dev/null
+++ b/build/timeline/index.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-i18n'), 'version' => 'b58129e5087f682034eb');
diff --git a/build/timeline/index.css b/build/timeline/index.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/timeline/index.css
@@ -0,0 +1 @@
+
diff --git a/build/timeline/index.js b/build/timeline/index.js
new file mode 100644
index 0000000..beab3c8
--- /dev/null
+++ b/build/timeline/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var r,e={798:()=>{const r=window.wp.blocks,e=window.wp.i18n,o=window.wp.blockEditor,i=window.ReactJSXRuntime,t=JSON.parse('{"UU":"jvb/timeline"}');(0,r.registerBlockType)(t.UU,{edit:function(){return(0,i.jsx)("p",{...(0,o.useBlockProps)(),children:(0,e.__)("Will output the timeline","jvb")})}})}},o={};function i(r){var t=o[r];if(void 0!==t)return t.exports;var n=o[r]={exports:{}};return e[r](n,n.exports,i),n.exports}i.m=e,r=[],i.O=(e,o,t,n)=>{if(!o){var l=1/0;for(v=0;v<r.length;v++){for(var[o,t,n]=r[v],s=!0,a=0;a<o.length;a++)(!1&n||l>=n)&&Object.keys(i.O).every((r=>i.O[r](o[a])))?o.splice(a--,1):(s=!1,n<l&&(l=n));if(s){r.splice(v--,1);var p=t();void 0!==p&&(e=p)}}return e}n=n||0;for(var v=r.length;v>0&&r[v-1][2]>n;v--)r[v]=r[v-1];r[v]=[o,t,n]},i.o=(r,e)=>Object.prototype.hasOwnProperty.call(r,e),(()=>{var r={643:0,627:0};i.O.j=e=>0===r[e];var e=(e,o)=>{var t,n,[l,s,a]=o,p=0;if(l.some((e=>0!==r[e]))){for(t in s)i.o(s,t)&&(i.m[t]=s[t]);if(a)var v=a(i)}for(e&&e(o);p<l.length;p++)n=l[p],i.o(r,n)&&r[n]&&r[n][0](),r[n]=0;return i.O(v)},o=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];o.forEach(e.bind(null,0)),o.push=e.bind(null,o.push.bind(o))})();var t=i.O(void 0,[627],(()=>i(798)));t=i.O(t)})();
\ No newline at end of file
diff --git a/build/timeline/style-index-rtl.css b/build/timeline/style-index-rtl.css
new file mode 100644
index 0000000..7f9d2fa
--- /dev/null
+++ b/build/timeline/style-index-rtl.css
@@ -0,0 +1 @@
+main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{max-width:var(--alignWide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--small)}#at-a-glance .before img{border-right:0;border-left-width:1px;border-top:0}#at-a-glance .after img{border-bottom:0;border-right-width:1px;border-left:0}.timeline-point.timeline-point{--lineWidth:1px;--gap:2rem;background-color:var(--base);margin:0;max-width:100vw;overflow:hidden;padding:0;position:relative}.timeline-point.timeline-point .open-gallery{border-radius:4px;padding:.5rem;position:sticky;width:40%}.timeline-point.timeline-point .info{padding:1rem .5rem .5rem;position:relative;width:60%}.timeline-point.timeline-point .info h2{font-size:var(--medium);margin:0 0 .5rem;position:relative}.timeline-point.timeline-point .info h2 .icon{--w:2.5rem;background-color:var(--action-100);right:-2.5rem;position:absolute;top:.25rem;transform:rotate(90deg)}.timeline-point.timeline-point:after,.timeline-point.timeline-point:before{background-color:var(--action-0);content:"";display:block;height:100%;right:45%;position:absolute;width:var(--lineWidth)}.timeline-point.timeline-point:before{height:1rem}.timeline-point.timeline-point:after{top:4rem}.timeline-point.timeline-point#before-treatment:before,.timeline-point.timeline-point:last-of-type:after{display:none}@media(min-width:768px){#at-a-glance h3{font-size:var(--xlarge)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point .open-gallery{width:50%}.timeline-point.timeline-point .info{padding:25vh 1rem 1rem;width:50%}.timeline-point.timeline-point .info h2 .icon{--w:4rem;right:-4.15rem;top:0}.timeline-point.timeline-point .info a{align-items:center;display:flex;flex-wrap:wrap}.timeline-point.timeline-point .info time{font-size:var(--small);text-transform:uppercase}.timeline-point.timeline-point:after,.timeline-point.timeline-point:before{right:calc(50% + 2rem)}.timeline-point.timeline-point:before{height:calc(25vh - 2rem)}.timeline-point.timeline-point:after{top:calc(25vh + 6rem)}}
diff --git a/build/timeline/style-index.css b/build/timeline/style-index.css
new file mode 100644
index 0000000..eaaa6d9
--- /dev/null
+++ b/build/timeline/style-index.css
@@ -0,0 +1 @@
+main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{max-width:var(--alignWide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--small)}#at-a-glance .before img{border-left:0;border-right-width:1px;border-top:0}#at-a-glance .after img{border-bottom:0;border-left-width:1px;border-right:0}.timeline-point.timeline-point{--lineWidth:1px;--gap:2rem;background-color:var(--base);margin:0;max-width:100vw;overflow:hidden;padding:0;position:relative}.timeline-point.timeline-point .open-gallery{border-radius:4px;padding:.5rem;position:sticky;width:40%}.timeline-point.timeline-point .info{padding:1rem .5rem .5rem;position:relative;width:60%}.timeline-point.timeline-point .info h2{font-size:var(--medium);margin:0 0 .5rem;position:relative}.timeline-point.timeline-point .info h2 .icon{--w:2.5rem;background-color:var(--action-100);left:-2.5rem;position:absolute;top:.25rem;transform:rotate(-90deg)}.timeline-point.timeline-point:after,.timeline-point.timeline-point:before{background-color:var(--action-0);content:"";display:block;height:100%;left:45%;position:absolute;width:var(--lineWidth)}.timeline-point.timeline-point:before{height:1rem}.timeline-point.timeline-point:after{top:4rem}.timeline-point.timeline-point#before-treatment:before,.timeline-point.timeline-point:last-of-type:after{display:none}@media(min-width:768px){#at-a-glance h3{font-size:var(--xlarge)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point .open-gallery{width:50%}.timeline-point.timeline-point .info{padding:25vh 1rem 1rem;width:50%}.timeline-point.timeline-point .info h2 .icon{--w:4rem;left:-4.15rem;top:0}.timeline-point.timeline-point .info a{align-items:center;display:flex;flex-wrap:wrap}.timeline-point.timeline-point .info time{font-size:var(--small);text-transform:uppercase}.timeline-point.timeline-point:after,.timeline-point.timeline-point:before{left:calc(50% + 2rem)}.timeline-point.timeline-point:before{height:calc(25vh - 2rem)}.timeline-point.timeline-point:after{top:calc(25vh + 6rem)}}
diff --git a/build/timeline/view.asset.php b/build/timeline/view.asset.php
new file mode 100644
index 0000000..f534533
--- /dev/null
+++ b/build/timeline/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '31d6cfe0d16ae931b73c');
diff --git a/build/timeline/view.js b/build/timeline/view.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/build/timeline/view.js
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index 966276f..43831f2 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/inc/blocks/CustomBlocks.php
@@ -14,13 +14,9 @@
class CustomBlocks
{
protected CacheManager $cache;
- protected CacheManager $imgCache;
public function __construct()
{
$this->cache = CacheManager::for('blocks', WEEK_IN_SECONDS);
- $this->imgCache = CacheManager::for('images', WEEK_IN_SECONDS);
-
-
add_filter('render_block', [$this, 'render'], 999, 3);
@@ -241,7 +237,7 @@
if (array_key_exists('useFeaturedImage', $attrs)) {
global $post;
$ID = get_post_thumbnail_id($post->ID);
- } else {
+ } else if (array_key_exists('id', $attrs)) {
$ID = (int)$attrs['id'];
}
@@ -781,7 +777,6 @@
protected function render_core_template_part(array $block, string $content):string
{
- $check = ['header', 'footer'];
$isHeaderTemplate = (
(array_key_exists('slug', $block['attrs']) && str_contains(strtolower($block['attrs']['slug']), 'header')) ||
(array_key_exists('tagName', $block['attrs']) && str_contains(strtolower($block['attrs']['tagName']), 'header'))
@@ -792,7 +787,8 @@
) ? 'footer' : false;
- if ($isHeaderTemplate || $isFooterTemplate) {
+ if (($isHeaderTemplate || $isFooterTemplate)) {
+
$tag = $isHeaderTemplate ?: $isFooterTemplate ?: 'div';
$breadcrumbs = $themeSwitch = $afterHeader = $beforeHeader = $footerText= '';
@@ -821,12 +817,21 @@
$beforeHeader = '<section class="pre-footer">'.$beforeHeader.'</section>';
}
$footerText = jvbRandomFooterText();
- }
+ }
+// jvbDump($beforeHeader,'beforeHeader');
+// jvbDump('<'.$tag.$this->getClassesAndStyles($block['attrs']).'>','tag');
+// jvbDump($themeSwitch,'themeSwitch');
+// jvbDump($this->inside($block, $tag, $content),'inside');
+// jvbDump($footerText,'footerText');
+// jvbDump($afterHeader, 'afterheader');
+// jvbDump($breadcrumbs, 'breadcrumbs');
+
return $beforeHeader.'<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.
$themeSwitch .
- $this->inside($block, $tag, $content).
+ $this->inside($block, $tag, $content) .
$footerText.'</'.$tag.'>'.$afterHeader.$breadcrumbs;
}
+
return $content;
}
//core_term_description
@@ -870,8 +875,6 @@
protected function localize_feedblock():void
{
wp_localize_script('jvb-feed-view-script', 'feedSettings', [
- 'apiUrl' => rest_url('jvb/v1/'),
- 'nonce' => wp_create_nonce('wp_rest'),
'currentUser' => is_user_logged_in() ? [
'id' => get_current_user_id()
] : null,
@@ -921,7 +924,9 @@
$tag = (str_contains($tag, ' class')) ? strtok($tag, ' class') : $tag;
$tag = trim($tag);
}
-
+ if (!str_starts_with($o, '<'.$tag)) {
+ return $o;
+ }
$len = strlen('</'.$tag.'>');
@@ -988,30 +993,16 @@
int $ID = 0,
string $start = 'tiny',
string $replace = 'large',
- string $postSlug = ''
+ ?string $postSlug = null
):string {
- $image = $this->image($ID, $start, $replace);
- if ($ID === 0) {
- $ID = $this->imageID($ID);
- }
- if ($postSlug==='') {
- global $post;
- $postSlug = $post->post_name;
- }
- if (!$ID) {
- return '';
- }
- $img = get_post($ID);
- if (!$img) {
- return '';
- }
- $imgSlug = $img->post_name;
- $img = '<a class="open-gallery" target="_blank" rel="nofollow" data-opens="gallery-'.$postSlug.'" data-focus="'.$postSlug.'-'.$imgSlug.'">'.$image;
+ $image = jvbFormatImage($ID, $start, $replace, true, $postSlug);
+
if ($close) {
- $img .= '</a>';
+ return $image;
}
- return $img;
+ $len = strlen('</a>');
+ return substr_replace($image, '', -$len, $len);
}
public function image(string $ID = '', string $start = 'tiny', string $replace = 'large'):string
{
@@ -1023,41 +1014,7 @@
if ($ID === 0 || $ID === false) {
return '';
}
-
- $img = wp_get_attachment_image_src($ID, $start);
- if (!$img) return '';
- $img = $img[0];
-
- $data = $this->gallerySizes($ID, $replace);
-
- $alt = get_post_meta($ID, '_wp_attachment_image_alt', true);
- $alt = ($alt=='')? '' : ' alt="'.$alt.'" ';
-
- return '<img width="100%" height="auto" src="' .
- $img . '"' .$alt . $data . ' loading="lazy" decoding="async">';
- }
- public function gallerySizes(int $ID, string $replace = 'medium'):string
- {
- if (!wp_get_attachment_image_src($ID)) {
- return '';
- }
- if ($replace == 'large') {
- return 'data-small="'.
- wp_get_attachment_image_src($ID, 'large')[0].'" data-medium="'.
- wp_get_attachment_image_src($ID, 'full')[0].'" data-full="'.
- wp_get_attachment_image_src($ID, 'full')[0].'"';
- } elseif ($replace == 'medium') {
- return 'data-small="'.
- wp_get_attachment_image_src($ID, 'large')[0].'" data-medium="'.
- wp_get_attachment_image_src($ID, 'large')[0].'" data-full="'.
- wp_get_attachment_image_src($ID, 'large')[0].'"';
- } elseif ($replace == 'thumbnail') {
- return 'data-small="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'" data-medium="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'" data-full="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'"';
- }
- return '';
+ return jvbFormatImage($ID, $start, $replace, false);
}
public function sanitizeBlockName(array $block):string
{
@@ -1753,61 +1710,15 @@
return $styles;
}
- public function getGallerySizes(int $ID, string $replace):string
- {
- if (!wp_get_attachment_image_src($ID)) {
- return '';
- }
- if (!has_image_size($replace)) {
- $replace = 'large';
- }
-
- if ($replace == 'large') {
- return 'data-small="' .
- wp_get_attachment_image_src($ID)[0] . '" data-medium="' .
- wp_get_attachment_image_src($ID, 'large')[0] . '" data-full="' .
- wp_get_attachment_image_src($ID, 'full')[0] . '"';
- } elseif ($replace == 'medium') {
- return 'data-small="'.
- wp_get_attachment_image_src($ID, 'large')[0].'" data-medium="'.
- wp_get_attachment_image_src($ID, 'large')[0].'" data-full="'.
- wp_get_attachment_image_src($ID, 'large')[0].'"';
- } elseif ($replace == 'thumbnail') {
- return 'data-small="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'" data-medium="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'" data-full="'.
- wp_get_attachment_image_src($ID, 'medium')[0].'"';
- }
- return '';
- }
-
public function formatImage(int $ID = 0, string $start = 'tiny', string $replace = 'large'):string
{
- if ($ID === 0) {
- $ID = $this->imageID($ID);
- }
- if ($ID === 0) {
- return '';
- }
-
- return $this->imgCache->remember(
- ['ID' => $ID, 'start' => $start, 'replace' => $replace],
- function() use ($ID, $start, $replace) {
- $img = wp_get_attachment_image_src($ID, $start);
- if (!$img) {
- return'';
- }
- $img = $img[0];
-
- $data = $this->getGallerySizes($ID, $replace);
-
-
-
- $alt = get_post_meta($ID, '_wp_attachment_image_alt', true);
- $alt = ($alt=='')? '' : ' alt="'.$alt.'" ';
- return '<img width="300px" height="300px" src="'.$img.'"'.$alt.$data.' loading="lazy" decoding="async">';
- }
- );
+ if ($ID === 0) {
+ $ID = $this->imageID($ID);
+ }
+ if ($ID === 0) {
+ return '';
+ }
+ return jvbFormatImage($ID, $start, $replace);
}
}
diff --git a/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 83f8b1d..1f9703a 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -2,306 +2,349 @@
namespace JVBase\blocks;
use JVBase\managers\CacheManager;
+use JVBase\utility\Features;
+use JVBase\utility\Checker;
use JVBase\forms\TaxonomySelector;
use WP_Block;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
class FeedBlock
{
- protected CacheManager $cache;
- protected array $config;
- protected string $path = JVB_DIR.'/build/feed';
+ protected CacheManager $cache;
+ protected array $config;
+ protected string $path = JVB_DIR.'/build/feed';
- public function __construct()
- {
- $this->cache = CacheManager::for('feed',WEEK_IN_SECONDS);
- add_action('init', [$this, 'registerBlock']);
- }
+ public function __construct()
+ {
+ // Initialize cache with connections
+ $this->cache = CacheManager::for('feed_block', WEEK_IN_SECONDS);
- public function registerBlock()
- {
- register_block_type($this->path, [
- 'render_callback' => [$this, 'render']
- ]);
- }
+ // Set up cache connections for all feed content types
+ $this->setupCacheConnections();
- protected function buildParams(array $attributes):array
- {
+ add_action('init', [$this, 'registerBlock']);
+ }
- if (!jvbCheck('inheritQuery', $attributes)) {
- return [
- 'title' => $attributes['title'],
- 'content' => $attributes['contentTypes'],
- 'taxonomies'=> $this->getTaxonomies($attributes['contentTypes'])
- ];
- }
+ /**
+ * Set up cache connections for feed content
+ */
+ protected function setupCacheConnections(): void
+ {
+ // Connect to all content types that show in feed
+ $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
+ foreach ($contentTypes as $type) {
+ CacheManager::for('feed_content')->connectTo('post', $type);
+ }
- if (isJVBUserType()) {
- return $this->buildProfileConfig();
- } elseif (isJVBContentTax()) {
- return $this->buildShopConfig();
- } elseif (is_tax()) {
- return $this->buildTaxonomyConfig();
- }
+ // Connect to all taxonomies that show in feed
+ $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
+ foreach ($taxonomies as $tax) {
+ CacheManager::for('feed_taxonomy')->connectTo('taxonomy', $tax);
+ }
+ }
- return [];
- }
+ public function registerBlock()
+ {
+ register_block_type($this->path, [
+ 'render_callback' => [$this, 'render']
+ ]);
+ }
- protected function buildProfileConfig():array
- {
- $obj = get_queried_object();
- $content = jvbGetUserContentTypes($obj->ID);
- return [
- 'is_gallery' => true,
- 'content' => $content,
- 'context' => jvbNoBase($obj->post_type),
- 'taxonomies' => $this->getTaxonomies($content)
- ];
- }
+ protected function buildParams(array $attributes): array
+ {
+ if (!jvbCheck('inheritQuery', $attributes)) {
+ return [
+ 'title' => $attributes['title'],
+ 'content' => $attributes['contentTypes'],
+ 'taxonomies' => $this->getTaxonomies($attributes['contentTypes'])
+ ];
+ }
+ $config = [
+ 'is_gallery' => false,
+ 'content' => '',
+ 'taxonomies' => []
+ ];
+ $type = get_queried_object();
- protected function buildShopConfig():array
- {
- $type = jvbNoBase(get_queried_object()->taxonomy);
- $content = jvbContentTaxContent($type);
+ if (is_post_type_archive() || is_singular()) {
+ $content = is_singular() ? jvbNoBase($type->post_type) : jvbNoBase($type->name);
+ $mainConfig = JVB_CONTENT[$content]??false;
+ if ($mainConfig && array_key_exists('feed', $mainConfig) && array_key_exists('config', $mainConfig['feed'])){
+ $config = array_merge($config, $mainConfig['feed']['config']);
+ } else {
+ $config['content'] = $content;
+ $config['icon'] = JVB_CONTENT[$content]['icon']??['logo-triangle'];
+ }
+ if (is_singular()) {
+ $config['source'] = $type->ID;
+ }
- $context = [
- 'content' => $content,
- 'taxonomies' => $this->getTaxonomies($content),
- 'context' => $type,
- ];
- unset($context['taxonomies'][array_search($type, $context['taxonomies'])]);
- return $context;
- }
+ $config['taxonomies'] = $this->getTaxonomies([$content]);
+ } elseif (is_tax()) {
+ $content = jvbNoBase($type->taxonomy);
+ $mainConfig = JVB_TAXONOMY[$content]??false;
+ if ($mainConfig) {
+ $config['content'] = $mainConfig['for_content'];
+ $config['context'] = $content; // ← ADD THIS
+ $config['taxonomies'] = $this->getTaxonomies($mainConfig['for_content']);
+ if (array_key_exists('feed', $mainConfig) && array_key_exists('config', $mainConfig['feed'])){
+ $config = array_merge($config, $mainConfig['feed']['config']);
+ }
+ }
+ $config['source'] = $type->term_id;
+ }
- protected function buildTaxonomyConfig():array
- {
- $type = jvbNoBase(get_queried_object()->taxonomy);
- $content = jvbContentTaxContent($type);
- return [
- 'content' => $content,
- 'taxonomies' => $this->getTaxonomies($content),
- 'context' => $type
- ];
- }
+ if (!is_array($config['content'])) {
+ $config['content'] = [$config['content']];
+ }
- protected function getTaxonomies(array $content):array
- {
- global $jvb_taxonomy_for;
- $taxonomies = [];
- foreach ($jvb_taxonomy_for as $taxonomy => $for) {
- if (array_intersect($for, $content)) {
- $taxonomies[] = $taxonomy;
- }
- }
- return $taxonomies;
- }
+ return $config;
+ }
- public function render(array $attributes, string $content, WP_Block $block)
- {
- $this->config = $this->buildParams($attributes);
+ /**
+ * Get taxonomies for given content types
+ * Uses Checker instead of globals
+ */
+ protected function getTaxonomies(array $content): array
+ {
+ $checker = Checker::getInstance();
+ $taxonomies = [];
+ foreach ($content as $contentType) {
+ $contentTaxonomies = $checker->getTaxonomiesForContent($contentType);
+ $contentTaxonomies = array_filter($contentTaxonomies, function($taxonomy) {
+ return array_key_exists('show_feed', JVB_TAXONOMY[$taxonomy]) && JVB_TAXONOMY[$taxonomy]['show_feed'];
+ });
+ $taxonomies = array_merge($taxonomies, $contentTaxonomies);
+ }
+
+ return array_unique($taxonomies);
+ }
+
+ public function render(array $attributes, string $content, WP_Block $block)
+ {
+ $this->config = $this->buildParams($attributes);
return $this->cache->remember(
$this->config,
function() {
return $this->renderBlock();
}
);
- }
+ }
- protected function renderBlock():string
- {
- $work = isJVBUserType() ? ' id="work"' : '';
+ protected function renderBlock(): string
+ {
+ $ids = (array_key_exists('ids', $this->config) && !empty($this->config['ids'])) ? ' id="'.implode(' ',$this->config['ids']).'"' : '';
+ $classes = (array_key_exists('classes', $this->config) && !empty($this->config['classes'])) ? ' class="'.implode(' ',$this->config['classes']).'"' : '';
+ $source = (array_key_exists('source', $this->config)) ? ' data-source="'.$this->config['source'].'"' : '';
+ $context = (array_key_exists('context', $this->config)) ? ' data-context="'.$this->config['context'].'"' : '';
+ $icons = (array_key_exists('icon', $this->config)) ? ' data-icon="'.$this->config['icon'].'"' : ' data-icon="logo-triangle"';
+ $gallery = (array_key_exists('is_gallery', $this->config) && $this->config['is_gallery']) ? ' data-gallery' : '';
+ $content = (array_key_exists('content', $this->config)) ? ' data-content="'.implode(',',$this->config['content']).'"' : '';
+ ob_start();
+ ?>
+ <section<?= $ids.$classes ?> class="feed-block"<?= $content.$source.$context.$gallery.$icons ?>>
+ <?php
+ $this->renderFilters();
+ $this->renderGrid();
+ $this->renderLoader();
+ $this->renderTemplates();
+ echo TaxonomySelector::outputSelectorModal();
+ ?>
+ </section>
+ <?php
+ return ob_get_clean();
+ }
- ob_start();
- ?>
- <section<?= $work ?> class="feed-block"
- data-source="<?= get_queried_object_id(); ?>"
- <?= (array_key_exists('context', $this->config)) ? ' data-context="'.$this->config['context'].'"' : '' ?>
- <?= (array_key_exists('is_gallery', $this->config)) ? ' data-gallery="true"' : ''?>>
- <?php
- $this->renderFilters();
- $this->renderGrid();
- $this->renderLoader();
- $this->renderTemplates();
- ?>
- </section>
- <?php
- return ob_get_clean();
- }
+ protected function renderFilters(): void
+ {
+ if (empty($this->config)) {
+ return;
+ }
- protected function renderFilters():void
- {
- if (empty($this->config)) {
- return;
- }
- $many = count($this->config['content']) > 1;
- global $jvb_everything;
- global $jvb_taxonomy_for;
- ?>
- <form class="feed-filters" data-save="feed">
- <details>
- <summary class="row btw">
- <span class="label">SHOWING: </span>
- <?php
- $labels = [];
- foreach ($this->config['content'] as $i => $type) :
- $checked = $i === 0 ? ' checked' : '';
+ $feedContent = $this->getFeedContent();
+ $hasMany = count($this->config['content']) > 1;
+ ?>
+ <form class="feed-filters" data-save="feed-<?=$this->config['context']?>">
+ <?php if ($hasMany) {
+ //If we have multiple content, only show the content first
+ ?>
+ <details class="col a-start">
+ <summary class="row btw">
+ <span class="label">SHOWING: </span>
+ <?php
+ $labels = [];
+ foreach ($this->config['content'] as $i => $type) :
- $label = $jvb_everything[$type]['plural'];
- ?>
-
- <input type="radio"
- id="filter-<?= esc_attr($type) ?>"
- class="btn"
- name="content"
- value="<?= esc_attr($type) ?>"
- <?= $checked ?>>
- <label for="filter-<?= esc_attr($type) ?>" title="Show <?= $label ?>" class="row">
- <?= jvbIcon($type, ['title'=> $label]) ?>
- <span class="screen-reader-text"><?= $label ?></span>
- </label>
-
- <?php
- $labels['filter-'.$type] = $label;
- endforeach;
- ?>
- <ul class="filter-label">
- <?php
- $i = 0;
- foreach ($labels as $id =>$label) {
- $active = $i === 0 ? ' class="active"' : '';
- ?>
- <li id="<?=$id?>"<?=$active?>>
- <?=$label?>
- </li>
- <?php
- $i++;
- }
- ?>
- </ul>
+ $checked = $i === 0 ? ' checked' : '';
+ $label = $feedContent[$type]['plural'] ?? ucfirst($type);
+ ?>
+ <input type="radio"
+ id="filter-<?= esc_attr($type) ?>"
+ class="btn"
+ name="content"
+ data-filter="content"
+ value="<?= esc_attr($type) ?>"
+ <?= $checked ?>>
+ <label for="filter-<?= esc_attr($type) ?>" title="Show <?= $label ?>" class="row">
+ <?= jvbIcon($feedContent[$type]['icon']) ?>
+ <span class="screen-reader-text"><?= $label ?></span>
+ </label>
+ <?php
+ $labels['filter-'.$type] = $label;
+ endforeach;
+ ?>
+ <ul class="filter-label">
+ <?php
+ $i = 0;
+ foreach ($labels as $id => $label) {
+ $active = $i === 0 ? ' class="active"' : '';
+ ?>
+ <li id="<?= $id ?>"<?= $active ?>>
+ <?= $label ?>
+ </li>
+ <?php
+ $i++;
+ }
+ ?>
+ </ul>
+ <?php } ?>
- <?php if (is_user_logged_in()) : ?>
- <input type="checkbox" id="favourites" class="btn" name="favourites" value="on">
- <label for="favourites" title="Show Favourites" class="row">
- <?= jvbIcon('heart', ['title' =>'Favourites']) ?>
- <span class="screen-reader-text">Show Favourites Only</span>
- </label>
- <?php endif; ?>
+ <?php if (Features::forSite()->has('favourites') && is_user_logged_in()) : ?>
+ <input type="checkbox" id="favourites" class="btn" name="favourites" value="on"
+ data-filter="favourites">
+ <label for="favourites" title="Show Favourites" class="row">
+ <?= jvbIcon('heart').jvbIcon('heart', ['style' => 'fill']) ?>
+ <span class="screen-reader-text">Show Favourites Only</span>
+ </label>
+ <?php endif; ?>
+ <?php if ($hasMany) { ?>
+ </summary>
+ <?php } ?>
- <?php if ($many) {
- echo '</summary>';
- } ?>
+ <div class="filters">
+ <div class="filter-group row start">
+ <span class="label">FILTER BY:</span>
- <div class="filters">
- <div class="filter-group">
- <span class="label">FILTER BY:</span>
+ <?php
+ $checker = Checker::getInstance();
+ foreach ($this->config['taxonomies'] as $tax) :
+ $taxConfig = JVB_TAXONOMY[$tax] ?? null;
+ if (!$taxConfig) continue;
- <?php
- foreach ($jvb_taxonomy_for as $tax => $items) :
- $hidden = !in_array($tax, $this->config['taxonomies']) ? ' hidden' : '';
- if (in_array($tax, $this->config['taxonomies'])) {
- $tax = new TaxonomySelector(
- 'feed-'.$tax,
- $tax,
- [
- 'update' => '.selected-items-section .selected-items',
- 'types' => $items,
- 'hidden'=> $hidden
- ]
- );
- echo $tax->render();
- }
+ $contentForTax = $checker->getContentForTaxonomy($tax);
+ $hidden = empty($contentForTax) ? ' hidden' : '';
- endforeach; ?>
- </div>
- <div class="selected-items-section">
- <div class="selected-items row"></div>
- <div class="filter-actions row">
- <?= jvbRenderToggleTextField('match', 'Match', 'Filters', 'ALL', 'ANY') ?>
- <button type="button" class="clear-filters row">
- <?= jvbIcon('x', ['title' => 'Clear']) ?>
- Clear All Filters
- </button>
- </div>
- </div>
- </div>
+ $taxSelector = new TaxonomySelector(
+ 'feed-'.$tax,
+ $tax,
+ [
+ 'icon' => $taxConfig['icon']??'logo-triangle',
+ 'update' => '.selected-items-section .selected-items',
+ 'types' => $contentForTax,
+ 'autocomplete' => false,
+ 'hidden' => $hidden,
+ 'output' => 'minimal'
+ ]
+ );
+ echo $taxSelector->render();
+ endforeach;
+ ?>
+ </div>
+ <div class="selected-items-section">
+ <div class="selected-items row"></div>
+ <div class="filter-actions row">
+ <?= jvbRenderToggleTextField('match', 'Match', 'Filters', 'ALL', 'ANY', false, ['filter' => 'match']) ?>
+ <button type="button" class="clear-filters row">
+ <?= jvbIcon('x') ?>
+ Clear All Filters
+ </button>
+ </div>
+ </div>
+ </div>
- <div class="filter-group">
- <div class="order-by">
- <span class="label">ORDER BY:</span>
- <input type="radio" id="order-title" class="btn" name="orderby" value="title" data-for="artist,shop" hidden>
- <label for="order-title" title="Order by Name" class="row">
- <?= jvbIcon('alphabetical') ?>
- <span class="label">Name</span>
- </label>
+ <div class="row btw nowrap">
+ <div class="order-by filter-group row start w-full">
+ <span class="label">ORDER BY:</span>
+ <?php
+ //TODO: Get content types that can be sorted alphabetically
+ ?>
+ <input type="radio" id="order-title" class="btn" name="orderby" value="title" data-for="artist,shop" data-filter="orderby" hidden>
+ <label for="order-title" title="Order by Name" class="row">
+ <?= jvbIcon('alphabetical') ?>
+ <span class="label">Name</span>
+ </label>
- <input type="radio" id="order-date" class="btn" name="orderby" value="date" checked>
- <label for="order-date" title="Order by Date" class="row">
- <?= jvbIcon('calendar', ['title'=>'Date']) ?>
- <span class="label">Date</span>
- </label>
+ <input type="radio" id="order-date" class="btn" name="orderby" value="date" data-filter="orderby" checked>
+ <label for="order-date" title="Order by Date" class="row">
+ <?= jvbIcon('calendar', ['title' => 'Date']) ?>
+ <span class="label">Date</span>
+ </label>
- <input type="radio" id="order-random" class="btn" name="orderby" value="random">
- <label for="order-random" title="Random Order" class="row">
- <?= jvbIcon('shuffle') ?>
- <span class="label">Random</span>
- </label>
- </div>
+ <input type="radio" id="order-random" class="btn" name="orderby" value="random" data-filter="orderby">
+ <label for="order-random" title="Random Order" class="row">
+ <?= jvbIcon('shuffle') ?>
+ <span class="label">Random</span>
+ </label>
+ </div>
- <div class="order-direction radio-group-label" data-for-order="date,title">
- <span class="label">ORDER:</span>
- <input type="radio" id="order-desc" class="btn" name="order" value="desc" checked>
- <label for="order-desc" title="Newest First" class="row">
- <?= jvbIcon('sort-descending') ?>
- </label>
+ <div class="order-direction filter-group row start w-full" data-for-order="date,title">
+ <span class="label">ORDER:</span>
+ <input type="radio" id="order-desc" class="btn" name="order" value="desc" data-filter="order" checked>
+ <label for="order-desc" title="Sort Descending (A-Z, 1-10)" class="row">
+ <?= jvbIcon('sort-descending') ?>
+ <span class="label" >DESC (A-Z)</span>
+ </label>
- <input type="radio" id="order-asc" class="btn" name="order" value="asc">
- <label for="order-asc" title="Oldest First" class="row">
- <?= jvbIcon('sort-ascending') ?>
- </label>
- </div>
- </div>
- </details>
- </form>
- <?php
- }
+ <input type="radio" id="order-asc" class="btn" name="order" value="asc" data-filter="order">
+ <label for="order-asc" title="Sort Ascending (Z-A, 10-1)" class="row">
+ <?= jvbIcon('sort-ascending') ?>
+ <span class="label" >ASC (Z-A)</span>
+ </label>
+ </div>
+ </div>
+ <?php if ($hasMany) { ?>
+ </details>
+ <?php } ?>
+ </form>
+ <?php
+ }
- protected function renderGrid():void
- {
- ?>
- <div class="item-grid"></div>
- <?php
- }
+ protected function renderGrid(): void
+ {
+ ?>
+ <div class="item-grid"></div>
+ <?php
+ }
- protected function renderLoader():void
- {
- ?>
- <button type="button" class="load-more">
- <?= jvbIcon('arrow-elbow-left-down', ['title' =>'More']) ?>
- Show Me More
- <?= jvbIcon('arrow-elbow-right-down', ['title'=> 'More']) ?>
- </button>
+ protected function renderLoader(): void
+ {
+ ?>
+ <button type="button" class="load-more">
+ <?= jvbIcon('arrow-elbow-left-down') ?>
+ Show Me More
+ <?= jvbIcon('arrow-elbow-right-down') ?>
+ </button>
- <?= jvbLoadingScreen() ?>
- <?php
- if (array_key_exists('is_gallery', $this->config)) {
- jvbRenderGallery();
- }
- }
+ <?= jvbLoadingScreen() ?>
+ <?php
+ if (array_key_exists('is_gallery', $this->config)) {
+ jvbRenderGallery();
+ }
+ }
- protected function renderTemplates():void
- {
- echo '<template class="feed-item">
- <details class="item feed" data-umami-event="view_feed">
+ protected function renderTemplates(): void
+ {
+ echo '<template class="feed-item">'.apply_filters('jvbFeedItem', '<details class="item feed" data-umami-event="view_feed">
<summary class="row btw">
<span class="handle">DETAILS</span>
<button class="favourite" title="Add to favourites" onclick="toggleFavourite(this)">
'.jvbIcon('heart')
- .jvbIcon('heart', ['style'=>'fill']).'
+ .jvbIcon('heart', ['style'=>'fill']).'
</button>
<div class="feed-images">
<a>
@@ -327,17 +370,23 @@
</div>
</div>
</div>
- </details>
- </template>';
+ </details>', $this->config).'</template>';
- echo '<template class="emptyState">
- <div class="feed-empty-state">
+ echo '<template class="emptyState">'.apply_filters('jvbFeedEmptyState', '<div class="feed-empty-state">
<h3>NOTHING HERE...</h3>
<p>Try tweaking those filters a bit.</p>
<p>Edmonton\'s got talent - let\'s find it.</p>
- </div>
- </template>';
+ </div>', $this->config). '</template>';
- echo '<template class="placeholderTemplate"><div class="placeholder"></div></template>';
- }
+ echo '<template class="placeholderTemplate"><div class="placeholder">'.apply_filters('jvbFeedPlaceholder', '').'</div></template>';
+ }
+
+ /**
+ * Get feed content using Features instead of get_option
+ * Returns array of slug => config for types that show in feed
+ */
+ public function getFeedContent(): array
+ {
+ return JVB()->routes('feed')->getFeedTypesConfig();
+ }
}
diff --git a/inc/blocks/GlossaryBlock.php b/inc/blocks/GlossaryBlock.php
index c0442bb..5f8c394 100644
--- a/inc/blocks/GlossaryBlock.php
+++ b/inc/blocks/GlossaryBlock.php
@@ -24,7 +24,7 @@
public function __construct()
{
- $this->cache = CacheManager::for('glossary_terms', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('glossary_terms', WEEK_IN_SECONDS)->connectTo('post', 'terms');
add_action('init', [ $this, 'registerBlock' ]);
}
diff --git a/inc/blocks/MenuBlock.php b/inc/blocks/MenuBlock.php
index e6ba1e2..cabb11f 100644
--- a/inc/blocks/MenuBlock.php
+++ b/inc/blocks/MenuBlock.php
@@ -27,7 +27,7 @@
public function __construct()
{
- $this->cache = CacheManager::for('menu', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('menu', WEEK_IN_SECONDS)->connectTo('post', 'menu_item');
add_action('init', [ $this, 'registerBlock' ]);
}
diff --git a/inc/blocks/RegisterBlocks.php b/inc/blocks/RegisterBlocks.php
index b4cb788..5408503 100644
--- a/inc/blocks/RegisterBlocks.php
+++ b/inc/blocks/RegisterBlocks.php
@@ -6,7 +6,7 @@
exit;
}
//Blocks
-if (jvbSiteUsesFeedBlock()) {
+if (Features::forSite()->has('feed_block')) {
require(JVB_DIR . '/build/feed/render.php');
}
require(JVB_DIR . '/build/list/render.php');
@@ -61,49 +61,6 @@
}
add_filter('block_categories_all', 'jvbRegisterBlockCategory');
-//Get our loaded types
-function jvbRegisterFeedTypes():void
-{
- global $jvb_feed;
- global $jvb_taxonomy_for;
-
-
- wp_localize_script('jvb-feed-block-editor', 'jvbBlockSettings', [
- 'contentTypes' => $jvb_feed,
- 'taxonomyFor' => $jvb_taxonomy_for
- ]);
-
-
- // Filter for block.json
- add_filter('block_type_metadata', function ($metadata) use ($jvb_feed) {
- if ($metadata['name'] === 'jvb/feed') {
- $metadata['attributes']['contentTypes']['items']['enum'] = array_keys($jvb_feed);
- }
- return $metadata;
- });
-}
-add_action('wp_loaded', 'jvbRegisterFeedTypes');
-
-if (jvbSiteUsesFeedBlock()) {
- add_action('rest_api_init', 'jvbGetFeedTypes');
- function jvbGetFeedTypes():void
- {
- register_rest_route(
- 'jvb/v1',
- 'types',
- [
- 'permission_callback' => function () {
- return is_user_logged_in();
- },
- 'callback' => function () {
- global $jvb_feed;
- return new WP_REST_Response(json_encode((object) $jvb_feed));
- }
- ]
- );
- }
-}
-
/**
* Enqueue block editor assets
diff --git a/inc/blocks/SummaryBlock.php b/inc/blocks/SummaryBlock.php
index fd55561..7e92262 100644
--- a/inc/blocks/SummaryBlock.php
+++ b/inc/blocks/SummaryBlock.php
@@ -66,7 +66,7 @@
);
/**
* The h1 title element, excluding the h1 tag.
- * Can include additonal html (like <small> or <b>)
+ * Can include additional html (like <small> or <b>)
*/
$this->header = apply_filters(
'jvbSummaryHeader',
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
new file mode 100644
index 0000000..4d8e9d1
--- /dev/null
+++ b/inc/blocks/TimelineBlock.php
@@ -0,0 +1,181 @@
+<?php
+namespace JVBase\blocks;
+
+use JVBase\managers\CacheManager;
+use JVBase\forms\TaxonomySelector;
+use JVBase\meta\MetaManager;
+use JVBase\utility\Features;
+use WP_Block;
+use WP_Query;
+
+if (!defined('ABSPATH')) {
+ exit; // Exit if accessed directly
+}
+
+class TimelineBlock
+{
+ protected CacheManager $cache;
+ protected string $config;
+ protected string $type;
+ protected string $path = JVB_DIR . '/build/timeline';
+ protected string $image;
+ protected string $header;
+ protected int $parentID;
+ protected int $total;
+ protected array $children = [];
+ protected string $content;
+ protected string $headerExtra;
+ protected string $detailsTitle;
+ protected array $details;
+ protected array|false $sections = false;
+
+ public function __construct()
+ {
+ $this->cache = CacheManager::for('timelines', WEEK_IN_SECONDS)->connectTo('post', 'timeline');
+ add_action('init', [ $this, 'registerBlock' ]);
+ add_action('wp_footer', 'jvbRenderGallery');
+ }
+
+ public function registerBlock()
+ {
+ register_block_type($this->path, [
+ 'render_callback' => [ $this, 'render' ]
+ ]);
+ }
+
+ public function render(array $attributes, string $content, WP_Block $block)
+ {
+ global $post;
+ if (!$post || !Features::forContent(jvbNoBase($post->post_type))->has('is_timeline') ) {
+ return '';
+ }
+ $this->parentID = $post->ID;
+ $cache = $this->cache->get($this->parentID);
+ $cache = false;
+ if ($cache) {
+ return $cache;
+ }
+ $this->content = jvbNoBase($post->post_type);
+
+ $this->children = get_children([
+ 'post_parent' => $this->parentID,
+ 'post_status' => 'publish',
+ 'orderby' => 'date',
+ 'order' => 'ASC',
+ 'fields' => 'ids'
+ ]);
+ $this->total = count($this->children);
+
+ ob_start();
+ $this->renderBlock();
+ $content = ob_get_clean();
+ $this->cache->set($this->parentID, $content);
+ return $content;
+ }
+
+ protected function renderBlock():void
+ {
+ $this->renderHeader();
+ $this->renderTimeline();
+ }
+
+ protected function renderHeader():void
+ {
+ $title = get_the_title();
+ ?>
+ <header id="top">
+ <h1><small>Before and After Laser Tattoo Removal:</small><?=$title?></h1>
+ <ul class="timeline terms">
+ <?php
+ $tax = ['goal', 'number', 'body-part', 'style', 'skin-type', 'age'];
+
+ foreach ($tax as $slug) {
+ $config = JVB_TAXONOMY[$slug];
+ $taxSlug = jvbCheckBase($slug);
+ $terms = get_the_terms($this->parentID, $taxSlug);
+ if ($terms && !is_wp_error($terms)) {
+ $many = count($terms) > 1;
+ ?>
+ <li class="<?=$slug?>">
+ <?=jvbIcon($config['icon']??'triangle')?>
+
+ <?php
+ if ($many) { echo '<ul>'; }
+ $open = ($many) ? '<li>' : '';
+ $close = ($many) ? '</li>' : '';
+ foreach ($terms as $term) {
+ $link = get_term_link($term->term_id, $taxSlug);
+ ?>
+
+ <?= $open ?>
+ <a href="<?=$link?>" rel="tag"><?=$term->name?></a>
+ <?= $close ?>
+ <?php }
+ if ($many) { echo '</ul>'; }
+ ?>
+ </li>
+ <?php
+ }
+ }
+ ?>
+ </ul>
+ </header>
+ <section id="at-a-glance" class="row nowrap">
+ <div class="before">
+ <h3>Before</h3>
+ <?= jvbFormatImage(get_post_thumbnail_id($this->parentID), 'tiny', 'large', false) ?>
+ </div>
+ <div class="after">
+ <h3>After <?=$this->total?> Treatment<?= $this->total > 1 ? 's' : '' ?></h3>
+ <?= jvbFormatImage(get_post_thumbnail_id($this->children[count($this->children)-1]), 'tiny', 'large', false) ?>
+ </div>
+ </section>
+ <?php
+ }
+ protected function renderTimeline():void
+ {
+ $all = $this->children;
+ array_unshift($all, $this->parentID);
+ $uniqueFields = JVB()->routes('content')->getTimelineUniqueFields($this->content);
+ foreach ($all as $i => $ID) {
+ $meta = new MetaManager($ID, 'post');
+ $fields = $meta->getAll($uniqueFields);
+
+ $plural = ($i>1) ? 's': '';
+ $title = ($i === 0) ? 'Before Laser Tattoo Removal' : 'After '.$i.' Treatment'.$plural;
+ $title = ($i > 0 && !str_contains('Treatment', $fields['post_title'])) ? $fields['post_title'] : $title;
+ ?>
+ <section id="<?= $i === 0 ? 'before-treatment' : 'treatment-'.$i ?>" class="timeline-point row a-start nowrap">
+ <?php
+ if (array_key_exists('post_thumbnail', $fields) && is_int($fields['post_thumbnail'])) {
+ echo jvbFormatImage($fields['post_thumbnail']);
+ }
+ ?>
+ <div class="info">
+ <header>
+ <h2><?=jvbIcon('logo-triangle')?><?= $title?></h2>
+ <?= array_key_exists('post_date', $fields) && $fields['date'] !== '' ? '<time>'.date('F Y', strtotime($fields['post_date'])).'</time>' : '' ?>
+ <?= array_key_exists('timeline', $fields) && $fields['timeline'] !== '' ? $this->outputTimelineTax($ID) : '' ?>
+ <?= array_key_exists('post_content', $fields) && $fields['post_content'] !== '' ? '<div class="content">'.wptexturize(wp_kses_post( wpautop($fields['post_content']))).'</div>' : '' ?>
+ </header>
+ </div>
+ </section>
+ <?php
+ }
+ }
+
+ protected function outputTimelineTax(int $ID):string
+ {
+ $timeline = get_the_terms($ID, BASE.'timeline');
+ if (!$timeline || is_wp_error($timeline)) {
+ return '';
+ }
+ $out = '<ul class="term-list">';
+ foreach ($timeline as $term) {
+ $link = get_term_link($term->term_id, BASE.'timeline');
+ $out .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').$term->name.' Later</a></li>';
+ }
+ $out .='</ul>';
+ return $out;
+ }
+}
diff --git a/inc/blocks/_setup.php b/inc/blocks/_setup.php
index 78a4122..07d0e9c 100644
--- a/inc/blocks/_setup.php
+++ b/inc/blocks/_setup.php
@@ -24,6 +24,10 @@
require(JVB_DIR . '/inc/blocks/GlossaryBlock.php');
new JVBase\blocks\GlossaryBlock();
}
+if (Features::anyContentHas('is_timeline')) {
+ require(JVB_DIR . '/inc/blocks/TimelineBlock.php');
+ new JVBase\blocks\TimelineBlock();
+}
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index 29f7df3..e79834d 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -211,6 +211,10 @@
* @return string The rendered HTML
*/
public function render(array $selected = [], string $extra = ''): string {
+
+ if (array_key_exists('output', $this->config) && $this->config['output'] === 'minimal') {
+ return $this->renderTaxonomyToggle($selected, $extra);
+ }
// Build data attributes
$dataAttrs = $this->buildDataAttributes($selected);
@@ -237,12 +241,13 @@
<?= $hasAutocomplete ?>
title="Open <?= $this->singular ?> Selector"
aria-label="Select <?= esc_attr($this->plural) ?>">
- <?= jvbIcon('plus-square', ['title' => 'Add ' . $this->title]) ?>
+ <?= jvbIcon('plus-square') ?>
</button>
- <input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
- <ul class="autocomplete-dropdown" hidden>
-
- </ul>
+ <?php if ($hasAutocomplete !== '') { ?>
+ <input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
+ <ul class="autocomplete-dropdown" hidden>
+ </ul>
+ <?php } ?>
</div>
<div class="selected-items row" role="region" aria-label="Selected <?= esc_attr($this->plural) ?>">
@@ -259,6 +264,11 @@
return ob_get_clean();
}
+ protected function renderTaxonomyToggle(array $selected = [], string $extra = ''): string
+ {
+ return '<button type="button" data-filter="taxonomy" data-taxonomy="'.$this->name.'" title="Filter by '.$this->singular.'">'.jvbIcon($this->config['icon']).'<span class="label">'.$this->singular.'</span></button>';
+ }
+
/**
* Build data attributes string for the toggle button
*/
diff --git a/inc/helpers/breadcrumbs.php b/inc/helpers/breadcrumbs.php
index 4a42118..81cb9b8 100644
--- a/inc/helpers/breadcrumbs.php
+++ b/inc/helpers/breadcrumbs.php
@@ -1,6 +1,7 @@
<?php
use JVBase\managers\CacheManager;
+use JVBase\utility\Features;
if (!defined('ABSPATH')) {
exit;
@@ -12,7 +13,7 @@
*/
function jvbGetCrumbs():array
{
- $cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS);
+ $cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS)->connectTo('all');
$key = get_queried_object_id();
$crumbs = $cache->get($key);
$crumbs = false;
@@ -31,19 +32,27 @@
//taxonomies extra
if (is_tax()) {
- switch ($obj->taxonomy) {
- case BASE.'type':
- $type = 'artist';
- break;
- default:
- $type = jvbNoBase($obj->taxonomy);
- break;
- }
- $directory = jvbDirectories($type);
- $crumbs[] = [
- 'name' => $directory['title'],
- 'url' => $directory['url']
- ];
+ $tax = jvbNoBase($obj->taxonomy);
+ $config = Features::getConfig($tax, 'term');
+ if (count($config['for_content']) === 1) {
+ $contentConfig = JVB_CONTENT[$config['for_content'][0]];
+ $crumbs[] = [
+ 'name' => $contentConfig['breadcrumb']??$contentConfig['plural'],
+ 'url' => get_post_type_archive_link(jvbCheckBase($config['for_content'][0])),
+ ];
+ $crumbs[] = [
+ 'name' => 'By '.$config['singular'],
+ 'url' => false,
+ ];
+ }
+ if (Features::forTaxonomy($tax)->has('directory')){
+ $directory = jvbDirectories($tax);
+ $crumbs[] = [
+ 'name' => $directory['title'],
+ 'url' => $directory['url']
+ ];
+ }
+
$crumbs = array_merge($crumbs, jvbGetBreadcrumbTermHierarchy($obj));
}
diff --git a/inc/helpers/formatting.php b/inc/helpers/formatting.php
index de3d379..ec0c40f 100644
--- a/inc/helpers/formatting.php
+++ b/inc/helpers/formatting.php
@@ -73,9 +73,9 @@
*/
function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
- $key = 'rating';
- $cached = $cache->get($key);
+ $cache = CacheManager::for('rating', WEEK_IN_SECONDS)->connectTo('post')->connectTo('term');
+
+ $cached = $cache->get($ID);
$cached = false;
if ($cached) {
return $cached;
@@ -100,7 +100,7 @@
$out .= jvbFormatStarRating($avg, (int)$total);
}
- $cache->set($key, $out);
+ $cache->set($ID, $out);
return $out;
}
@@ -137,7 +137,7 @@
*/
function jvbImageData(int $imgID):array
{
- $cache = CacheManager::for('imageData', WEEK_IN_SECONDS);
+ $cache = CacheManager::for('imageData', WEEK_IN_SECONDS)->connectTo('post');
$cached = $cache->get($imgID);
if ($cached) {
return $cached;
diff --git a/inc/helpers/media.php b/inc/helpers/media.php
index 11ec253..6a6f6fe 100644
--- a/inc/helpers/media.php
+++ b/inc/helpers/media.php
@@ -8,18 +8,22 @@
{
?>
<dialog class="gallery" aria-modal="true" aria-label="Image Gallery">
+
<div class="wrap">
- <button type="button" class="cancel" aria-label="Close Gallery"> <?= jvbIcon('x') ?></button>
- <button class="nav prev row" aria-label="Previous image">
- <?= jvbIcon('caret-left') ?>
- </button>
+ <div class="controls row">
+ <button type="button" class="cancel" aria-label="Close Gallery"> <?= jvbIcon('x') ?></button>
+ <button class="nav prev row" aria-label="Previous image">
+ <?= jvbIcon('caret-left') ?>
+ </button>
- <button class="nav next row" aria-label="Next image">
- <?= jvbIcon('caret-right') ?>
- </button>
-
+ <button class="nav next row" aria-label="Next image">
+ <?= jvbIcon('caret-right') ?>
+ </button>
+ </div>
<div class="content row">
+ <img src="" alt="" class="image-left">
<img src="" alt="" class="image">
+ <img src="" alt="" class="image-right">
<details>
<summary class="row btw">DETAILS</summary>
<div class="item-info"></div>
diff --git a/inc/helpers/members.php b/inc/helpers/members.php
index e4d8386..291ecb5 100644
--- a/inc/helpers/members.php
+++ b/inc/helpers/members.php
@@ -35,7 +35,7 @@
*/
function jvbGetUserByFirstName(string $first_name):WP_User|false
{
- $cache = CacheManager::for('userFirstname');
+ $cache = CacheManager::for('userFirstname')->connectTo('user');
$cached = $cache->get($first_name)??false;
if ($cached) {
return get_userdata($cached);
@@ -76,7 +76,7 @@
*/
function jvbGetUserByDisplayName(string $display_name):WP_User|false
{
- $cache = CacheManager::for('user_displaynames');
+ $cache = CacheManager::for('user_displaynames')->connectTo('user');
$cached = $cache->get($display_name)??false;
if ($cached && is_int($cached)) {
@@ -115,7 +115,7 @@
function jvbGetUsername(int $user_id):string
{
$key = 'user_display_names';
- $cache = CacheManager::for('userNames', WEEK_IN_SECONDS);
+ $cache = CacheManager::for('userNames', WEEK_IN_SECONDS)->connectTo('user');
$cached = $cache->get($user_id);
if ($cached) {
@@ -156,7 +156,7 @@
return false;
}
- $cache = CacheManager::for('artist', 3600);
+ $cache = CacheManager::for('artist', 3600)->connectTo('post');
$cached = $cache->get($userID);
if ($cached) {
return match ($return) {
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 8786204..4817908 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -25,12 +25,16 @@
string $labelAfter,
string $on,
string $off,
- bool $hidden = false
+ bool $hidden = false,
+ array $data = []
):string {
$hidden = ($hidden) ? ' hidden' : '';
+ $dataset = (!empty($data)) ? implode('', array_map(function ($key, $value) {
+ return ' data-'.$key.'="'.$value.'"';
+ }, array_keys($data), array_values($data))) : '';
return sprintf(
- '<div class="toggle-text" %s>
- <input value="all" name="%s" id="%s" type="checkbox">
+ '<div class="toggle-text"%s>
+ <input value="all" name="%s" id="%s" type="checkbox"%s>
<label for="%s" class="row">
%s
<span class="text row">
@@ -43,6 +47,7 @@
$hidden,
$name,
$name,
+ $dataset,
$name,
$labelBefore,
$off,
@@ -59,9 +64,8 @@
*/
function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
- $key = 'links';
- $cached = $cache->get($key);
+ $cache = CacheManager::for('user_links', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+ $cached = $cache->get($ID);
if ($cached) {
return $cached;
}
@@ -124,7 +128,7 @@
}
$out .= '</ul>';
}
- $cache->set($key, $out);
+ $cache->set($ID, $out);
return $out;
}
@@ -136,9 +140,9 @@
*/
function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string
{
- $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
- $key = 'contact';
- $cached = $cache->get($key);
+ $cache = CacheManager::for('contact', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+
+ $cached = $cache->get($ID);
if($cached){
return $cached;
}
@@ -178,7 +182,7 @@
$out .= '</ul>';
}
- $cache->set($key, $out);
+ $cache->set($ID, $out);
return $out;
}
@@ -365,7 +369,7 @@
<div class="field">
<input type="checkbox" id="select-all-group" name="select-all-group">
<label for="select-all-group">
- Select All
+ Select All In Group
</label>
</div>
<div class="info" hidden>
@@ -532,38 +536,39 @@
$dataID = ($ID) ? ['image-id' => $ID] : false;
$addID = ($ID) ? '-'.$ID : '';
- $fields = array_merge([
- 'image_data' => [
- 'type' => 'group',
- 'wrap' => 'details',
- 'label' => 'Image Info',
- 'hint' => 'These will be automatically generated if left blank.',
- 'fields' => [
- 'image-title'.$addID => [
- 'type' => 'text',
- 'label' => 'Image Title',
- 'value' => $title,
- 'data' => $dataID
- ],
- 'image-alt-text'.$addID => [
- 'type' => 'text',
- 'label' => 'Alt Text',
- 'value' => $alt,
- 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
- 'data' => $dataID
- ],
- 'image-caption'.$addID => [
- 'type' => 'textarea',
- 'value' => $caption,
- 'label' => 'Image Caption',
- 'data' => $dataID
- ]
- ]
+
+ $defaultFields = [
+ 'image-title'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Image Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'image-alt-text'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Alt Text',
+ 'value' => $alt,
+ 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
+ 'data' => $dataID
+ ],
+ 'image-caption'.$addID => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Image Caption',
+ 'data' => $dataID
]
- ], $fields);
+ ];
+ $fields = array_merge($defaultFields, $fields);
+ $config = [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => $fields
+ ];
- return $form->render('image_data',null, $fields,false, true);
+ return $form->render('image_data',null, $config,false, true);
}
@@ -573,7 +578,7 @@
return '';
}
- $cache = CacheManager::for('locations');
+ $cache = CacheManager::for('locations')->connectTo('taxonomy');
$key = $cache->generateKey($location);
$cached = false;
diff --git a/inc/helpers/time.php b/inc/helpers/time.php
index 126f52a..6f94c49 100644
--- a/inc/helpers/time.php
+++ b/inc/helpers/time.php
@@ -141,7 +141,7 @@
*/
function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string
{
- $cache = CacheManager::for('hours-'.$ID, WEEK_IN_SECONDS);
+ $cache = CacheManager::for('hours-'.$ID, WEEK_IN_SECONDS)->connectTo('taxonomy');
$key = 'hours_display';
$cached = $cache->get($key);
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index 85f276d..0039a73 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -1,6 +1,7 @@
<?php
use JVBase\utility\Features;
+use JVBase\utility\Image;
if (!defined('ABSPATH')) {
exit;
@@ -53,8 +54,7 @@
?>
</nav>
</div>
- <div class="qitems col">
- <!-- Operations will be listed here -->
+ <div class="qitems col a-start">
</div>
<div class="queue-actions row btw">
<button class="dismiss-all">Clear Completed</button>
@@ -245,19 +245,10 @@
*
* @return string
*/
-function jvbFormatImage(int|string $imgID, string $start = 'tiny', string $end = 'large'):string
+function jvbFormatImage(int $imgID, string $start = 'tiny', string $end = 'large', bool $addLink = true, ?string $postSlug = null):string
{
- $block = new \JVBase\blocks\CustomBlocks();
- if ($imgID === '' || $imgID === 0) {
- $imgID = $block->imageID($imgID);
- }
- if ($imgID === '' || $imgID === 0 || $imgID === false) {
- return '';
- }
-
- $imgID = (int)$imgID;
-
- return $block->formatImage($imgID, $start, $end);
+ $image = new Image();
+ return $image->formatImage($imgID, $start, $end, $addLink, $postSlug);
}
/**
@@ -265,8 +256,6 @@
* @return void
*/
add_action('wp_footer', 'jvbToastContainer');
-
-
function jvbToastContainer():void
{
?>
@@ -332,11 +321,12 @@
add_action('wp_footer', 'jvbLoadingScreen');
function jvbLoadingScreen():string
{
+ $icon = apply_filters('jvbLoadingIcon', 'drop-simple');
return '<dialog class="loading">
<div class="col">
<div class="spinner"></div>
<div class="status col">
- <div class="icon">'.apply_filters('jvbLoadingIcon', jvbIcon('drop-simple')).'</div>
+ <div class="icon">'.jvbIcon($icon).'</div>
<h3>Loading</h3>
<p class="typeText">Please wait...</p>
</div>
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index 3260b8c..e42e0b3 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -85,18 +85,18 @@
'label' => 'OAuth Client Secret',
'required' => true,
],
- 'access_token' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Access Token',
- 'hint' => 'Generated automagically after OAuth authorization.'
- ],
- 'refresh_token' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Refresh Token',
- 'hint' => 'Generated automagically after OAuth authorization.'
- ]
+// 'access_token' => [
+// 'type' => 'text',
+// 'subtype' => 'password',
+// 'label' => 'Access Token',
+// 'hint' => 'Generated automagically after OAuth authorization.'
+// ],
+// 'refresh_token' => [
+// 'type' => 'text',
+// 'subtype' => 'password',
+// 'label' => 'Refresh Token',
+// 'hint' => 'Generated automagically after OAuth authorization.'
+// ]
];
$this->advanced = [
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 9510af5..6e0d674 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -924,8 +924,11 @@
bool $force = false
): ?array
{
- $cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
- $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
+ $cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey);
+
+ $ttl = is_int($cacheStrategy)
+ ? max(0, $cacheStrategy)
+ : ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl);
if (!$force && $ttl > 0) {
$cached = $this->cache->get($cacheKey);
@@ -944,7 +947,6 @@
return $result;
}
-
/**
* Check if response contains an error
* Override in child classes for service-specific error detection
diff --git a/inc/integrations/PostMark.php b/inc/integrations/PostMark.php
index baac1bc..5bb1744 100644
--- a/inc/integrations/PostMark.php
+++ b/inc/integrations/PostMark.php
@@ -257,6 +257,9 @@
*/
protected function sendEmail(array $payload): bool|WP_Error
{
+ if (!$this->isSetUp()) {
+ return false;
+ }
try {
$response = $this->postRequest('email', $payload);
diff --git a/inc/integrations/_setup.php b/inc/integrations/_setup.php
index 2953c01..e3a5d39 100644
--- a/inc/integrations/_setup.php
+++ b/inc/integrations/_setup.php
@@ -40,3 +40,8 @@
if (Features::hasIntegration('postmark')) {
require(JVB_DIR . '/inc/integrations/PostMark.php');
}
+
+
+if (Features::hasIntegration('helcim')) {
+ require(JVB_DIR . '/inc/integrations/Helcim.php');
+}
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index d0a1f8c..5145e51 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use JVBase\utility\Features;
+use WP_REST_Response;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -43,8 +44,163 @@
// Hook into WordPress admin
add_action('admin_menu', [$this, 'registerAdminPages']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
+
+ add_filter(BASE.'admin_action_filter', [$this, 'handleCacheActions'], 10, 3);
+
+ add_action('rest_api_init', [$this, 'registerRestRoutes']);
}
+ /**
+ * Register REST API routes for admin actions
+ */
+ public function registerRestRoutes(): void
+ {
+ register_rest_route('jvb/v1', '/admin-cache', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handleCacheAction'],
+ 'permission_callback' => [$this, 'checkAdminPermission']
+ ]);
+
+ register_rest_route('jvb/v1', '/admin-icons', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handleIconAction'],
+ 'permission_callback' => [$this, 'checkAdminPermission']
+ ]);
+ }
+
+ /**
+ * Check if user has admin permissions
+ */
+ public function checkAdminPermission(\WP_REST_Request $request): bool
+ {
+ if (!current_user_can('manage_options')) {
+ return false;
+ }
+
+ // Verify nonce
+ $nonce = $request->get_header('X-WP-Nonce');
+ if (!wp_verify_nonce($nonce, 'wp_rest')) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle cache-related actions
+ */
+ public function handleCacheAction(\WP_REST_Request $request): \WP_REST_Response
+ {
+ $action = sanitize_text_field($request->get_param('action'));
+
+ switch ($action) {
+ case 'flush-all':
+ wp_cache_flush();
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => 'All caches flushed successfully'
+ ]);
+
+ case 'flush-cache':
+ $group = sanitize_text_field($request->get_param('group'));
+ if (empty($group)) {
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'No cache group specified'
+ ], 400);
+ }
+
+ \JVBase\managers\CacheManager::invalidateAll($group);
+
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => "Cache group '{$group}' flushed successfully"
+ ]);
+
+ default:
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Invalid action'
+ ], 400);
+ }
+ }
+
+ /**
+ * Handle icon-related actions
+ */
+ public function handleIconAction(\WP_REST_Request $request): \WP_REST_Response
+ {
+ $action = sanitize_text_field($request->get_param('action'));
+ $icons = \JVBase\managers\IconsManager::getInstance();
+
+ switch ($action) {
+ case 'refresh-icons':
+ $icons->forceRefresh();
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon CSS regenerated successfully'
+ ]);
+
+ case 'restore-icon-version':
+ $timestamp = (int)$request->get_param('timestamp');
+ if (empty($timestamp)) {
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'No timestamp provided'
+ ], 400);
+ }
+
+ if ($icons->restoreVersion($timestamp)) {
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon version restored successfully'
+ ]);
+ }
+
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Failed to restore icon version'
+ ], 500);
+
+ case 'merge-icon-versions':
+ $timestamps = $request->get_param('timestamps');
+
+ if (empty($timestamps) || !is_array($timestamps)) {
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'No versions selected for merging'
+ ], 400);
+ }
+
+ $timestamps = array_map('intval', $timestamps);
+
+ if (count($timestamps) < 2) {
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Please select at least 2 versions to merge'
+ ], 400);
+ }
+
+ if ($icons->mergeVersions($timestamps)) {
+ return new \WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon versions merged successfully'
+ ]);
+ }
+
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Failed to merge icon versions'
+ ], 500);
+
+ default:
+ return new \WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Invalid action'
+ ], 400);
+ }
+ }
+
/**
* Register a subpage to appear under the main settings page
*
@@ -179,6 +335,14 @@
BASE.'cache',
[$this, 'renderCachePage']
);
+ add_submenu_page(
+ $this->main_page['menu_slug'],
+ 'Icon Management',
+ 'Icons',
+ 'manage_options',
+ BASE.'icons',
+ [$this, 'renderIconsPage']
+ );
// $this->getSubpages();
// Add registered subpages
@@ -489,42 +653,464 @@
return 'data:image/svg+xml;base64,' . base64_encode($svg);
}
- public function renderCachePage()
- {
- $groups = get_option(BASE.'all_cache_groups', []);
+ public function renderCachePage():void
+ {
+ $connections = CacheManager::getAllConnections();
- ?>
- <h1>Manage Cache</h1>
- <?php
- foreach ($groups as $group => $caches) {
- ?>
- <details>
- <summary class="row btw"><h2><?=$group?></h2></summary>
- <table>
- <thead>
- <tr>
- <th scope="col"><input type="checkbox" name="select-all-<?=$group?>" id="select-all-<?=$group?>">
- <label for="select-all-<?=$group?>">All</label></th>
- <th scope="col">Cache Key</th>
- <th scope="col">Actions</th>
- </tr>
- </thead>
- <tbody>
- <?php
- foreach ($caches as $key) {
- ?>
- <tr>
- <td><input type="checkbox" name="select-<?=$group?>-<?=$key?>" id="select-<?=$group?>-<?=$key?>"><label for="select-<?=$group?>-<?=$key?>"></label></td>
- <td><?= $key ?></td>
- <td><button type="button" data-action="flush-<?=$group?>-<?=$key?>"><?= jvbIcon('trash')?></button></td>
- </tr>
- <?php
- }
- ?>
- </tbody>
- </table>
- </details>
- <?php
- }
- }
+ // Separate generic vs. specific caches
+ $generic_groups = [];
+ $content_specific = [];
+ $nonce = wp_create_nonce('wp_rest');
+
+ foreach ($connections as $group => $configs) {
+ $is_generic = !$this->isBoundToContentOrTaxonomy($group);
+
+ if ($is_generic) {
+ $generic_groups[$group] = $configs;
+ } else {
+ $content_specific[$group] = $configs;
+ }
+ }
+
+ ?>
+ <div class="wrap jvb-admin-wrap">
+ <h1>Cache Management</h1>
+
+ <div class="jvb-cache-actions">
+ <button type="button" class="button button-primary" data-action="flush-all">
+ <?= jvbIcon('arrows-clockwise'); ?>
+ Flush All Caches
+ </button>
+ </div>
+
+ <div class="jvb-cache-section">
+ <h2>Generic Caches & Connections</h2>
+ <table class="wp-list-table widefat fixed striped">
+ <thead>
+ <tr>
+ <th class="manage-column">Cache Group</th>
+ <th class="manage-column">Connected To</th>
+ <th class="manage-column">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($generic_groups)): ?>
+ <tr><td colspan="3">No generic caches registered</td></tr>
+ <?php else: ?>
+ <?php foreach ($generic_groups as $group => $configs): ?>
+ <tr>
+ <td><strong><?= esc_html($group); ?></strong></td>
+ <td><?= $this->formatConnections($configs); ?></td>
+ <td>
+ <button type="button" class="button" data-action="flush-cache" data-group="<?= esc_attr($group); ?>">
+ <?= jvbIcon('trash'); ?> Flush
+ </button>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <details class="jvb-cache-section">
+ <summary><h2>Content-Specific Caches</h2></summary>
+ <table class="wp-list-table widefat fixed striped">
+ <thead>
+ <tr>
+ <th>Cache Group</th>
+ <th>Connected To</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($content_specific as $group => $configs): ?>
+ <tr>
+ <td><strong><?= esc_html($group); ?></strong></td>
+ <td><?= $this->formatConnections($configs); ?></td>
+ <td>
+ <button type="button" class="button" data-action="flush-cache" data-group="<?= esc_attr($group); ?>">
+ <?= jvbIcon('trash'); ?> Flush
+ </button>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ </details>
+ </div>
+ <script>
+ (function() {
+ const apiUrl = '<?= esc_js(rest_url('jvb/v1/admin-cache')); ?>';
+ const nonce = '<?= esc_js($nonce); ?>';
+
+ function callCacheAction(action, data = {}) {
+ const body = { action, ...data };
+
+ return fetch(apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': nonce
+ },
+ body: JSON.stringify(body)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert(data.message || 'Success!');
+ location.reload();
+ } else {
+ alert('Error: ' + (data.message || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ alert('Network error: ' + error.message);
+ console.error('Error:', error);
+ });
+ }
+
+ // Flush all caches
+ document.querySelector('[data-action="flush-all"]')?.addEventListener('click', function() {
+ if (confirm('Flush all caches? This may temporarily slow down your site.')) {
+ this.disabled = true;
+ callCacheAction('flush-all');
+ }
+ });
+
+ // Flush individual cache groups
+ document.querySelectorAll('[data-action="flush-cache"]').forEach(btn => {
+ btn.addEventListener('click', function() {
+ const group = this.getAttribute('data-group');
+ if (confirm(`Flush cache group "${group}"?`)) {
+ this.disabled = true;
+ callCacheAction('flush-cache', { group: group });
+ }
+ });
+ });
+ })();
+ </script>
+ <?php
+ }
+
+ protected function isBoundToContentOrTaxonomy(string $group): bool
+ {
+ $group = jvbNoBase($group);
+
+ if (defined('JVB_CONTENT')) {
+ foreach (JVB_CONTENT as $key => $config) {
+ if (jvbNoBase($key) === $group) {
+ return true;
+ }
+ }
+ }
+
+ if (defined('JVB_TAXONOMY')) {
+ foreach (JVB_TAXONOMY as $key => $config) {
+ if (jvbNoBase($key) === $group) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ protected function formatConnections(array $configs): string
+ {
+ $connections = [];
+ foreach ($configs as $config) {
+ $parent = $config['parent'] ?? 'unknown';
+ $scope = $config['scope'] ?? 'id';
+ $connections[] = "{$parent} ({$scope})";
+ }
+ return esc_html(implode(', ', $connections));
+ }
+
+ public function handleCacheActions($response, $request, $action):WP_REST_Response
+ {
+ if (!str_starts_with($action, 'flush-')) {
+ return $response;
+ }
+
+ if ($action === 'flush-all') {
+ wp_cache_flush();
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => 'All caches flushed successfully'
+ ]);
+ }
+
+ if (str_starts_with($action, 'flush-cache')) {
+ $group = $request->get_param('group');
+ if (empty($group)) {
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'No cache group specified'
+ ], 400);
+ }
+
+ \JVBase\managers\CacheManager::invalidateAll($group);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => "Cache group '{$group}' flushed successfully"
+ ]);
+ }
+
+ if ($action === 'merge-icon-versions') {
+ $timestamps = $request->get_param('timestamps');
+
+ if (empty($timestamps) || !is_array($timestamps)) {
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'No versions selected for merging'
+ ], 400);
+ }
+
+ // Convert to integers
+ $timestamps = array_map('intval', $timestamps);
+
+ if (count($timestamps) < 2) {
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Please select at least 2 versions to merge'
+ ], 400);
+ }
+
+ $icons = \JVBase\managers\IconsManager::getInstance();
+
+ if ($icons->mergeVersions($timestamps)) {
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon versions merged successfully'
+ ]);
+ }
+
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Failed to merge icon versions'
+ ], 500);
+ }
+
+ if ($action === 'refresh-icons') {
+ $icons = \JVBase\managers\IconsManager::getInstance();
+ $icons->forceRefresh();
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon CSS refresh triggered'
+ ]);
+ }
+
+ if ($action === 'restore-icon-version') {
+ $timestamp = (int)$request->get_param('timestamp');
+ $icons = \JVBase\managers\IconsManager::getInstance();
+
+ if ($icons->restoreVersion($timestamp)) {
+ return new WP_REST_Response([
+ 'success' => true,
+ 'message' => 'Icon version restored successfully'
+ ]);
+ }
+
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Failed to restore icon version'
+ ], 500);
+ }
+
+ return $response;
+ }
+
+ public function renderIconsPage():void
+ {
+ $icons = \JVBase\managers\IconsManager::getInstance();
+ $versions = $icons->getVersionHistory();
+ $nonce = wp_create_nonce('wp_rest');
+
+ ?>
+ <div class="wrap jvb-admin-wrap">
+ <h1>Icon Management</h1>
+
+ <div class="jvb-icon-actions">
+ <button type="button" class="button button-primary" data-action="refresh-icons">
+ <?= jvbIcon('arrows-clockwise'); ?>
+ Force Refresh CSS
+ </button>
+ <button type="button" class="button" data-action="merge-icon-versions" id="merge-versions-btn" disabled>
+ <?= jvbIcon('git-merge'); ?>
+ Merge Selected Versions
+ </button>
+ </div>
+
+ <h2>Version History</h2>
+ <table class="wp-list-table widefat fixed striped">
+ <thead>
+ <tr>
+ <th class="check-column">
+ <input type="checkbox" id="select-all-versions">
+ <label for="select-all-versions" class="screen-reader-text">Select All</label>
+ </th>
+ <th>Date/Time</th>
+ <th>Icon Count</th>
+ <th>File Size</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($versions)): ?>
+ <tr><td colspan="5">No version history available</td></tr>
+ <?php else: ?>
+ <?php foreach (array_reverse($versions) as $index => $version): ?>
+ <tr>
+ <th class="check-column">
+ <input type="checkbox"
+ name="version-select"
+ class="version-checkbox"
+ value="<?= esc_attr($version['timestamp']); ?>">
+ </th>
+ <td><?= esc_html(date('Y-m-d H:i:s', $version['timestamp'])); ?></td>
+ <td>
+ <?= esc_html($version['icon_count']); ?> icons
+ <button type="button"
+ class="button-link"
+ data-action="view-icon-list"
+ data-timestamp="<?= esc_attr($version['timestamp']); ?>">
+ (view)
+ </button>
+ </td>
+ <td><?= esc_html($version['size_formatted']); ?></td>
+ <td>
+ <button type="button" class="button"
+ data-action="restore-icon-version"
+ data-timestamp="<?= esc_attr($version['timestamp']); ?>">
+ <?= jvbIcon('arrow-counter-clockwise'); ?> Restore
+ </button>
+ </td>
+ </tr>
+ <tr id="icon-list-<?= esc_attr($version['timestamp']); ?>" class="icon-list-row" style="display: none;">
+ <td colspan="5">
+ <div class="icon-list-content">
+ <?php foreach ($version['iconList'] as $style => $icons): ?>
+ <strong><?= esc_html(ucfirst($style)); ?>:</strong>
+ <?= esc_html(implode(', ', $icons)); ?><br>
+ <?php endforeach; ?>
+ </div>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <script>
+ (function() {
+ const apiUrl = '<?= esc_js(rest_url('jvb/v1/admin-icons')); ?>';
+ const nonce = '<?= esc_js($nonce); ?>';
+
+ // Helper function for API calls
+ function callIconAction(action, data = {}) {
+ const body = { action, ...data };
+
+ return fetch(apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': nonce
+ },
+ body: JSON.stringify(body)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert(data.message || 'Success!');
+ location.reload();
+ } else {
+ alert('Error: ' + (data.message || 'Unknown error'));
+ }
+ return data;
+ })
+ .catch(error => {
+ alert('Network error: ' + error.message);
+ console.error('Error:', error);
+ });
+ }
+
+ // Enable/disable merge button based on selection
+ document.querySelectorAll('.version-checkbox').forEach(checkbox => {
+ checkbox.addEventListener('change', function() {
+ const checkedCount = document.querySelectorAll('.version-checkbox:checked').length;
+ document.getElementById('merge-versions-btn').disabled = checkedCount < 2;
+ });
+ });
+
+ // Select all functionality
+ const selectAll = document.getElementById('select-all-versions');
+ if (selectAll) {
+ selectAll.addEventListener('change', function() {
+ document.querySelectorAll('.version-checkbox').forEach(checkbox => {
+ checkbox.checked = this.checked;
+ checkbox.dispatchEvent(new Event('change'));
+ });
+ });
+ }
+
+ // Toggle icon list view
+ document.querySelectorAll('.view-icon-list-btn').forEach(btn => {
+ btn.addEventListener('click', function() {
+ const timestamp = this.getAttribute('data-timestamp');
+ const row = document.getElementById('icon-list-' + timestamp);
+ if (row) {
+ row.style.display = row.style.display === 'none' ? '' : 'none';
+ }
+ });
+ });
+
+ // Force refresh button
+ const refreshBtn = document.getElementById('refresh-icons-btn');
+ if (refreshBtn) {
+ refreshBtn.addEventListener('click', function() {
+ if (confirm('Force regenerate icon CSS? This will reload the page.')) {
+ this.disabled = true;
+ callIconAction('refresh-icons');
+ }
+ });
+ }
+
+ // Merge versions button
+ const mergeBtn = document.getElementById('merge-versions-btn');
+ if (mergeBtn) {
+ mergeBtn.addEventListener('click', function() {
+ const checkboxes = document.querySelectorAll('.version-checkbox:checked');
+ const timestamps = Array.from(checkboxes).map(cb => parseInt(cb.value));
+
+ if (timestamps.length < 2) {
+ alert('Please select at least 2 versions to merge');
+ return;
+ }
+
+ if (confirm(`Merge ${timestamps.length} versions? This will create a new CSS file with all unique icons.`)) {
+ this.disabled = true;
+ callIconAction('merge-icon-versions', { timestamps: timestamps });
+ }
+ });
+ }
+
+ // Restore version buttons
+ document.querySelectorAll('.restore-version-btn').forEach(btn => {
+ btn.addEventListener('click', function() {
+ const timestamp = parseInt(this.getAttribute('data-timestamp'));
+
+ if (confirm('Restore this icon version? This will reload the page.')) {
+ this.disabled = true;
+ callIconAction('restore-icon-version', { timestamp: timestamp });
+ }
+ });
+ });
+ })();
+ </script>
+ <?php
+ }
}
diff --git a/inc/managers/CRUDManager.php b/inc/managers/CRUDManager.php
index aa4293d..e05a0e5 100644
--- a/inc/managers/CRUDManager.php
+++ b/inc/managers/CRUDManager.php
@@ -58,6 +58,10 @@
];
$this->init();
+
+ if ($this->isTimeline) {
+ $this->stuck[] = 'post_thumbnail';
+ }
add_filter('jvbAdditionalActions', [$this, 'createItem']);
}
@@ -89,6 +93,7 @@
}));
array_unshift($this->timelineSharedFields, 'post_thumbnail');
array_unshift($this->timelineSharedFields, 'post_title');
+ array_unshift($this->timelineSharedFields, 'post_status');
$this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
@@ -192,7 +197,7 @@
{
ob_start();
?>
- <div class="dashboard-page <?= esc_attr($this->content) ?>">
+ <div class="dashboard-page <?= esc_attr($this->content) ?>"<?=($this->isTimeline) ? ' data-timeline' : ''?>>
<?php
$this->renderHeader();
$this->renderContent();
@@ -331,7 +336,7 @@
<?php
$order = [
'orderby' => [
- 'calendar' => 'Order by date created',
+ 'date' => 'Order by date created',
'alphabetical' => 'Order alphabetically'
],
'order' => [
@@ -347,10 +352,11 @@
<?php
$i = 0;
foreach ($option as $opt => $label) {
+ $icon = $opt === 'date' ? 'calendar' : $opt;
?>
<input id="<?=$opt?>" class="btn" type="radio" name="<?=$o?>" data-filter="<?=$o?>" value="<?=$opt?>"<?=$i===0 ? ' checked':''?>>
- <label for="<?=$opt?>" title="<?=$label?>"><?=jvbIcon($opt)?></label>
+ <label for="<?=$opt?>" title="<?=$label?>"><?=jvbIcon($icon)?></label>
<?php
$i++;
}
@@ -736,36 +742,6 @@
}
- protected function renderTimelineFields():string
- {
- ob_start();
-
-
- ?>
- <div class="repeater-field timeline-repeater" data-timeline data-field="fields">
- <div class="repeater-rows" data-repeater-container>
- <!-- Parent row (non-draggable) -->
- <div class="repeater-row parent-row" data-row-index="0" data-id="">
- <div class="row-header">
- <h4>Before (Starting Point)</h4>
- </div>
- <div class="row-fields">
- <?php $this->renderRowFields(); ?>
- </div>
- </div>
-
- <!-- Child rows will be added dynamically -->
- </div>
-
- <button type="button" class="add-repeater-row btn secondary">
- <?= jvbIcon('plus-square') ?>
- <span>Add Progress Step</span>
- </button>
- </div>
- <?php
- return ob_get_clean();
- }
-
protected function renderRowFields():void
{
$fields = $this->fields;
@@ -1010,6 +986,10 @@
protected function renderTableView():void
{
+ if ($this->isTimeline) {
+ $this->renderTimelineTableView();
+ return;
+ }
?>
<template class="contentTable">
<form class="table"
@@ -1038,13 +1018,17 @@
* Render table row template
*/
protected function renderTableRow(): void {
+ if ($this->isTimeline) {
+ $this->renderTimelineTableGroup();
+ return;
+ }
?>
<template class="tableView">
<tr class="item">
<td class="select">
<?= $this->renderItemSelect() ?>
</td>
- <td class="status">
+ <td class="status" data-field="post_status">
<?= $this->renderStatusRadios() ?>
</td>
<?php
@@ -1070,6 +1054,102 @@
</template>
<?php
}
+
+ protected function renderTimelineTableView():void
+ {
+ ?>
+ <template class="contentTable">
+ <form class="table"
+ data-save="content"
+ data-content="<?= esc_attr($this->content) ?>"
+ data-form-id="content-table-<?= esc_attr($this->content) ?>">
+ <?= jvbFormStatus() ?>
+ <?= $this->renderTableActions() ?>
+
+ <table>
+ <thead>
+ <?= $this->renderTimelineTableHeader() ?>
+ </thead>
+ <!-- Rows are inserted as tbody groups -->
+ <tfoot>
+ <?= $this->renderTimelineTableHeader() ?>
+ </tfoot>
+ </table>
+ </form>
+ </template>
+ <?php
+ }
+
+ protected function renderTimelineTableGroup():void
+ {
+ $makeDetails = [
+ 'group',
+ 'repeater',
+ 'checkbox',
+ 'radio'
+ ];
+ ?>
+ <template class="tableView">
+ <tbody class="item">
+ <tr class="shared">
+ <td class="select">
+ <?= $this->renderItemSelect() ?>
+ </td>
+ <td class="show-post_status field" data-field="post_status">
+ <?= $this->renderStatusRadios() ?>
+ </td>
+ <?php
+ foreach ($this->fields as $name => $config) {
+ if(array_key_exists('hidden', $config) || $name === 'post_status') {
+ continue;
+ }
+ if (!in_array($name, $this->timelineSharedFields)) {
+ echo '<td></td>';
+ continue;
+ }
+ $makeThisDetailed = (in_array($config['type'], $makeDetails));
+ ?>
+ <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
+ <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
+ <?php $this->meta->render('form', $name, $config); ?>
+ <?= $makeThisDetailed ? '</details>' : '' ?>
+ </td>
+ <?php
+ }
+
+ ?>
+ </tr>
+ <tr class="timeline-point">
+ <td class="select">
+ <button class="drag-handle" title="Drag to reorder" aria-label="Drag to reorder this timeline point"><?= jvbIcon('dots-six') ?></button>
+ </td>
+ <td class="show-post_status field" data-field="post_status">
+ <?= $this->renderStatusRadios() ?>
+ </td>
+ <?php
+ foreach ($this->fields as $name => $config) {
+ if(array_key_exists('hidden', $config) || $name === 'post_status') {
+ continue;
+ }
+ if (!in_array($name, $this->timelineUniqueFields)) {
+ echo '<td></td>';
+ continue;
+ }
+ $makeThisDetailed = (in_array($config['type'], $makeDetails));
+ ?>
+ <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
+ <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
+ <?php $this->meta->render('form', $name, $config); ?>
+ <?= $makeThisDetailed ? '</details>' : '' ?>
+ </td>
+ <?php
+ }
+ ?>
+ </tr>
+ </tbody>
+ </template>
+ <?php
+ }
/**
* Render status radio buttons
*/
@@ -1131,6 +1211,32 @@
return ob_get_clean();
}
+ protected function renderTimelineTableHeader(): string {
+ ob_start();
+
+ ?>
+ <tr>
+ <th scope="col" class="select-header">
+ <input type="checkbox" id="select-all" name="select-all">
+ <label for="select-all">All</label>
+ </th>
+ <th scope="col" class="show-post_status">
+ Status
+ </th>
+ <?php foreach ($this->fields as $name => $config):
+ if (array_key_exists('hidden', $config) || $name === 'post_status'){
+ continue;
+ }
+ ?>
+ <th scope="col" class="show-<?= esc_attr($name) ?>"<?= (in_array($name, $this->stuck)) ? ' data-stuck':''?>>
+ <?= esc_html($config['label']) ?>
+ </th>
+ <?php endforeach; ?>
+ </tr>
+ <?php
+ return ob_get_clean();
+ }
+
/**
* Render table action controls
*/
diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
index c8a38b9..8612385 100644
--- a/inc/managers/CacheManager.php
+++ b/inc/managers/CacheManager.php
@@ -13,14 +13,15 @@
*/
class CacheManager
{
+ private const CONNECTIONS_OPTION = BASE.'cache_connections';
+ private static ?array $connections_cache = null; // Cache in memory
private string $prefix = BASE;
private string $group;
private int $cache_ttl;
private static ?bool $use_object_cache = null;
private static array $instances = []; // Cache instances per type
private static array $http_timestamps = []; // Request-level memory cache
- private static array $relationships = []; // Type => [related types]
- private static bool $relationships_loaded = false;
+ private static ?CacheManager $singleton = null;
/**
* Private constructor - use for() factory method instead
@@ -33,6 +34,76 @@
if (is_null(static::$use_object_cache)) {
static::$use_object_cache = wp_using_ext_object_cache();
}
+
+ add_action('init', [$this, 'registerHooks']);
+ }
+
+ /**
+ * Get singleton instance (for general cache operations)
+ * For type-specific operations, use for() or forUser() instead
+ */
+ public static function getInstance(): self
+ {
+ if (self::$singleton === null) {
+ self::$singleton = new self('global', HOUR_IN_SECONDS);
+ }
+ return self::$singleton;
+ }
+
+ /**
+ * Get all cache connections (public accessor)
+ *
+ * @return array Array of cache group connections
+ */
+ public static function getAllConnections(): array
+ {
+ return self::getConnections();
+ }
+
+ /**
+ * Get all registered cache groups
+ *
+ * @return array List of cache group names
+ */
+ public static function getAllGroups(): array
+ {
+ $connections = self::getConnections();
+ return array_keys($connections);
+ }
+ /**
+ * Register WordPress hooks for automatic cache invalidation
+ * Call this once during plugin initialization
+ */
+ public static function registerHooks(): void
+ {
+ // Post updates (all post types including core)
+ add_action('save_post', [self::class, 'onPostSave'], 10, 2);
+ add_action('delete_post', [self::class, 'onPostDelete']);
+ // Meta updates (will catch MetaManager updates)
+ add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+ add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+ add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
+ // transition_post_status?
+
+ // Term updates (all taxonomies)
+ add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
+ add_action('create_term', [self::class, 'onTermSave'], 10, 3);
+ add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+ // Term meta updates
+ add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+ add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+ add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
+
+ // User updates
+ add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
+ add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
+ add_action('deleted_user', [self::class, 'onUserDelete']);
+
+ // User meta updates
+ add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+ add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+ add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
}
/**
@@ -127,21 +198,21 @@
}
/**
- * Invalidate cache for a content type with automatic cascade
+ * Invalidate cache for a content type
*
* @param string $type Content type to invalidate
- * @param mixed $context Post/Term object or array with relationship data (for cascade)
* @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+ * @param bool $flush_connections Whether to flush connected caches
* @return void
*/
- public static function invalidateAll(string $type, $context = null, $specific_keys = null): void
+ public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
{
$type = jvbNoBase($type);
// Update HTTP timestamp
self::updateTimestamp($type);
- // If specific keys provided, only delete those (don't flush whole group)
+ // If specific keys provided, only delete those
if ($specific_keys !== null) {
$instance = self::for($type);
if (is_array($specific_keys)) {
@@ -152,21 +223,20 @@
$instance->delete($specific_keys);
}
} else {
- // No specific keys - flush the entire group
+ // Flush the entire group
if (function_exists('wp_cache_flush_group')) {
wp_cache_flush_group($type);
} else {
- // Fallback for older WP
wp_cache_flush();
}
}
- // Cascade to related types if context provided
- if ($context !== null) {
- self::cascadeInvalidation($type, $context);
+ // Flush connected caches
+ if ($flush_connections) {
+ self::for($type)->connections();
}
- do_action('jvb_cache_invalidated', $type, $context);
+ do_action('jvb_cache_invalidated', $type);
}
/**
@@ -192,15 +262,14 @@
/**
* Fluent instance method to invalidate this cache type
- * Allows chaining: CacheManager::for('tattoo')->invalidate()->clear()
*
- * @param mixed $context Optional context for cascade
* @param string|array|null $specific_keys Optional specific key(s)
+ * @param bool $flush_connections Whether to flush connected caches
* @return self For chaining
*/
- public function invalidate($context = null, $specific_keys = null): self
+ public function invalidate($specific_keys = null, bool $flush_connections = true): self
{
- self::invalidateAll($this->group, $context, $specific_keys);
+ self::invalidateAll($this->group, $specific_keys, $flush_connections);
return $this;
}
@@ -360,308 +429,383 @@
return $this->group;
}
- // ===== RELATIONSHIP MANAGEMENT =====
+ /***************************************************************************
+ * CONNECTIONS
+ * Connect to other caches by instantiating and defining connection
+ * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
+ * Where: $type = content / taxonomy / user
+ * $scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
+ * $keyPattern = ??
+ ***************************************************************************/
/**
- * Register cache relationship
- * When $type is invalidated, these related types are also invalidated
+ * Define a connection between cache groups
+ * Connected caches will have their ID-based keys deleted when this cache invalidates
*
- * @param string $type Primary type
- * @param array $config Relationship configuration
- * - 'author' => bool - Invalidate user content caches
- * - 'taxonomies' => array - List of taxonomy types to invalidate
- * - 'content_types' => array - List of content types to invalidate
- * - 'related' => array - Generic related types to invalidate
- * - 'cascade' => callable - Custom cascade function
+ * @param string $type Grand overview ('post', 'taxonomy', 'user')
+ * @param string $scope Type-specific constant, user role, or 'id'
+ * @return self For chaining
*/
- public static function registerRelationship(string $type, array $config): void
+ public function connectTo(string $type, string $scope = 'id'): self
{
- $type = jvbNoBase($type);
+ //TODO: Handle connect to where $type === 'all'
+ $connections = self::getConnections();
- // Merge with existing relationships
- self::$relationships[$type] = array_merge(
- self::$relationships[$type] ?? [],
- $config
- );
+ if (!isset($connections[$this->group])) {
+ $connections[$this->group] = [];
+ }
- // Build reverse relationships for bidirectional linking
- self::buildReverseRelationships($type, $config);
- }
+ $new_connection = [
+ 'parent' => $type,
+ 'scope' => $scope
+ ];
- /**
- * Build reverse relationships (if A relates to B, B should know about A)
- *
- * @param string $type The type being registered
- * @param array $config Its relationship config
- */
- private static function buildReverseRelationships(string $type, array $config): void
- {
- // If this type relates to taxonomies, those taxonomies should know about this type
- if (!empty($config['taxonomies'])) {
- foreach ($config['taxonomies'] as $taxonomy) {
- $taxonomy = jvbNoBase($taxonomy);
- self::$relationships[$taxonomy]['content_types'] =
- array_unique(array_merge(
- self::$relationships[$taxonomy]['content_types'] ?? [],
- [$type]
- ));
+ // Check if already exists
+ foreach ($connections[$this->group] as $existing) {
+ if ($existing === $new_connection) {
+ return $this;
}
}
- // If this type relates to content_types, those types should know about this taxonomy
- if (!empty($config['content_types'])) {
- foreach ($config['content_types'] as $content_type) {
- $content_type = jvbNoBase($content_type);
- self::$relationships[$content_type]['related'] =
- array_unique(array_merge(
- self::$relationships[$content_type]['related'] ?? [],
- [$type]
- ));
+ $connections[$this->group][] = $new_connection;
+ update_option(self::CONNECTIONS_OPTION, $connections, false);
+ self::$connections_cache = $connections;
+
+ return $this;
+ }
+
+ /**
+ * Get all registered connections (cached for performance)
+ *
+ * @param bool $refresh Force refresh from database
+ * @return array
+ */
+ private static function getConnections(bool $refresh = false): array
+ {
+ if (self::$connections_cache === null || $refresh) {
+ self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
+ }
+
+ return self::$connections_cache;
+ }
+
+ /**
+ * Flush all caches connected to this one
+ *
+ * @return self For chaining
+ */
+ public function connections(): self
+ {
+ $all_connections = self::getConnections();
+
+ foreach ($all_connections as $cache_group => $connections) {
+ foreach ($connections as $conn) {
+ if ($this->matchesConnection($conn)) {
+ $this->flushConnection($cache_group, $conn);
+ }
}
}
+
+ return $this;
+ }
+
+ /**
+ * Check if this cache group matches a connection definition
+ */
+ private function matchesConnection(array $connection): bool
+ {
+ $parent = $connection['parent'] ?? '';
+ $scope = $connection['scope'] ?? 'id';
+
+ // Grand overview match
+ if ($this->group === $parent) {
+ return true;
+ }
+
+ // Type-specific match
+ if ($scope !== 'id') {
+ if ($this->group === jvbNoBase($scope)) {
+ return true;
+ }
+
+ // Check constants
+ if ($parent === 'post' && defined('JVB_CONTENT')) {
+ return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
+ }
+
+ if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
+ return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
+ }
+ }
+
+ // ID-specific match: 'user_123' matches 'user' + 'id'
+ if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Flush a connected cache group
+ * For ID-specific connections, deletes the specific ID key
+ * For type/overview connections, flushes entire group
+ */
+ private function flushConnection(string $cache_group, array $connection): void
+ {
+ $scope = $connection['scope'] ?? 'id';
+
+ // ID-specific: delete specific key
+ if ($scope === 'id') {
+ $id = $this->extractIdFromGroup();
+
+ if ($id !== null) {
+ self::invalidateKeys($cache_group, $id);
+ return;
+ }
+ }
+
+ // Type/overview: flush entire group
+ self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
+ }
+
+ /**
+ * Extract ID from group name like 'user_123' -> '123'
+ *
+ * @return string|null
+ */
+ private function extractIdFromGroup(): ?string
+ {
+ if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Register multiple connections at once
+ */
+ public static function registerConnections(array $connections): void
+ {
+ $existing = self::getConnections();
+ $changed = false;
+
+ foreach ($connections as $cache_group => $configs) {
+ if (!isset($existing[$cache_group])) {
+ $existing[$cache_group] = [];
+ }
+
+ foreach ($configs as $config) {
+ $duplicate = false;
+ foreach ($existing[$cache_group] as $existing_config) {
+ if ($existing_config === $config) {
+ $duplicate = true;
+ break;
+ }
+ }
+
+ if (!$duplicate) {
+ $existing[$cache_group][] = $config;
+ $changed = true;
+ }
+ }
+ }
+
+ if ($changed) {
+ update_option(self::CONNECTIONS_OPTION, $existing, false);
+ self::$connections_cache = $existing;
+ }
}
/**
- * Load relationships from JVB_CONTENT and JVB_TAXONOMY
+ * Handle post save/update
*/
- private static function loadRelationships(): void
+ public static function onPostSave(int $post_id, \WP_Post $post): void
{
- if (self::$relationships_loaded) {
+ // Skip revisions and autosaves
+ if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
- // Load post type relationships
- if (defined('JVB_CONTENT')) {
- foreach (JVB_CONTENT as $slug => $config) {
- $relationships = [];
+ $post_type = jvbNoBase($post->post_type);
- // Author relationship
- if (!($config['no_author'] ?? false)) {
- $relationships['author'] = true;
- }
+ // Invalidate post type cache
+ self::invalidateAll($post_type);
- // Taxonomy relationships
- if (!empty($config['taxonomies'])) {
- $relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']);
- }
-
- // Custom relationships from config
- if (!empty($config['cache_relationships'])) {
- $relationships = array_merge($relationships, $config['cache_relationships']);
- }
-
- if (!empty($relationships)) {
- self::registerRelationship($slug, $relationships);
- }
- }
- }
-
- // Load taxonomy relationships
- if (defined('JVB_TAXONOMY')) {
- foreach (JVB_TAXONOMY as $slug => $config) {
- $relationships = [];
-
- // Content type relationships
- if (!empty($config['for_content'])) {
- $relationships['content_types'] = array_map('jvbNoBase', $config['for_content']);
- }
-
- // Always include generic 'terms' cache
- $relationships['related'] = ['terms'];
-
- // Custom relationships from config
- if (!empty($config['cache_relationships'])) {
- $relationships = array_merge($relationships, $config['cache_relationships']);
- }
-
- if (!empty($relationships)) {
- self::registerRelationship($slug, $relationships);
- }
- }
- }
-
- self::$relationships_loaded = true;
-
- do_action('jvb_cache_relationships_loaded', self::$relationships);
+ // Invalidate specific post cache
+ self::invalidateAll($post_id);
+ // Clear WordPress core post object cache
+ clean_post_cache($post_id);
}
/**
- * Get relationships for a type (for debugging)
- *
- * @param string|null $type Specific type or null for all
- * @return array Relationships
+ * Handle post deletion
*/
- public static function getRelationships(?string $type = null): array
+ public static function onPostDelete(int $post_id): void
{
- self::loadRelationships();
-
- if ($type !== null) {
- return self::$relationships[jvbNoBase($type)] ?? [];
- }
-
- return self::$relationships;
- }
-
- /**
- * Cascade invalidation to related types based on relationships
- *
- * @param string $type Primary type being invalidated
- * @param mixed $context Context with relationship data
- */
- /**
- * Cascade invalidation to related types based on relationships
- */
- private static function cascadeInvalidation(string $type, $context): void
- {
- self::loadRelationships();
-
- $relationships = self::$relationships[$type] ?? [];
- if (empty($relationships)) {
+ $post = get_post($post_id);
+ if (!$post) {
return;
}
- $data = self::extractContext($context);
+ $post_type = jvbNoBase($post->post_type);
- // Author relationship - SIMPLIFIED
- if (!empty($relationships['author'])) {
- $user_ids = self::extractUserIds($data, $relationships['author']);
-
- foreach ($user_ids as $user_id) {
- // Single clean call - handles content, profile, everything
- self::invalidateAll("user_{$user_id}");
- }
- }
-
- // Taxonomy relationships
- if (!empty($relationships['taxonomies']) && !empty($data['ID'])) {
- foreach ($relationships['taxonomies'] as $taxonomy) {
- $taxonomy_full = jvbCheckBase($taxonomy);
- $terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']);
-
- if (!empty($terms) && !is_wp_error($terms)) {
- self::updateTimestamp($taxonomy);
- wp_cache_flush_group($taxonomy);
- }
- }
- }
-
- // Content type relationships (for taxonomies)
- if (!empty($relationships['content_types'])) {
- foreach ($relationships['content_types'] as $content_type) {
- self::updateTimestamp($content_type);
- wp_cache_flush_group($content_type);
- }
- }
-
- // Generic related caches
- if (!empty($relationships['related'])) {
- foreach ($relationships['related'] as $related_type) {
- self::updateTimestamp($related_type);
- wp_cache_flush_group($related_type);
- }
- }
-
- // Custom cascade function
- if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) {
- call_user_func($relationships['cascade'], $type, $data);
- }
+ self::invalidateAll($post_type);
+ self::invalidateAll($post_id);
+ // Clear WordPress core post object cache
+ clean_post_cache($post_id);
}
/**
- * Extract user IDs from context based on relationship config
- * Supports multiple authors, contributors, etc.
- *
- * @param array $data Context data
- * @param mixed $config Author relationship config (bool or array)
- * @return array User IDs to invalidate
+ * Handle term save/update
*/
- private static function extractUserIds(array $data, $config): array
+ public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
{
- $user_ids = [];
+ // Clear WordPress core term cache
+ clean_term_cache($term_id, $taxonomy);
+ $taxonomy = jvbNoBase($taxonomy);
- // Simple case: 'author' => true
- if ($config === true) {
- if (!empty($data['post_author'])) {
- $user_ids[] = $data['post_author'];
- }
- return array_filter($user_ids);
- }
+ // Invalidate taxonomy cache
+ self::invalidateAll($taxonomy);
- // Advanced case: 'author' => ['post_author', 'contributors', 'linked_user']
- if (is_array($config)) {
- foreach ($config as $field) {
- // Handle meta fields
- if (str_starts_with($field, 'meta:') && !empty($data['ID'])) {
- $meta_key = substr($field, 5);
- $value = get_post_meta($data['ID'], BASE . $meta_key, true);
-
- if (is_array($value)) {
- $user_ids = array_merge($user_ids, $value);
- } elseif ($value) {
- $user_ids[] = $value;
- }
- }
- // Handle direct data fields
- elseif (!empty($data[$field])) {
- if (is_array($data[$field])) {
- $user_ids = array_merge($user_ids, $data[$field]);
- } else {
- $user_ids[] = $data[$field];
- }
- }
- }
- }
-
- // Callable: 'author' => function($data) { return [...user_ids]; }
- if (is_callable($config)) {
- $result = call_user_func($config, $data);
- if (is_array($result)) {
- $user_ids = array_merge($user_ids, $result);
- } elseif ($result) {
- $user_ids[] = $result;
- }
- }
-
- return array_unique(array_filter(array_map('intval', $user_ids)));
+ // Invalidate specific term cache
+ self::invalidateAll($term_id);
}
/**
- * Extract context data from various formats
- * Converts WP objects to arrays with relevant data
- *
- * @param mixed $context Post/Term object, array, or ID
- * @return array Normalized context data
+ * Handle term deletion
*/
- private static function extractContext($context): array
+ public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
{
- if (is_array($context)) {
- return $context;
- }
+ // Clear WordPress core term cache
+ clean_term_cache($term_id, $taxonomy);
+ $taxonomy = jvbNoBase($taxonomy);
- if ($context instanceof \WP_Post) {
- return [
- 'ID' => $context->ID,
- 'post_author' => $context->post_author,
- 'post_type' => $context->post_type,
- 'post_status' => $context->post_status,
- ];
- }
+ self::invalidateAll($taxonomy);
+ self::invalidateAll($term_id);
+ }
- if ($context instanceof \WP_Term) {
- return [
- 'term_id' => $context->term_id,
- 'taxonomy' => $context->taxonomy,
- 'parent' => $context->parent,
- ];
- }
+ /**
+ * Handle user update
+ */
+ public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
+ {
+ // Invalidate user-specific cache
+ self::invalidateAll($user_id);
- if (is_numeric($context)) {
- $post = get_post($context);
- if ($post) {
- return self::extractContext($post);
+ // Invalidate user role caches if roles changed
+ if ($old_user_data) {
+ $user = get_userdata($user_id);
+ if ($user && $user->roles !== $old_user_data->roles) {
+ foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
+ self::invalidateAll($role);
+ }
}
}
+ // Clear WordPress core user cache
+ clean_user_cache($user_id);
+ }
- return [];
+ /**
+ * Handle user deletion
+ */
+ public static function onUserDelete(int $user_id): void
+ {
+ self::invalidateAll($user_id);
+ // Clear WordPress core user cache
+ clean_user_cache($user_id);
+ }
+
+ /**
+ * Handle post meta updates
+ */
+ public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ return;
+ }
+
+ self::onPostSave($post_id, $post);
+ }
+ public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ return;
+ }
+
+ self::onPostSave($post_id, $post);
+ }
+
+ /**
+ * Handle term meta updates
+ */
+ public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $term = get_term($term_id);
+ if (!$term || is_wp_error($term)) {
+ return;
+ }
+
+ self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+ }
+
+ public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $term = get_term($term_id);
+ if (!$term || is_wp_error($term)) {
+ return;
+ }
+
+ self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+ }
+
+ /**
+ * Handle user meta updates
+ */
+ public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $user = get_userdata($user_id);
+ if (!$user) {
+ return;
+ }
+
+ self::onUserUpdate($user_id, null);
+ }
+
+ public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
+ {
+ if (!str_starts_with($meta_key, BASE)) {
+ return;
+ }
+
+ $user = get_userdata($user_id);
+ if (!$user) {
+ return;
+ }
+
+ self::onUserUpdate($user_id, null);
}
}
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 10a8a4a..a7e6790 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -169,7 +169,7 @@
// For valid dashboard pages, check access permissions
if (!is_404()) {
- $page = $this->getCurrentPage();
+ $page = $this->getCurrentPageSlug();
// Dashboard home is always accessible (if authenticated)
if ($page === '' || $page === 'dash') {
@@ -178,7 +178,7 @@
// Check if page exists in allowed pages
$allowedPages = $this->getUserAllowedPages();
- if (!in_array($page, $allowedPages)) {
+ if (!array_key_exists($page, $allowedPages)) {
error_log("User not allowed to access page: {$page}");
$this->redirectToDashboard();
return;
@@ -193,15 +193,17 @@
public function buildDashboard():void
{
$manageableContent = $this->getAllDashboardPages();
- foreach ($manageableContent as $key => $slug) {
- if ($slug === 'dash') {
+ foreach ($manageableContent as $slug => $page) {
+ if ($page === 'dash') {
continue;
}
$existing = get_page_by_path($slug, OBJECT, BASE.'dash');
if ($existing) {
continue;
}
- $title = $this->getTitle($slug);
+ $title = $page;
+
+ $slug = $this->getSlug($slug, $page);
$ID = wp_insert_post(array(
'post_title' => $title,
@@ -305,11 +307,11 @@
}
// Get current page/section
- $page = $this->getCurrentPage();
+ $page = $this->getCurrentPageTitle();
$config = $this->getConfig($page);
if(!empty($config)) {
add_filter('jvbLoadingIcon', function() use ($config) {
- return jvbIcon($config['icon']);
+ return $config['icon'];
});
}
$integrationSlugs = array_map(function($name) {
@@ -515,7 +517,7 @@
wp_enqueue_script('jvb-content');
wp_enqueue_script('jvb-crud');
- $page = $this->getCurrentPage();
+ $page = $this->getCurrentPageSlug();
switch ($page) {
case 'notifications':
@@ -575,7 +577,20 @@
do_action('jvbDashScripts', $page);
}
- protected function getCurrentPage():string
+ protected function getCurrentPageTitle():string
+ {
+ if (is_post_type_archive(BASE.'dash')) {
+ return 'dash';
+ }
+
+ global $post;
+ if (!$post) {
+ return '';
+ }
+
+ return $post->post_title;
+ }
+ protected function getCurrentPageSlug():string
{
if (is_post_type_archive(BASE.'dash')) {
return 'dash';
@@ -588,6 +603,29 @@
return $post->post_name;
}
+ protected function getIcon(string $slug, string $page):string
+ {
+ return $this->cache->remember('icon_'.sanitize_title($page), function() use ($slug, $page) {
+ $icon = sanitize_title($page);
+ if (!is_numeric($slug)) {
+ $config = Features::getConfig($slug);
+ if (array_key_exists('icon', $config)) {
+ $icon = $config['icon'];
+ }
+ }
+ return $icon;
+ });
+ }
+ protected function getSlug(string $slug, string $page):string
+ {
+ return $this->cache->remember('slug_'.sanitize_title($page), function() use ($slug, $page) {
+ if (!is_numeric($slug)) {
+ return $slug;
+ } else {
+ return sanitize_title($page);
+ }
+ });
+ }
protected function renderHeader():void
{
@@ -612,7 +650,7 @@
<link rel="preconnect" href="<?= get_home_url()?>"/>
<?php wp_head(); ?>
</head>
- <body class="dashboard<?= ' '.$this->getCurrentPage()?>">
+ <body class="dashboard<?= ' '.$this->getCurrentPageSlug()?>">
<?php jvbAccessibility();?>
<header>
<?php
@@ -663,28 +701,28 @@
<?= TaxonomySelector::outputSelectorModal() ?>
<nav class="dashboard-nav">
<?php
- $current_page = $this->getCurrentPage();
+ $current_page = $this->getCurrentPageSlug();
$pages = $this->getUserAllowedPages()?:[];
echo '<ul>';
- foreach ($pages as $page) {
- // Add data-page attribute for the navigator
- $active = ($current_page == $page) ? ' class="current"' : '';
- $current = ($current_page == $page) ? ' aria-current="page"' : '';
- $config = $this->getConfig($page);
- $icon = $config['icon']??$page;
- $title = ucwords(str_replace('-', ' ', $page));
+ foreach ($pages as $slug => $page) {
+ $slug = $this->getSlug($slug, $page);
+ $icon = $this->getIcon($slug, $page);
+ // Add data-page attribute for the navigator
+ $active = ($current_page == $slug) ? ' class="current"' : '';
+ $current = ($current_page == $slug) ? ' aria-current="page"' : '';
- $link = ($page === 'dash') ? '/'.$page : "/dash/$page";
+
+ $link = ($page === 'dash') ? '/'.$page : "/dash/$slug";
printf(
'<li%s><a href="%s"%s data-page="%s" data-dash title="%s">%s<span>%s</span></a></li>',
$active,
get_home_url(null, $link),
$current,
+ $slug,
$page,
- $title,
- jvbIcon($icon, ['title'=> $title]),
- $title
+ jvbIcon($icon, ['title'=> $page]),
+ $page
);
}
@@ -695,7 +733,7 @@
<?php
- do_action('jvbRenderDashboardSettings', $this->getCurrentPage());
+ do_action('jvbRenderDashboardSettings', $this->getCurrentPageSlug());
?>
<?php wp_footer(); ?>
@@ -717,7 +755,6 @@
echo '<p>Welcome back!</p>';
$pages = $this->getUserAllowedPages();
- error_log('Pages: '.print_r($pages, true));
echo '<h2>What would you like to do today?</h2>';
@@ -729,15 +766,9 @@
$title = $this->getTitle($page);
$description = $this->getDescription($page);
- $icon = $page;
- if (!is_numeric($slug)) {
- $config = Features::getConfig($slug);
- if (array_key_exists('icon', $config)) {
- $icon = $config['icon'];
- }
- } else {
- $slug = sanitize_title($page);
- }
+
+ $slug = $this->getSlug($slug, $page);
+ $icon = $this->getIcon($slug, $page);
if ($title !== '') {
echo '<li><p><a href="'.get_home_url(null, '/dash/'.$slug.'/').'"
data-page="'.$slug.'" data-dash>'.jvbIcon($icon).ucwords($title).'</a></p></li>';
@@ -1104,52 +1135,52 @@
// Add feature-dependent pages (non-config)
if (Features::forSite()->has('referrals')) {
- $pages[] = 'referrals';
+ $pages[] = 'Referrals';
}
if (Features::forMembership()->has('can_invite')) {
- $pages[] = 'invites';
+ $pages[] = 'Invites';
}
if (Features::forMembership()->has('term_approval')) {
- $pages[] = 'approvals';
+ $pages[] = 'Approvals';
}
if (Features::forMembership()->has('forum')) {
- $pages[] = 'news';
+ $pages[] = 'News';
}
if (Features::forMembership()->has('member_content')) {
- $pages[] = 'metrics';
+ $pages[] = 'Metrics';
}
if (Features::forSite()->has('favourites')) {
- $pages[] = 'favourites';
+ $pages[] = 'Favourites';
}
if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) {
- $pages[] = 'karmic-score';
+ $pages[] = 'Karmic Score';
}
if (Features::forSite()->has('notifications')) {
- $pages[] = 'notifications';
+ $pages[] = 'Notifications';
}
if (Features::forSite()->has('support')) {
- $pages[] = 'support';
+ $pages[] = 'Support';
}
if (Features::hasAnyIntegration()) {
- $pages[] = 'integrations';
+ $pages[] = 'Integrations';
}
// Add all content types (with config keys)
foreach (JVB_CONTENT as $slug => $config) {
- $pages[$slug] = sanitize_title($config['plural']);
+ $pages[$slug] = $config['plural'];
}
foreach (JVB_TAXONOMY as $slug=>$config) {
- $pages[$slug] = sanitize_title($config['plural']);
+ $pages[$slug] = $config['plural'];
}
// Allow filtering
diff --git a/inc/managers/IconsManager.php b/inc/managers/IconsManager.php
index d3710d0..8278763 100644
--- a/inc/managers/IconsManager.php
+++ b/inc/managers/IconsManager.php
@@ -17,6 +17,7 @@
protected array $customIcons = [];
protected array $usedIcons = [];
protected array $map = [];
+ protected const MAX_VERSIONS = 5;
/**
* Get singleton instance
@@ -31,6 +32,7 @@
private function __construct()
{
$this->cache = CacheManager::for('icons', WEEK_IN_SECONDS);
+
$this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
? JVB_SITE['icons']
: 'regular';
@@ -44,36 +46,35 @@
]);
- $this->usedIcons = get_option(BASE.'used_icons', []);
+ $this->usedIcons = get_option(BASE.'usedIcons', []);
$this->includeIcons();
+ // Track custom icons for CSS generation
+ $this->trackCustomIcons();
// Register hooks only once
$this->registerHooks();
}
/**
- * Include icons via filter (for JS usage, etc.)
+ * Ensure custom icons are tracked for CSS generation
*/
- protected function includeIcons():array
+ protected function trackCustomIcons(): void
{
- $icons = get_option(BASE.'includeIcons');
-// $icons = false;
- if (!$icons) {
- $icons = $this->addIncludeIcons();
- }
- $include = apply_filters('jvbIncludeIcons', []);
- $add = array_filter($include, function($addIt) use ($icons) {
- return !in_array($addIt, $icons);
- });
- error_log('Adding icons: '.print_r($add, true));
- if (!empty($add)) {
- $this->addIncludeIcons($add);
+ if (empty($this->customIcons)) {
+ return;
}
- return $icons;
+ foreach ($this->customIcons as $name => $path) {
+ $this->trackIconUsage($name, $this->style);
+ }
}
- protected function addIncludeIcons(array $add = []) {
+ /**
+ * Include icons via filter (for JS usage, etc.)
+ */
+ protected function includeIcons():void
+ {
$icons = get_option(BASE.'includeIcons');
+
if (!$icons) {
$icons = [
'check-circle',
@@ -120,6 +121,7 @@
'file-txt',
'file-xls',
];
+
$check = [JVB_CONTENT, JVB_TAXONOMY, JVB_USER];
foreach ($check as $constant) {
foreach ($constant as $key => $value) {
@@ -129,20 +131,77 @@
}
}
$icons = apply_filters('jvbIncludeIcons', $icons);
-
+ $icons = $this->maybePrefixIcons($icons);
update_option(BASE.'includeIcons', $icons);
- update_option(BASE.'icons_needs_update', true);
}
- $add = apply_filters('jvbIncludeIcons', $add);
- $add = array_filter($add, function($addIt) use ($icons) {
- return !in_array($addIt, $icons);
- });
- if (!empty($add)) {
- $icons = array_merge($add, $icons);
- update_option(BASE.'usedIcons', $icons);
- update_option(BASE.'icons_needs_update', true);
+
+ // Ensure icons are in the correct format (handle legacy data)
+ if (!$this->isIconsArrayPrefixed($icons)) {
+ $icons = $this->maybePrefixIcons($icons);
+ update_option(BASE.'includeIcons', $icons);
}
- return $icons;
+
+ $additional = apply_filters('jvbIncludeIcons', []);
+ if (!empty($additional)) {
+ $additional = $this->maybePrefixIcons($additional);
+ $merged = $this->mergeUsedIcons($icons, $additional);
+
+ if ($icons != $merged) {
+ update_option(BASE.'includeIcons', $merged);
+ $icons = $merged;
+ }
+ }
+
+ foreach ($icons as $style => $theIcons) {
+ foreach($theIcons as $icon) {
+ $this->trackIconUsage($icon, $style);
+ }
+ }
+ }
+
+ /**
+ * Check if icons array is in the prefixed format [style => [icons]]
+ */
+ protected function isIconsArrayPrefixed(array $icons): bool
+ {
+ if (empty($icons)) {
+ return true;
+ }
+
+ // Check if first key is a valid style name
+ $first_key = array_key_first($icons);
+ if (!in_array($first_key, $this->styles)) {
+ return false;
+ }
+
+ // Check if first value is an array
+ return is_array($icons[$first_key]);
+ }
+
+ protected function maybePrefixIcons(array $icons):array
+ {
+ $out = [];
+ foreach ($icons as $icon) {
+ if (is_array($icon) && array_key_exists('style', $icon)) {
+ if (!array_key_exists($icon['style'], $out)) {
+ $out[$icon['style']] = [];
+ }
+ if (!in_array($icon['name'], $out[$icon['style']])) {
+ $out[$icon['style']][] = $icon['name'];
+ }
+ } elseif(is_array($icon)) {
+ $icon = $icon['name'];
+ }
+ if (!is_array($icon)) {
+ if (!array_key_exists($this->style, $out)) {
+ $out[$this->style] = [];
+ }
+ if (!in_array($icon, $out[$this->style])){
+ $out[$this->style][] = $icon;
+ }
+ }
+ }
+ return $out;
}
protected function addMap():void
@@ -173,7 +232,8 @@
*/
protected function registerHooks(): void
{
- add_action('init', [$this, 'checkCSS']);
+ add_action('init', [$this, 'includeIcons'], 1);
+ add_action('init', [$this, 'checkCSS'], 10);
add_action('wp_enqueue_scripts', [$this, 'enqueueIconStyles']);
add_action('admin_enqueue_scripts', [$this, 'enqueueIconStyles']);
}
@@ -192,7 +252,15 @@
{
error_log('[IconsManager]:regenerateCSS');
$css = $this->generateIconCSS();
- $css_path = JVB_DIR.'/assets/css/icons.css';
+ $css_path = JVB_CHILD_DIR.'/assets/css/';
+ if (!file_exists($css_path)) {
+ wp_mkdir_p($css_path);
+ }
+ $css_path .= '/icons.css';
+
+
+ // Archive current version before overwriting
+ $this->archiveCurrentVersion($css);
if (file_put_contents($css_path, $css) !== false) {
CacheManager::updateTimestamp('icons');
@@ -228,24 +296,23 @@
*/
public function getIcon(string $name, array $options = []): string
{
- if (!array_key_exists('style', $options)) {
- $options['style'] = $this->style;
- }
+ $style = array_key_exists('style', $options) ? $options['style'] :$this->style;
$name = (array_key_exists($name, $this->map)) ? $this->map[$name] : $name;
// Validate icon exists
- if (!$this->iconExists($name, $options['style'])) {
+ if (!$this->iconExists($name, $style)) {
error_log('[IconsManager] Icon not found: ' . $name);
return '';
}
- $style = $options['style'] ?? $this->style;
+
// Track icon usage
$this->trackIconUsage($name, $style);
+ $styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
// Build classes
- $classes = ['icon', 'icon-' . $name];
+ $classes = ['icon', 'icon-' . $name.$styleClass];
if (!empty($options['class'])) {
$classes[] = $options['class'];
}
@@ -264,13 +331,24 @@
*/
protected function trackIconUsage(string $name, string $style): void
{
+ $needsUpdate = false;
+
if (!array_key_exists($style, $this->usedIcons)) {
$this->usedIcons[$style] = [];
+ $needsUpdate = true;
}
if (!in_array($name, $this->usedIcons[$style])) {
$this->usedIcons[$style][] = $name;
- update_option(BASE.'used_icons', $this->usedIcons);
+ $needsUpdate = true;
+ }
+
+ if ($needsUpdate) {
+ // Merge with existing option to never lose icons
+ $existing = get_option(BASE.'usedIcons', []);
+ $merged = $this->mergeUsedIcons($existing, $this->usedIcons);
+ update_option(BASE.'usedIcons', $merged);
+
// Flag for regeneration on next init
update_option(BASE.'icons_needs_update', true);
@@ -357,7 +435,7 @@
wp_enqueue_style(
'jvb-icons',
- JVB_URL.'assets/css/icons.css',
+ JVB_CHILD_URL.'assets/css/icons.css',
[],
$timestamp
);
@@ -369,10 +447,11 @@
protected function generateIconCSS(): string
{
$css = '';
+ $this->mergeUsedIcons();
+
foreach ($this->usedIcons as $style => $icons) {
$styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
foreach ($icons as $icon) {
-
$svg = $this->getEncodedSVG($icon, $style);
if ($svg !== '') {
$css .= ".icon-{$icon}{$styleClass}{";
@@ -384,6 +463,34 @@
return $this->minifyCss($css);
}
+ protected function mergeUsedIcons(array|bool $oldIcons = true, array|bool $newIcons = true):array
+ {
+ $set = false;
+ if ($oldIcons === true) {
+ $oldIcons = $this->usedIcons;
+ $set = true;
+ }
+ if ($newIcons === true) {
+ $history = $this->getVersionHistory();
+ $newIcons = (count($history) > 0) ? $history[0]['iconList'] : [];
+ }
+ foreach ($newIcons as $style => $icons) {
+ if (!isset($oldIcons[$style])) {
+ //Style doesn't exist in previous set, add the whole thing
+ $oldIcons[$style] = $icons;
+ } else {
+ $oldIcons[$style] = array_unique(
+ array_merge($oldIcons[$style], $icons)
+ );
+ }
+ }
+ if ($set) {
+ $this->usedIcons = $oldIcons;
+ update_option(BASE.'usedIcons', $oldIcons);
+ }
+ return $oldIcons;
+ }
+
protected function minifyCSS(string $css): string
{
// Remove comments
@@ -428,7 +535,123 @@
public function clearIconCache(): void
{
delete_option(BASE . 'icon_usage_list'); // Clear DB option
+ delete_option(BASE.'usedIcons');
+ delete_option(BASE.'includeIcons');
+ delete_option(BASE.'iconMap');
$this->cache->delete('icon_styles_css');
CacheManager::updateTimestamp('icons');
}
+
+ protected function archiveCurrentVersion(string $css): void
+ {
+ $history = $this->getVersionHistory();
+
+ $icon_count = 0;
+ foreach ($this->usedIcons as $style => $icons) {
+ $icon_count += count($icons);
+ }
+
+ $newEntry = [
+ 'css' => $css,
+ 'iconList' => $this->usedIcons,
+ 'timestamp' => time(),
+ 'icon_count' => $icon_count,
+ 'size' => strlen($css),
+ 'size_formatted' => size_format(strlen($css), 2)
+ ];
+
+ array_unshift($history, $newEntry);
+
+ if (count($history) > self::MAX_VERSIONS) {
+ $history = array_slice($history, 0, self::MAX_VERSIONS);
+ }
+
+ update_option(BASE.'icon_css_history', $history);
+ }
+
+ public function getVersionHistory(): array
+ {
+ return get_option(BASE.'icon_css_history', []);
+ }
+
+ public function restoreVersion(int $timestamp): bool
+ {
+ $history = $this->getVersionHistory();
+
+ foreach ($history as $entry) {
+ if ($entry['timestamp'] === $timestamp) {
+ $css_path = JVB_DIR . '/assets/css/icons.css';
+
+ // Archive current before restoring
+ $current_css = file_get_contents($css_path);
+ if ($current_css !== false) {
+ $this->archiveCurrentVersion($current_css);
+ }
+
+ // Restore the version
+ if (file_put_contents($css_path, $entry['css']) !== false) {
+ $this->usedIcons = $entry['iconList'];
+ update_option(BASE.'usedIcons', $this->usedIcons);
+ CacheManager::updateTimestamp('icons');
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ error_log("[IconsManager] Version {$timestamp} not found in history");
+ return false;
+ }
+
+ public function forceRefresh(): void
+ {
+ $this->clearIconCache();
+ update_option(BASE.'icons_needs_update', true);
+ CacheManager::updateTimestamp('icons');
+ }
+
+ public function mergeVersions(array $timestamps): bool
+ {
+ if (empty($timestamps)) {
+ return false;
+ }
+
+ $history = get_option(BASE.'icon_css_history', []);
+ $merged_icons = [];
+ // Collect icons from selected versions
+ foreach ($history as $entry) {
+ if (in_array($entry['timestamp'], $timestamps)) {
+ foreach ($entry['iconList'] as $style => $icons) {
+ if (!isset($merged_icons[$style])) {
+ $merged_icons[$style] = [];
+ }
+ // Merge and keep unique
+ $merged_icons[$style] = array_unique(
+ array_merge($merged_icons[$style], $icons)
+ );
+ }
+ }
+ }
+
+ if (empty($merged_icons)) {
+ error_log('[IconsManager] No icons found in selected versions');
+ return false;
+ }
+
+ // Archive current version
+ $current_css = file_get_contents(JVB_DIR . '/assets/css/icons.css');
+ if ($current_css !== false) {
+ $this->archiveCurrentVersion($current_css);
+ }
+
+ // Update used icons and regenerate
+ $this->usedIcons = $merged_icons;
+ update_option(BASE.'usedIcons', $this->usedIcons);
+
+ // Force regeneration
+ $this->regenerateCSS();
+
+ return true;
+ }
}
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 7ded3ba..525a53f 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -45,7 +45,7 @@
{
$this->siteFeatures = Features::forSite();
$this->emailManager = new EmailManager();
- $this->rateLimiter = new AjaxRateLimiter();
+
$this->cache = CacheManager::for('login');
@@ -664,10 +664,7 @@
/*************************************************************************
* SECURITY & VALIDATION
*************************************************************************/
- protected function checkAjaxRateLimit(string $action): bool
- {
- return $this->rateLimiter->checkLimit($action);
- }
+
protected function checkRequestId(): bool
{
$request_id = $_POST['request_id'] ?? '';
@@ -841,8 +838,7 @@
$action = $this->getAction();
ob_start();
?>
- <script type="text/javascript">
- window.checkedEmails = new Set();
+
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('.login form');
if (!form) return;
@@ -863,33 +859,34 @@
window.LoginController.subscribe((event, data) => {
if (event === 'form-submit') {
handleFormSubmission(data);
- } else if (event === 'field-validated' && data.name === 'user_email') {
- if (!window.checkedEmails.has(data.value)){
- checkEmail(data.value, data);
- }
}
});
async function handleFormSubmission(data) {
- const { formId, config, data: formData } = data;
- const form = config.element;
- const submit = form.querySelector('[type=submit]');
- let oldText = submit.textContent;
- // Show uploading status
- window.LoginController.showFormStatus(formId, 'uploading');
+ let realFormData = data.fullData;
+ const { formId, config, data: formData } = data;
- try {
- submit.disabled = true;
- submit.textContent = 'Loading...';
- const response = await fetch(`${jvbSettings.api}<?=($action === 'magic') ? $action : 'auth/'.$action?>`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': jvbSettings.nonce
- },
- body: JSON.stringify(formData)
- });
+
+ const form = config.element;
+
+ const submit = form.querySelector('[type=submit]');
+ let oldText = submit.textContent;
+
+
+ window.LoginController.showFormStatus(formId, 'uploading');
+
+ try {
+ submit.disabled = true;
+ submit.textContent = 'Loading...';
+ const response = await fetch(`${jvbSettings.api}<?=($action === 'magic') ? $action : 'auth/'.$action?>`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': jvbSettings.nonce
+ },
+ body: JSON.stringify(realFormData)
+ });
const result = await response.json();
@@ -927,68 +924,11 @@
submit.disabled = false;
}
}
- async function checkEmail(email, input) {
- window.checkedEmails.add(email);
- let wrapper = input.closest('.field');
- let submit = input.closest('form').querySelector('[type=submit]');
- try {
- submit.disabled = true;
- window.LoginController.showSuccess(wrapper, 'Checking our records...');
- const response = await fetch(`${jvbSettings.api}auth/email`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': jvbSettings.nonce
- },
- body: JSON.stringify({ email: email })
- });
- const result = await response.json();
-
- if (!response.ok) {
- return; // On error, allow to proceed (fail open)
- }
-
- if (result.exists) {
- <?php
- switch ($action) {
- case 'register':
- echo 'window.LoginController.showError(wrapper,\'This email is already registered. Log in instead?\');';
- break;
- case 'login':
- case 'lostpassword':
- case 'magic':
- echo 'window.LoginController.showSuccess(wrapper,\'Email exists in our system.\');';
- break;
- }
- ?>
- } else {
- <?php
- switch ($action) {
- case 'register':
- echo 'window.LoginController.showSuccess(wrapper,\'Email is available!\');';
- break;
- case 'login':
- case 'lostpassword':
- case 'magic':
- echo 'window.LoginController.showError(wrapper,\'This email doesn\\\'t seem to exist in our system. Create account instead?\');';
- break;
- }
- ?>
- }
-
- return true; // Email is available
- } catch (error) {
- console.error('Email check failed:', error);
- return true; // On network error, allow to proceed
- }finally {
- submit.disabled = false;
- }
- }
});
- </script>
+
<?php
$script = ob_get_clean();
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
index 07f2104..4e07c6f 100644
--- a/inc/managers/OperationQueue.php
+++ b/inc/managers/OperationQueue.php
@@ -599,9 +599,7 @@
}
protected function updateLastModified(int $user_id) {
- if (Features::forSite()->has('dashboard')) {
- JVB()->routes('queue')->updateUserQueueTimestamp($user_id);
- }
+ CacheManager::updateTimestamp("user_{$user_id}");
}
protected function deepMerge(array $existing, array $new): array
@@ -1559,8 +1557,8 @@
protected function updateUserQueueTimestamp(int $user_id)
{
- $key = "{$user_id}_queue_timestamp";
- $this->cache->set($key, time());
+
+ CacheManager::updateTimestamp("user_{$user_id}");
}
/**
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 21895b9..953450f 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -624,7 +624,6 @@
private function renderRepeaterField(string $name, mixed $value, array $field):void
{
- error_log('Rendering Repeater Field!');
$values = is_array($value) ? $value : array();
$conditional = $this->handleConditionalField($field);
@@ -722,9 +721,11 @@
protected function renderGroupField(string $name, mixed $value, array $field): void
{
if (!array_key_exists('fields', $field) || empty($field['fields'])) {
+ error_log('No fields to render');
return;
}
+
$values = is_array($value) ? $value : [];
$original = $name;
@@ -744,14 +745,15 @@
$conditional = $this->handleConditionalField($field);
$validationAttrs = $this->buildValidationAttributes($field);
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
+ $fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset';
+ $legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend';
?>
- <fieldset class="field group <?= esc_attr($name) ?>"
+ <<?= $fieldset?> class="field group <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
<?= $validationAttrs ?>
<?= $describedBy ?>>
- <legend><?= esc_html($field['label']) ?></legend>
+ <<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>>
<?php $this->renderHintAndDescription($field, $name); ?>
@@ -760,7 +762,7 @@
</div>
<span class="validation-message" hidden role="alert"></span>
- </fieldset>
+ </<?= $fieldset?>>
<?php
}
@@ -1072,7 +1074,7 @@
<?php jvbRenderProgressBar('',true) ?>
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
<label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ? $attachment : '<img>
+ <?= ($attachment) ?: '<img>
<video></video>
<span></span>' ?>
</label>
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index f8081aa..a1d7dbc 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -333,11 +333,7 @@
}
}
if ($field_config['type'] == 'taxonomy' && (!array_key_exists('taxonomy_type', $field_config))) {
- error_log('Attempting to set taxonomies: ' . print_r($this->object_id, true));
- error_log('Sanitized data: ' . print_r($sanitized, true));
- error_log('Taxonomy: ' . print_r($field_config['taxonomy'], true));
$set = wp_set_post_terms($this->object_id, $sanitized, jvbCheckBase($field_config['taxonomy']), false);
- error_log('Set post terms: ' . print_r($set, true));
}
if ($field_config['type'] === 'location' && empty($sanitized)) {
$this->addMeta('has_map', false);
@@ -1020,7 +1016,8 @@
}
if ($field_config['type'] === 'taxonomy' && !array_key_exists('taxonomy_type', $field_config)){
- $set = wp_set_post_terms($this->object_id, $sanitized, jvbCheckBase($field_config['taxonomy']), false);
+ $term_ids = array_map('intval', explode(',', trim($sanitized)));
+ $set = wp_set_post_terms($this->object_id, $term_ids, jvbCheckBase($field_config['taxonomy']), false);
}
if ($field_config['type'] === 'location' && empty($sanitized)) {
diff --git a/inc/meta/MetaValidator.php b/inc/meta/MetaValidator.php
index 1a01611..40b1725 100644
--- a/inc/meta/MetaValidator.php
+++ b/inc/meta/MetaValidator.php
@@ -43,7 +43,6 @@
protected function validateNumber(float $value, array $field_config):bool|WP_Error
{
- error_log('Number: '.print_r($value, true));
if (empty($value)) {
if (!empty($field['required'])) {
return new WP_Error('required_field', 'This field is required');
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index 1d6c2f4..cd753c4 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -130,45 +130,6 @@
add_action('post_type_link', [$this, 'rewriteTaxonomySingle'], 15, 2);
add_filter('post_type_archive_link', [$this, 'rewriteTaxonomyArchive'], 15, 2);
}
-
- $postType = $this->post_type;
- add_action("save_post_{$this->post_type}", function($post_id, $post, $update) use ($postType) {
- if (jvbNoSaveIt($post_id, $post)) {
- return;
- }
- $this->invalidatePostCache($postType, $post, $update ? 'update' : 'create');
- }, 10, 3);
-
- add_action("delete_post", function($post_id, $post) use ($postType) {
- $post = get_post($post_id);
- if ($post && $post->post_type === $postType) {
- $this->invalidatePostCache($postType, $post, 'delete');
- }
- }, 10, 2);
-
- add_action("transition_post_status", function($new_status, $old_status, $post) use ($postType) {
- if ($post->post_type === $postType && $new_status !== $old_status) {
- $this->invalidatePostCache($postType, $post, 'status_change');
- }
- }, 10, 3);
- }
-
- protected function invalidatePostCache(string $type, $post, string $action) {
- error_log('Clearing Cache for '.print_r($type, true));
-
- $cache = CacheManager::for(jvbNoBase($type))->invalidate();
-
- // Clear related caches (taxonomies attached to this post)
- $taxonomies = get_object_taxonomies($post->post_type);
- foreach ($taxonomies as $taxonomy) {
- $terms = wp_get_post_terms($post->ID, $taxonomy, ['fields' => 'ids']);
- if (!empty($terms)) {
- CacheManager::for(jvbNoBase($taxonomy))->invalidate();
- }
- }
-
- // Trigger custom action for additional handling
- do_action("jvb_cache_invalidated_{$type}", $post, $action);
}
public function hideFromPublic(bool $is_viewable): bool
diff --git a/inc/registry/TaxonomyRegistrar.php b/inc/registry/TaxonomyRegistrar.php
index e31fc5a..489dd12 100644
--- a/inc/registry/TaxonomyRegistrar.php
+++ b/inc/registry/TaxonomyRegistrar.php
@@ -55,12 +55,32 @@
$post_types = array_map(fn($type) => BASE . $type, $this->config['for_content'] ?? []);
register_taxonomy($this->taxonomy, $post_types, $args);
+ $this->maybeAddRewriteRule($args['rewrite']);
if (!empty($this->fields)) {
$meta_registry = new MetaRegistry($this->fields, $this->slug, 'term');
$meta_registry->registerMetaFields();
}
}
+ /**
+ * Add custom rewrite rule for hierarchical taxonomy slugs (e.g., post-type/by/taxonomy-slug)
+ */
+ protected function maybeAddRewriteRule(array $rewrite_config): void
+ {
+ $slug = $rewrite_config['slug'] ?? $this->slug;
+
+ // Only add custom rule if slug contains slashes (hierarchical path)
+ if (!str_contains($slug, '/')) {
+ return;
+ }
+
+ add_rewrite_rule(
+ "^{$slug}/([^/]+)/?$",
+ 'index.php?' . $this->taxonomy . '=$matches[1]',
+ 'top'
+ );
+ }
+
private function buildLabels(string $singular, string $plural): array
{
return [
diff --git a/inc/rest/RegisterRoutes.php b/inc/rest/RegisterRoutes.php
index 1022b56..04c00c9 100644
--- a/inc/rest/RegisterRoutes.php
+++ b/inc/rest/RegisterRoutes.php
@@ -106,7 +106,6 @@
public function handlePOST(WP_REST_Request $request):WP_REST_Response
{
$data = $request->get_params();
- error_log('Handling update with this data: '.print_r($data, true));
$user = $data['user'];
if (!$this->checkUser($user) || !$this->userCheck($user)) {
return new WP_REST_Response([
@@ -114,7 +113,6 @@
'message' => 'Looks like you may not be who you say you are...'
]);
}
- error_log('User passed');
if ($this->route === 'shop' && !$this->checkTerm([
'term_id' => $data['shop'],
'taxonomy' => $this->route
@@ -124,7 +122,6 @@
'message' => 'This shop doesn\'t exist?'
]);
}
- error_log('Processing...');
$queue = JVB()->queue();
unset($data['user']);
$operationID = $data['id'];
@@ -156,7 +153,6 @@
}
protected function handleUpdateOperation(int $userID, array $data):WP_Error|array
{
- error_log('Handling update Operation with route: '.print_r($this->route, true));
if ($this->route === 'options') {
if (!user_can($userID, 'manage_options')) {
return [
@@ -182,7 +178,6 @@
return array_key_exists($v, $this->config['fields']??[]);
}, ARRAY_FILTER_USE_BOTH);
- error_log('Allowed Result: '.print_r($allowed, true));
foreach ($allowed as $name => $value) {
if (empty($value)) {
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 96ccfb7..3d9b2a4 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -6,6 +6,7 @@
use JVBase\managers\OperationQueue;
use JVBase\managers\CacheManager;
use JVBase\managers\NotificationManager;
+use JVBase\utility\Features;
use WP_REST_Request;
use WP_Error;
use Exception;
@@ -36,7 +37,7 @@
protected NotificationManager $notifications;
protected string $cache_name ='';
protected int $cache_ttl = 3600; //1 hour default
-
+ protected array $response_headers = [];
// Error code constants for consistency
const ERROR_MISSING_PARAMS = 'missing_parameters';
@@ -906,6 +907,19 @@
$lock_key = 'op_lock_' . md5($operation_key);
delete_transient($lock_key);
}
+
+ 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);
+ }
}
//
//Simple example:
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index dfa9cb6..e1b457a 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -88,17 +88,44 @@
$config = Features::getConfig($content);
$this->fields = $config['fields'];
- $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
- if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
+ $this->timelineSharedFields = $this->getTimelineSharedFields($content);
+ array_unshift($this->timelineSharedFields, 'post_thumbnail');
+ array_unshift($this->timelineSharedFields, 'post_title');
+ array_unshift($this->timelineSharedFields, 'post_status');
+
+ $this->timelineUniqueFields = $this->getTimelineUniqueFields($content);
+ }
+ public function getTimelineUniqueFields(string $content):array
+ {
+ $content = jvbNoBase($content);
+ if (!Features::forContent($content)->has('is_timeline')){
+ return [];
+ }
+ $config = Features::getConfig($content);
+ $allFields = $config['fields'];
+
+ return array_keys(array_filter($allFields, function ($field) {
+ if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
return true;
}
return false;
}));
- array_unshift($this->timelineSharedFields, 'post_thumbnail');
- array_unshift($this->timelineSharedFields, 'post_title');
+ }
- $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
- if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
+ public function getTimelineSharedFields(string $content):array
+ {
+ $content = jvbNoBase($content);
+ if (!Features::forContent($content)->has('is_timeline')){
+ return [];
+ }
+ $config = Features::getConfig($content);
+ if (!$config || empty($config)) {
+ return [];
+ }
+ $allFields = $config['fields']??[];
+
+ return array_keys(array_filter($allFields, function ($field) {
+ if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
return true;
}
return false;
@@ -198,9 +225,6 @@
public function handleContentRequest(WP_REST_Request $request):WP_REST_Response
{
$params = $request->get_params();
- error_log('handleContentRequest params: '.print_r($params, true));
-
- error_log('Fetching content. Params: '.print_r($params, true));
$user_id = $params['user'];
if (!$this->userCheck($user_id)) {
return new WP_REST_Response([
@@ -251,10 +275,6 @@
$args['s'] = sanitize_text_field($params['search']);
}
-
-
- error_log('Content Routes final args: '.print_r($args, true));
-
$key = $this->cache->generateKey($args);
// Check HTTP cache headers with the specific content type
$content_type = $params['content'] ?? $params['type'];
@@ -339,7 +359,7 @@
$results = [];
foreach ($posts as $ID => $post_data) {
- if (Features::forContent($post_data['content'])->has('is_timeline')) {
+ if (Features::forContent($post_data['content'])->has('is_timeline') && array_key_exists('timeline', $post_data)) {
$results[$ID] =$this->processTimelinePost($ID, $post_data);
continue;
}
@@ -416,7 +436,6 @@
error_log('Allowed Fields: '.print_r($allowedFields, true));
$meta = new MetaManager($ID, 'post');
$success = $meta->setAll($allowedFields);
- error_log('Should be set?');
$results[$ID] = [
'success' => $success
];
@@ -458,62 +477,138 @@
return ['success' => false, 'message' => 'No permission'];
}
+ $ignore = ['content', 'user'];
$this->fields = jvbGetFields($post_data['content']);
$this->initTimelineFields($post_data['content']);
- error_log('Received Data: '.print_r($post_data, true));
- // First, process the main fields that will apply to all posts
- $sharedData = array_filter($post_data, function ($key) {
- return in_array($key, $this->timelineSharedFields);
+ // Get parent post details
+ $parent_post = get_post($parent_id);
+ $parent_title = $parent_post->post_title;
+ $parent_is_published = ($parent_post->post_status === 'publish');
+
+ // Extract shared data from top level (excluding post_thumbnail which is unique per post)
+ $sharedData = array_filter($post_data, function ($key) use ($ignore) {
+ return in_array($key, $this->timelineSharedFields)
+ && !in_array($key, $ignore)
+ && $key !== 'post_thumbnail';
}, ARRAY_FILTER_USE_KEY);
- //Next, process any individual posts, including any menu order changes
+ // If no shared post_title at top level, extract from first timeline entry
+ if (!isset($sharedData['post_title']) && isset($post_data['timeline'][0]['post_title'])) {
+ $sharedData['post_title'] = $post_data['timeline'][0]['post_title'];
+ }
+ $clearParent = false;
if (array_key_exists('timeline', $post_data) && is_array($post_data['timeline'])) {
- $sharedTaxonomies = $sharedData;
- unset($sharedTaxonomies['post_title']);
+ // Remove post_title and post_thumbnail from shared taxonomies
+ $sharedTaxonomies = array_filter($sharedData, function($key) {
+ return $key !== 'post_title' && $key !== 'post_thumbnail';
+ }, ARRAY_FILTER_USE_KEY);
- //Ensure the parent post exists and is still first in the array
+ // Ensure the parent post exists and is still first in the array
$index = array_search((string)$parent_id, array_column($post_data['timeline'], 'id'));
- error_log('Found index: '.print_r($index, true));
+
if ($index === false) {
return [
'success' => false,
- 'message' => 'Missing parent id. This should not have happened'
+ 'message' => 'Missing parent id. This should not have happened'
];
- } elseif ($index !== 0) {
- // Move that element to the start of the array
- $item = $post_data['timeline'][$index];
- unset($post_data['timeline'][$index]);
- array_unshift($post_data['timeline'], $item);
}
+
+ if ($index !== 0) {
+ $new_parent_id = $post_data['timeline'][0]['id'];
+
+ if (is_numeric($new_parent_id) && (int)$new_parent_id > 0) {
+ $new_parent_id = (int)$new_parent_id;
+ wp_update_post([
+ 'ID' => $new_parent_id,
+ 'post_parent' => 0
+ ]);
+
+ wp_update_post([
+ 'ID' => $parent_id,
+ 'post_parent' => $new_parent_id
+ ]);
+
+ $existing_children = get_children([
+ 'post_parent' => $parent_id,
+ 'fields' => 'ids'
+ ]);
+
+ foreach ($existing_children as $child_id) {
+ if ($child_id !== $new_parent_id) {
+ wp_update_post([
+ 'ID' => $child_id,
+ 'post_parent' => $new_parent_id
+ ]);
+ }
+ }
+
+ // Update parent references
+ $parent_id = $new_parent_id;
+ $parent_post = get_post($parent_id);
+ $parent_title = $parent_post->post_title;
+ $parent_is_published = ($parent_post->post_status === 'publish');
+ } else {
+ $item = $post_data['timeline'][$index];
+ unset($post_data['timeline'][$index]);
+ array_unshift($post_data['timeline'], $item);
+ }
+ }
+
$errors = [];
$success = [];
- // Get existing children to track deletions
$existing_children = get_children([
'post_parent' => $parent_id,
- 'post_type' => jvbCheckBase($post_data['content']),
- 'fields' => 'ids'
+ 'orderby' => 'menu_order',
+ 'post_status' => ['publish', 'draft'],
+ 'fields'=> 'ids'
]);
- //Iterate through the timeline posts
+
+ $prevDate = null;
+
foreach($post_data['timeline'] as $order => $timeline) {
- $allowedFields = array_filter($timeline, function($key) {
- return in_array($key, $this->timelineUniqueFields);
+ // Get unique fields for this specific timeline entry
+ $allowedFields = array_filter($timeline, function($key) use ($ignore) {
+ return in_array($key, $this->timelineUniqueFields) && !in_array($key, $ignore);
}, ARRAY_FILTER_USE_KEY);
- $allowedFields['post_title'] = $allowedFields['post_title'] ?? $sharedData['post_title'].' - Treatment #'.$order;
- $allowedFields = array_merge($allowedFields, $sharedTaxonomies);
+ // Determine the post title
+ $is_parent = ((int)$timeline['id'] === $parent_id);
+ $provided_title = $timeline['post_title'] ?? '';
+ $auto_generated_pattern = '/^.+Treatment #?\d+$/'; // Matches "Title - Treatment #1" or "Title - Treatment 1"
+
+ if ($is_parent) {
+ // Parent keeps its own title or uses shared title
+ $allowedFields['post_title'] = $provided_title ?: ($sharedData['post_title'] ?? $parent_title);
+ } else {
+ // For child posts, auto-generate if:
+ // 1. No title provided, OR
+ // 2. Title matches auto-generated pattern (meaning it wasn't customized)
+ if (empty($provided_title) || preg_match($auto_generated_pattern, $provided_title)) {
+ $allowedFields['post_title'] = 'Treatment ' . $order;
+ } else {
+ // Keep custom title
+ $allowedFields['post_title'] = $provided_title;
+ }
+ }
+
+ // Merge with shared taxonomies AFTER setting unique fields
+ $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
+
+ // Handle post creation if needed
if (!array_key_exists('id', $timeline) || !is_numeric($timeline['id'])) {
$newChild = wp_insert_post([
- 'post_author' => $this->user_id,
- 'post_type' => jvbCheckBase($post_data['content']),
- 'post_title' => $allowedFields['post_title'],
- 'post_parent' => $parent_id,
- 'menu_order' => $order
+ 'post_author' => $this->user_id,
+ 'post_type' => jvbCheckBase($post_data['content']),
+ 'post_title' => $allowedFields['post_title'],
+ 'post_parent' => $parent_id,
+ 'menu_order' => $order,
+ 'post_status' => $parent_is_published ? 'publish' : 'draft'
]);
if (!$newChild || is_wp_error($newChild)) {
$errors[] = [
- 'message' => 'Could not create child post',
- 'data' => $timeline
+ 'message' => 'Could not create child post',
+ 'data' => $timeline
];
continue;
}
@@ -524,42 +619,98 @@
unset($existing_children[array_search((int)$timeline['id'], $existing_children)]);
}
- //Determine which fields to update
- $meta = new MetaManager($timeline['id'], 'post');
- $oldValues = $meta->getAll(array_keys($allowedFields));
- $updateValues = array_filter($allowedFields, function($value, $key) use ($oldValues) {
- return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
- }, ARRAY_FILTER_USE_BOTH);
- $meta->setAll($updateValues);
- //Update Menu Order, if applicable
- if ((int) $timeline['id'] !== $parent_id) {
- $post = get_post((int) $timeline['id']);
- if ($post && $post->menu_order !== $order) {
- $updated = wp_update_post([
- 'ID' => $post->ID,
- 'menu_order' => $order,
- ]);
- if (!$updated || is_wp_error($updated)) {
- $errors[] = [
- 'message' => 'Could not update timeline order for post',
- 'data' => $timeline
- ];
+ // Update post status and menu order
+ $post_updates = ['ID' => $timeline['id']];
+
+ if (!$is_parent) {
+ $post_updates['menu_order'] = $order;
+
+ // Auto-publish child if parent is published
+ if ($parent_is_published) {
+ $current_post = get_post($timeline['id']);
+ if ($current_post && $current_post->post_status !== 'publish') {
+ $post_updates['post_status'] = 'publish';
}
}
}
+
+ if (count($post_updates) > 1) {
+ $result = wp_update_post($post_updates);
+ error_log('Updated post '.$timeline['id'].' with: '.print_r($post_updates, true).' Result: '.$result);
+ $clearParent = true;
+ }
+
+ // Update metadata
+ $meta = new MetaManager($timeline['id'], 'post');
+ $oldValues = $meta->getAll(array_keys($allowedFields));
+
+ // Set number taxonomy to menu_order (always update for reordering)
+ if (!$is_parent) {
+ $number_value = $order;
+ $term = get_term_by('name', (string)$number_value, BASE.'number');
+ if (!$term) {
+ $result = wp_insert_term((string)$number_value, BASE.'number');
+ if ($result && !is_wp_error($result)) {
+ $term = $result['term_id'];
+ }
+ } else {
+ $term = $term->term_id;
+ }
+ $allowedFields['number'] = $term;
+ }
+
+ // Auto-timeline logic
+ if ($prevDate) {
+ $newDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : null);
+ if ($newDate) {
+ $date1 = new \DateTime($prevDate);
+ $date2 = new \DateTime($newDate);
+ $weeks = floor($date1->diff($date2)->days / 7);
+ if ($weeks > 0) {
+ $termToCheck = $weeks.' Weeks';
+ $term = get_term_by('name', $termToCheck, BASE.'timeline');
+ if (!$term) {
+ $result = wp_insert_term($termToCheck, BASE.'timeline');
+ if ($result && !is_wp_error($result)) {
+ $term = $result['term_id'];
+ }
+ } else {
+ $term = $term->term_id;
+ }
+ $allowedFields['timeline'] = $term;
+ }
+ }
+ }
+ $prevDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : $prevDate);
+
+ $updateValues = array_filter($allowedFields, function($value, $key) use ($oldValues) {
+ return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
+ }, ARRAY_FILTER_USE_BOTH);
+ error_log('Setting values for '.$timeline['id'].': '.print_r($updateValues, true));
+
+ $meta->setAll($updateValues);
+ $timeline['id'] = (int) $timeline['id'];
+
$success[] = $timeline['id'];
}
}
- //Delete any remaining children that no longer exist
+
+ // Delete any remaining children that no longer exist
if (!empty($existing_children)) {
foreach ($existing_children as $ID) {
wp_delete_post($ID);
}
}
+ if ($clearParent) {
+ $this->cache->clear();
+ CacheManager::onPostSave($parent_id, $parent_post);
+ }
+
+
return ['success' => true, 'data' => [
- 'success' => $success,
- 'errors' => $errors
+ 'success' => $success,
+ 'errors' => $errors
]];
}
@@ -650,6 +801,7 @@
$this->meta = new MetaManager($post->ID, 'post');
$data = [
'id' => $post->ID,
+ 'title' => $post->post_title,
'status' => $post->post_status,
'date' => $post->post_date,
'modified' => $post->post_modified,
@@ -719,7 +871,7 @@
$item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
//Step 2: Get the fields for each individual posts
- $children = get_children(['post_parent' => $post->ID, 'orderby' => 'menu_order', 'post_status' => ['publish', 'draft'], 'fields'=> 'ids']);
+ $children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish', 'draft'], 'fields'=> 'ids']);
array_unshift($children, $post->ID);
$subFields = [];
@@ -728,7 +880,7 @@
$meta = new MetaManager($child, 'post');
$f = $meta->getAll($this->timelineUniqueFields);
$f = ['id' => $child] + $f;
- $subFields[$f['post_thumbnail']] = $f;
+ $subFields[] = $f;
$images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
}
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 90993b4..daadec6 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -1,10 +1,13 @@
<?php
namespace JVBase\rest\routes;
+use JVBase\managers\CacheManager;
use JVBase\rest\RestRouteManager;
use JVBase\integrations\Umami;
use JVBase\meta\MetaManager;
use JVBase\managers\TaxonomyRelationships;
+use JVBase\utility\Checker;
+use JVBase\utility\Features;
use WP_Query;
use WP_Post;
use WP_Term;
@@ -14,1020 +17,1265 @@
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-/**
- * FeedRoutes - Optimized API endpoints for the feed block
- * TODO: Look at Content Routes' setup and make this more like that one; it's a bit more organized
- * Or NewsRoutes
- */
+
class FeedRoutes extends RestRouteManager
{
- protected int $per_page = 36;
- protected Umami $tracker;
+ protected int $per_page = 36;
+ protected ?Umami $tracker = null;
+ protected ?Checker $checker = null;
+ protected ?array $fields = null;
+ protected ?array $timelineSharedFields = null;
+ protected ?array $timelineUniqueFields = null;
- public function __construct()
- {
- $this->cache_name = 'feed';
- $this->cache_ttl = 86400;
+ public function __construct()
+ {
+ $this->cache_name = 'feed';
+ $this->cache_ttl = 86400;
+ parent::__construct();
+ }
- if (jvbSiteUsesUmami()) {
- $this->tracker = JVB()->connect('umami');
- }
- parent::__construct();
- }
+ public function init():void
+ {
+ $this->checker = Checker::getInstance();
- /**
- * Registers feed routes
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/feed', [
- 'methods' => ['GET', 'POST'],
- 'callback' => [$this, 'handleFeedRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- ]);
- }
-
- /**
- * Formats an item
- * @param int $postID
- *
- * @return array
- */
- protected function formatItem(int $postID, string $type = 'post'):array
- {
- switch ($type) {
- case 'post':
- $post = get_post($postID);
- $type = jvbNoBase($post->post_type);
- $metaType = 'post';
- break;
- default:
- $post = get_term($postID, jvbCheckBase($type));
- $type = jvbNoBase($type);
- $metaType = 'term';
- break;
- }
- if (!$post || is_wp_error($post)) {
- return [];
- }
- $formatted = $this->cache->get($postID, $type);
-// $formatted = false;
- if ($formatted) {
- return $formatted;
- }
-
-
- $fields = apply_filters(
- 'jvbFeedFields',
- [],
- $type
- );
-
- $meta = new MetaManager($postID, $metaType);
- $formatted = [
- 'id' => $postID,
- 'icon' => $type,
- ];
-
- if (jvbSiteUsesUmami()) {
- $args = ($metaType === 'post') ? [ 'owner_id' => $post->post_author] : [];
- $formatted['umami_view'] = $this->tracker->trackFeedView($postID, $type, $args);
- $formatted['umami_fav'] = $this->tracker->trackFavouriteToggle($postID, $type, false);
- }
- switch ($metaType) {
- case 'term':
- if (jvbSiteUsesUmami()) {
- $formatted['umami_click'] = $this->tracker->trackTaxonomyClick($postID, $type);
- }
- $owner = (in_array($type, jvbContentTaxonomies()) ? $meta->getValue('owner') : null);
- if (!is_null($owner)) {
- $formatted['user_id'] = $owner;
- }
- $formatted['url'] = get_term_link($postID, $type);
- break;
- default:
- $formatted = array_merge($formatted, [
- 'date' => $post->post_date,
- 'user_id' => (int)$post->post_author,
- 'url' => get_the_permalink($postID),
- ]);
- break;
- }
- $order = array_keys($fields);
- foreach ($fields as $field => $config) {
- $value = [];
- if ($field === 'umami_click') {
- if ($config === 'profile') {
- $formatted['umami_click'] = $this->tracker->trackProfileClick($postID, $type);
- }
- if ($config === 'contentTaxonomy') {
- $formatted['umami_click'] = $this->tracker->trackContentTaxonomyClick($postID, $type);
- }
- } else {
- if (array_key_exists('field', $config)) {
- if ($field === 'image' && array_key_exists('gallery', $config)) {
- //Array === post types
- if (is_array($config['gallery'])) {
- $posts = get_posts([
- 'post_type' => array_map(function ($item) { return BASE.$item; }, $config['gallery']),
- 'author' => $post->post_author,
- 'posts_per_page' => 5,
- 'orderby' => 'date',
- 'order' => 'DESC',
- ]);
- $formatted['content'] = array_map(function ($content) {
- return [
- 'url' => get_permalink($content->ID),
- 'title' => $content->post_title,
- 'image' => jvbImageData((int)get_post_thumbnail_id($content->ID)),
- ];
- }, $posts);
- } else {
- //String === $meta
- $ids = explode(',', $meta->getValue($config['gallery']));
- $formatted['content'] = array_map(function ($id) {
- return [
- 'image' => jvbImageData((int)$id)
- ];
- }, $ids);
- }
- }
- switch ($config['field']) {
- case 'post_author':
- $author = $this->getAuthorData($post);
- $value = [
- 'value' => $author['value'],
- 'url' => $author['url']
- ];
- break;
- case 'name':
- $value = $post->name;
- break;
- case 'post_title':
- $value = $post->post_title;
- break;
- case 'image':
- case 'image_portrait':
- case 'featured_image':
- $value = $meta->getValue($config['field']);
- $value = jvbImageData((int)$value);
- break;
- case 'top_style':
- case 'city':
- case 'top_theme':
- $terms = explode(',', $meta->getValue($field));
- $terms = array_filter(array_map(function ($termID) use ($config, $postID, $type) {
- $term = get_term($termID, jvbCheckBase($config['icon']));
- if ($term && !is_wp_error($term)) {
- return $this->formatTaxonomy($term, $postID, $type);
- }
- return [];
- }, $terms));
- $value = [
- 'terms' => $terms
- ];
- break;
- default:
- $value = [
- 'value' => $meta->getValue($field)
- ];
- }
-
- } elseif (array_key_exists('taxonomy', $config)) {
- $terms = get_the_terms($postID, BASE.$config['taxonomy']);
- if ($terms && !is_wp_error($terms)) {
- $terms = array_map(function ($term) use ($postID, $type) {
- return $this->formatTaxonomy($term, $postID, $type);
- }, $terms);
- $value = [
- 'terms' => $terms
- ];
- }
- }
-
- if (array_key_exists('label', $config)) {
- $value['label'] = $config['label'];
- }
- if (array_key_exists('icon', $config)) {
- $value['icon'] = $config['icon'];
- }
- $formatted[$field] = $value;
- }
- }
-
- $formatted['order'] = $order;
- $this->cache->set($postID, $formatted, $type);
-
- return $formatted;
- }
-
- protected function formatTaxonomy(WP_Term $term, int $postID, string $type)
- {
- return [
- 'ID' => $term->term_id,
- 'title' => htmlspecialchars_decode($term->name),
- 'url' => get_term_link($term->term_id, $term->taxonomy),
- 'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $term->taxonomy, [
- 'from' => $type.'_'.$postID
- ])
- ];
- }
- protected function getAuthorData(WP_Post $post)
- {
- $author = $this->cache->get($post->post_author, 'author_data');
- if (!$author) {
- $author = [
- 'id' => $post->post_author,
- 'label' => 'Artist',
- 'value' => get_the_author_meta('display_name', $post->post_author),
- 'icon' => 'artist',
- 'url' => get_the_permalink(get_user_meta($post->post_author, BASE.'link', true)),
- ];
- $this->cache->set($post->post_author, $author, 'author_data');
- }
- return $author;
- }
-
- protected function getTaxonomies(int $postID, string $content):array
- {
- $taxonomies = jvbTaxonomiesForContent($content);
- $out = [];
- foreach ($taxonomies as $tax) {
- $terms = get_the_terms($postID, $tax);
- $t = [];
- if ($terms && !is_wp_error($terms)) {
- $config = jvbNoBase($tax);
- $out[] = [
- 'icon' => $config,
- 'title' => JVB_TAXONOMY[$config]['plural'],
- 'terms' => array_map(function ($term) use ($tax, $postID, $content) {
- return [
- 'ID' => $term->term_id,
- 'title' => htmlspecialchars_decode($term->name),
- 'url' => get_term_link($term->term_id, $tax),
- 'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
- 'from' => $content.'_'.$postID
- ])
- ];
- }, $terms),
- ];
- }
- }
- return $out;
- }
-
-
- protected function buildRequestArgs(WP_REST_Request $request):array
- {
- global $jvb_everything;
- $data = $request->get_params();
- error_log('Feed Request: '.print_r($data, true));
- $args = [
- 'post_type' => (array_key_exists($data['content'], $jvb_everything)) ?
- BASE.$data['content'] :
- BASE.array_key_first(JVB_CONTENT),
- 'paged' => intval($data['page'] ?? 1),
- 'posts_per_page' => $this->per_page,
- ];
- if (!empty($data['context'])) {
- $args = $this->applyContextFilters(
- $args,
- [
- 'id' => $data['source'],
- 'type'=>$data['context']
- ]
- );
- }
- $args = $this->applyContextFilters($args, $data);
- $args = $this->applyTaxonomyFilters($args, $data);
- $args = $this->applyOrderFilters($args, $data);
- $args = $this->applyDateFilters($args, $data);
-
- $args = $this->applyFavouritesFilter($args, $data);
-
- return $args;
- }
- /**
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function handleFeedRequest(WP_REST_Request $request):WP_REST_Response
- {
- $args = $this->buildRequestArgs($request);
-
- error_log('Final Args: '.print_r($args, true));
-
- // Determine content type(s) for cache checking
- $content_types = [];
- if (!empty($data['content'])) {
- $content_types[] = $data['content'];
+ if (jvbSiteUsesUmami()) {
+ $this->tracker = JVB()->connect('umami');
}
- if (!empty($data['type'])) {
- $types = is_array($data['type']) ? $data['type'] : [$data['type']];
- $content_types = array_merge($content_types, $types);
+
+ $this->cache->clear();
+ $this->setupCacheConnections();
+ }
+
+ /**
+ * Set up cache connections for automatic invalidation
+ */
+ protected function setupCacheConnections(): void
+ {
+ // Connect to all content types with show_feed
+ $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
+ foreach ($contentTypes as $type) {
+ CacheManager::for('feed_item_'.$type)->connectTo('post');
+ $this->cache->connectTo('post', $type);
}
+
+ // Connect to all taxonomies with show_feed
+ $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
+ foreach ($taxonomies as $tax) {
+ CacheManager::for('feed_item_'.$tax)->connectTo('taxonomy');
+ $this->cache->connectTo('taxonomy', $tax);
+ }
+
+ }
+
+ /**
+ * Registers feed routes
+ * @return void
+ */
+ public function registerRoutes(): void
+ {
+ register_rest_route($this->namespace, '/feed', [
+ 'methods' => ['GET', 'POST'],
+ 'callback' => [$this, 'handleFeedRequest'],
+ 'permission_callback' => [$this, 'checkPermission'],
+ ]);
+
+ register_rest_route($this->namespace, 'feed/types', [
+ 'permission_callback' => [$this, 'checkPermission'],
+ 'methods' => 'GET',
+ 'callback' => [$this, 'getFeedTypes']
+ ]);
+ }
+
+ /**
+ * Formats an item
+ * @param int $postID
+ *
+ * @return array
+ */
+ protected function formatItem(int $postID, string $type = 'post', $skip = false): array
+ {
+ switch ($type) {
+ case 'post':
+ $post = get_post($postID);
+ $type = jvbNoBase($post->post_type);
+ $metaType = 'post';
+ $cache = CacheManager::for('feed_item_'.$type);
+ break;
+ default:
+ $post = get_term($postID, jvbCheckBase($type));
+ $type = jvbNoBase($type);
+ $metaType = 'term';
+ $cache = CacheManager::for('feed_item_'.$type);
+ break;
+ }
+ if (!$post || is_wp_error($post)) {
+ return [];
+ }
+
+ return $cache->remember($postID,
+ function() use ($postID, $type, $metaType, $post, $skip) {
+ $config = null;
+ switch ($metaType) {
+ case 'post':
+ $config = JVB_CONTENT[$type];
+ if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) {
+ return $this->formatTimeline($postID, $post);
+ }
+ break;
+ case 'term':
+ $config = JVB_TAXONOMY[$type];
+ break;
+ }
+ if (!$config) {
+ return [];
+ }
+ $fields = $config['fields'];
+
+ //Allow custom filtering for public fields
+ if (array_key_exists('feed', $config) && array_key_exists('fields', $config['feed'])) {
+ $fields = array_filter($fields, function($field) use ($config) {
+ return in_array($field, $config['feed']['fields']);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+ $meta = new MetaManager($postID, $metaType);
+ $values = $meta->getAll(array_keys($fields));
+
+ $out = [
+ 'fields' => $values,
+ ];
+
+ //Format Taxonomies
+ $temp = array_filter($fields, function($field) {
+ return $field['type'] === 'taxonomy';
+ });
+ foreach ($temp as $key => $config) {
+ if (array_key_exists($key, $out) && $out[$key] !== '') {
+ $IDs = array_map('absint', explode(',', $out[$key]));
+ $data = [];
+ $icon = JVB_TAXONOMY[$config['taxonomy']]['icon']??'triangle';
+ foreach ($IDs as $ID) {
+ $term = get_term($ID, jvbCheckBase($config['taxonomy']));
+ if ($term && !is_wp_error($term)) {
+ $data[$ID] = [
+ 'id' => $ID,
+ 'icon' => $icon,
+ 'name' => $term->name,
+ 'url' => get_term_link($ID, jvbCheckBase($config['taxonomy'])),
+ ];
+ if ($this->tracker) {
+ $data[$ID]['umami_click'] = $this->tracker->trackClick($ID, $config['taxonomy'], ['from' => $type.'_'.$postID]);
+ }
+ }
+ }
+ if (!empty($data)) {
+ $out['fields'][$key] = $data;
+ }
+ }
+ }
+
+ //Add images
+ $imgIDs = [];
+ $temp = array_filter($fields, function($field) {
+ return in_array($field['type'], [ 'upload', 'image', 'gallery']);
+ });
+ foreach ($temp as $key => $config) {
+ if (array_key_exists($key, $out) && $out[$key] !== '') {
+ $IDs = array_map('absint', explode(',',$out[$key]));
+ foreach ($IDs as $ID) {
+ $imgIDs[$ID] = jvbImageData($ID);
+ }
+ }
+ }
+ $out['images'] = $imgIDs;
+
+ $out['id'] = $postID;
+ $out['content'] = $type;
+ $out['icon'] = $config['icon']??'triangle';
+
+ if ($this->tracker) {
+ $args = ($metaType === 'post') ? ['owner_id' => $post->post_author] : [];
+ $out['umami_view'] = $this->tracker->trackFeedView($postID, $type, $args);
+ $out['umami_fav'] = $this->tracker->trackFavouriteToggle($postID, $type, false);
+ $out['umami_click'] = $this->tracker->trackClick($postID, $type);
+ }
+
+ switch ($metaType) {
+ case 'term':
+ $owner = (in_array($type, jvbContentTaxonomies()) ? $meta->getValue('owner') : null);
+ if (!is_null($owner)) {
+ $out['user_id'] = $owner;
+ }
+ $out['url'] = get_term_link($postID, $type);
+ break;
+ case 'post':
+ $out['date'] = $post->post_date;
+ $out['user_id'] = (int)$post->post_author;
+ $out['url'] = get_the_permalink($postID);
+ break;
+ }
+ return $out;
+ }
+ );
+
+ }
+
+ protected function initTimelineFields(string $content):void
+ {
+ $content = jvbNoBase($content);
+ if (!Features::forContent($content)->has('is_timeline')){
+ return;
+ }
+ $config = Features::getConfig($content);
+ $this->fields = $config['fields'];
+
+ $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
+ if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
+ return true;
+ }
+ return false;
+ }));
+ array_unshift($this->timelineSharedFields, 'post_thumbnail');
+ array_unshift($this->timelineSharedFields, 'post_title');
+
+ $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
+ if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
+ return true;
+ }
+ return false;
+ }));
+ }
+
+ protected function formatTimeline(int $postID, WP_Post $post):array
+ {
+ if (!$this->timelineSharedFields || !$this->timelineUniqueFields){
+ $this->initTimelineFields($post->post_type);
+ }
+ $item = $this->formatItem($postID, 'post', true);
+ //Step 1: Get the fields that apply to all posts
+ $mainMeta = new MetaManager($post->ID, 'post');
+ $item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
+
+ //Step 2: Get the fields for each individual posts
+ $children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish'], 'fields'=> 'ids']);
+ array_unshift($children, $post->ID);
+
+ $subFields = [];
+ $images = [];
+ foreach ($children as $child) {
+ $meta = new MetaManager($child, 'post');
+ $f = $meta->getAll($this->timelineUniqueFields);
+ $f = ['id' => $child] + $f;
+ $subFields[] = $f;
+
+ $images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
+ }
+ $item['fields']['order'] = $subFields;
+ $item['images'] = $item['images'] + $images;
+
+ return $item;
+ }
+
+ protected function formatTaxonomy(WP_Term $term, int $postID, string $type)
+ {
+ return [
+ 'ID' => $term->term_id,
+ 'title' => htmlspecialchars_decode($term->name),
+ 'url' => get_term_link($term->term_id, $term->taxonomy),
+ 'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $term->taxonomy, [
+ 'from' => $type . '_' . $postID
+ ])
+ ];
+ }
+
+ protected function getAuthorData(WP_Post $post)
+ {
+ $author = $this->cache->get($post->post_author, 'author_data');
+ if (!$author) {
+ $author = [
+ 'id' => $post->post_author,
+ 'label' => 'Artist',
+ 'value' => get_the_author_meta('display_name', $post->post_author),
+ 'icon' => 'artist',
+ 'url' => get_the_permalink(get_user_meta($post->post_author, BASE . 'link', true)),
+ ];
+ $this->cache->set($post->post_author, $author, 'author_data');
+ }
+ return $author;
+ }
+
+ protected function getTaxonomies(int $postID, string $content): array
+ {
+ $taxonomies = jvbTaxonomiesForContent($content);
+ $out = [];
+ foreach ($taxonomies as $tax) {
+ $terms = get_the_terms($postID, $tax);
+ $t = [];
+ if ($terms && !is_wp_error($terms)) {
+ $config = jvbNoBase($tax);
+ $out[] = [
+ 'icon' => $config,
+ 'title' => JVB_TAXONOMY[$config]['plural'],
+ 'terms' => array_map(function ($term) use ($tax, $postID, $content) {
+ return [
+ 'ID' => $term->term_id,
+ 'title' => htmlspecialchars_decode($term->name),
+ 'url' => get_term_link($term->term_id, $tax),
+ 'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
+ 'from' => $content . '_' . $postID
+ ])
+ ];
+ }, $terms),
+ ];
+ }
+ }
+ return $out;
+ }
+
+
+ protected function buildRequestArgs(WP_REST_Request $request): array
+ {
+ $data = $request->get_params();
+ error_log('Feed Request: ' . print_r($data, true));
+ $args = [
+ 'post_type' => (array_key_exists($data['content'], $this->buildFeedTypesConfig())) ?
+ BASE . $data['content'] :
+ BASE . array_key_first(JVB_CONTENT),
+ 'paged' => intval($data['page'] ?? 1),
+ 'posts_per_page' => $this->per_page,
+ ];
+ if (!empty($data['context'])) {
+ $args = $this->applyContextFilters(
+ $args,
+ [
+ 'id' => $data['source']??'0',
+ 'type' => $data['context']
+ ]
+ );
+ }
+ $args = $this->applyContextFilters($args, $data);
+ $args = $this->applyTaxonomyFilters($args, $data);
+ $args = $this->applyOrderFilters($args, $data);
+ $args = $this->applyDateFilters($args, $data);
+
+ $args = $this->applyFavouritesFilter($args, $data);
+ error_log('Final Args: '.print_r($args, true));
+ return $args;
+ }
+
+ protected function applyTaxonomyFilters(array $args, array $data): array
+ {
+ if (!isset($data['taxonomy']) || empty($data['taxonomy'])) {
+ return $args;
+ }
+
+ $taxonomyFilters = $data['taxonomy'];
+
+ // Validate taxonomies exist and sanitize
+ $validFilters = [];
+ foreach ($taxonomyFilters as $taxonomy => $terms) {
+ if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+ continue;
+ }
+
+ $validFilters[] = [
+ 'taxonomy' => jvbCheckBase($taxonomy),
+ 'field' => 'term_id',
+ 'terms' => array_map('absint', (array)$terms),
+ 'operator' => 'IN'
+ ];
+ }
+
+ if (empty($validFilters)) {
+ return $args;
+ }
+
+ // Determine relation based on match filter
+ $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+ $args['tax_query'] = array_merge(
+ ['relation' => $relation],
+ $validFilters
+ );
+
+ return $args;
+ }
+
+ /**
+ * @param WP_REST_Request $request
+ *
+ * @return WP_REST_Response
+ */
+ public function handleFeedRequest(WP_REST_Request $request): WP_REST_Response
+ {
+ $args = $this->buildRequestArgs($request);
+ $cacheContext = $this->buildCacheContext($args, $request);
+
// Check HTTP cache headers first
- $cache_check = $this->checkHeaders($request, $content_types ?: ['feed']);
+ $cache_check = $this->checkHeaders(
+ $request,
+ $cacheContext['content_types'],
+ $cacheContext['additional_params']
+ );
if ($cache_check) {
return $cache_check; // Returns 304 Not Modified
}
-
- $key = $this->cache->generateKey($args);
- $cached = $this->cache->get($key);
- if ($cached) {
- if ($request->get_param('highlight')) {
- $highlight = json_decode($request->get_param('highlight'), true);
- $args['highlight'] = $highlight;
- }
- $cached['items'] = $this->processHighlightedItem($cached['items'], $args);
- $response = new WP_REST_Response($cached);
+ error_log('Feed Request Args: '.print_r($args, true));
+ $key = $this->cache->generateKey($args);
+ $cached = $this->cache->get($key);
+ if ($cached) {
+ if ($request->get_param('highlight')) {
+ $highlight = json_decode($request->get_param('highlight'), true);
+ $args['highlight'] = $highlight;
+ }
+ $cached['items'] = $this->processHighlightedItem($cached['items'], $args);
+ $response = new WP_REST_Response($cached);
return $this->addCacheHeaders($response);
- }
- // Fetch and format items
- $items = $this->fetchFeedItems($args);
+ }
+ // Fetch and format items
+ $items = $this->fetchFeedItems($args);
+
+ error_log('Feed Got items: ' .print_r($items, true));
- $ttl = (str_contains($args['orderby'], 'RAND')) ? 1800 : $this->cache_ttl;
- $this->cache->set($key, $items, $ttl);
+ $ttl = (str_contains($args['orderby'], 'RAND')) ? 1800 : $this->cache_ttl;
+ $this->cache->set($key, $items, $ttl);
- if ($request->get_param('highlight')) {
- $highlight = json_decode($request->get_param('highlight'), true);
- $args['highlight'] = $highlight;
- }
+ if ($request->get_param('highlight')) {
+ $highlight = json_decode($request->get_param('highlight'), true);
+ $args['highlight'] = $highlight;
+ }
- $items['items'] = $this->processHighlightedItem($items['items'], $args);
+ $items['items'] = $this->processHighlightedItem($items['items'], $args);
$response = new WP_REST_Response($items);
return $this->addCacheHeaders($response);
- }
+ }
- /**
- * @param array $args Formatted Args for WP_Query
- * @param array $data parsed Request Data
- *
- * @return array|null
- */
- protected function processHighlightedItem(array $items, array $data):array
- {
- error_log('Data passed to processHighlightedItem:'.print_r($data, true));
- if (empty($data['highlight']??null)) {
- return $items;
- }
+ /**
+ * Build cache context from query args
+ * Extracts content types and parameters needed for proper cache checking
+ *
+ * @param array $args Built WP_Query arguments
+ * @param WP_REST_Request $request Original request
+ * @return array Cache context with content_types and additional_params
+ */
+ protected function buildCacheContext(array $args, WP_REST_Request $request): array
+ {
+ // Extract content types from post_type in args
+ $post_types = is_array($args['post_type'])
+ ? $args['post_type']
+ : [$args['post_type']];
- // Convert to array if string
- if (is_string($data['highlight'])) {
- $data['highlight'] = json_decode($data['highlight'], true);
- }
+ $content_types = array_map('jvbNoBase', $post_types);
+ $content_types[] = 'feed'; // Always include base feed type
- // Extract key and value
- $key = array_keys($data['highlight'])[0] ?? false;
- $value = array_values($data['highlight'])[0] ?? false;
- error_log('Highlighted item: '.$key);
- error_log('Highlighted item: '.$value);
- error_log('No Single Content Types: '.print_r(jvbNoSingleContentTypes(), true));
- error_log('Page: '.print_r($data['paged'], true));
- if (in_array($key, jvbNoSingleContentTypes()) && $value && $data['paged'] === 1) {
- error_log('Formatted Highlighted item: '.print_r($this->formatItem($value), true));
- error_log('Items: '.print_r($items, true));
- array_unshift($items, $this->formatItem($value));
- error_log('Items after unshift: '.print_r($items, true));
- }
- return $items;
- }
+ // Build additional params for ETag uniqueness
+ $additional_params = [
+ 'order' => $args['orderby'] ?? 'date',
+ 'direction' => $args['order'] ?? 'DESC',
+ 'page' => $args['paged'] ?? 1,
+ ];
- /**
- * @param array $args
- * @param array $context
- *
- * @return array
- */
- protected function applyContextFilters(array $args, array $context):array
- {
+ if ($request->get_param('favourites')) {
+ $additional_params['user'] = (int)$request->get_param('user');
+ }
- error_log('Args at Context Filters: '.print_r($args, true));
- error_log('Request at Context Filters: '.print_r($context, true));
- if (!isset($context['type'])) {
- return $args;
- }
+ // Include author filter if present (from context or favourites)
+ if (!empty($args['author'])) {
+ $additional_params['author'] = $args['author'];
+ }
- switch (true) {
- case contentIsJVBUserType($context['type']):
- $args['author'] = (int)get_post_meta($context['id'], BASE.'link', true);
- break;
- case taxIsJVBContentTax($context['type']):
- $args['post_type'] = (is_array($args['post_type'])) ? $args['post_type'] : explode(',',$args['post_type']);
- if (array_intersect($args['post_type'], array_map(function ($type) { return jvbCheckBase($type); },array_keys(jvbGlobalFeedContent())))) {
- $artists = jvbGetContentUsers($context['id']);
- if (!empty($artists)) {
- $args['author__in'] = $artists;
- }
- } else {
- $args['tax_query'] = [
- 'relation' => 'AND',
- [
- 'taxonomy' => BASE.$context['type'],
- 'terms' => $context['id'],
- ]
- ];
- }
- break;
- case taxonomy_exists(jvbCheckBase($context['type'])):
- $args['tax_query'] = [
- 'relation' => 'AND',
- [
- 'taxonomy' => BASE.$context['type'],
- 'terms' => $context['id'],
- ]
- ];
- break;
- }
- return $args;
- }
+ if (!empty($args['author__in'])) {
+ $additional_params['author__in'] = $args['author__in'];
+ }
- /**
- * @param array $args
- * @param array $filters
- *
- * @return array
- */
- protected function applyFavouritesFilter(array $args, array $filters):array
- {
- if (!array_key_exists('favourites', $filters)){
- return $args;
- }
- error_log('Proceeding to check for favourites:');
- global $wpdb;
+ // Include taxonomy filters if present
+ if (!empty($args['tax_query'])) {
+ $tax_filters = [];
+ foreach ($args['tax_query'] as $key => $query) {
+ if ($key === 'relation' || !is_array($query)) {
+ continue;
+ }
- // Get post types for the current filter
- $post_types = explode(',', $args['post_type']);
+ $taxonomy = jvbNoBase($query['taxonomy'] ?? '');
+ if ($taxonomy) {
+ $tax_filters[$taxonomy] = $query['terms'] ?? [];
+ // Also add taxonomy to content_types for timestamp checking
+ $content_types[] = $taxonomy;
+ }
+ }
+ if (!empty($tax_filters)) {
+ $additional_params['taxonomies'] = $tax_filters;
+ }
+ }
- $favourites_table = $wpdb->prefix . BASE . 'favourites';
- $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
- error_log('CurrentUser ID: '.print_r(get_current_user_id(), true));
- $favourited_ids = $wpdb->get_col($wpdb->prepare(
- "SELECT target_id FROM {$favourites_table}
+ // Include date filters if present
+ if (!empty($args['date_query'])) {
+ $additional_params['date_filter'] = md5(serialize($args['date_query']));
+ }
+
+ // Include meta queries if present
+ if (!empty($args['meta_query'])) {
+ $additional_params['meta_filter'] = md5(serialize($args['meta_query']));
+ }
+
+ return [
+ 'content_types' => array_unique($content_types),
+ 'additional_params' => $additional_params
+ ];
+ }
+
+ /**
+ * @param array $args Formatted Args for WP_Query
+ * @param array $data parsed Request Data
+ *
+ * @return array|null
+ */
+ protected function processHighlightedItem(array $items, array $data): array
+ {
+ error_log('Data passed to processHighlightedItem:' . print_r($data, true));
+ if (empty($data['highlight'] ?? null)) {
+ return $items;
+ }
+
+ // Convert to array if string
+ if (is_string($data['highlight'])) {
+ $data['highlight'] = json_decode($data['highlight'], true);
+ }
+
+ // Extract key and value
+ $key = array_keys($data['highlight'])[0] ?? false;
+ $value = array_values($data['highlight'])[0] ?? false;
+ error_log('Highlighted item: ' . $key);
+ error_log('Highlighted item: ' . $value);
+ error_log('No Single Content Types: ' . print_r(jvbNoSingleContentTypes(), true));
+ error_log('Page: ' . print_r($data['paged'], true));
+ if (in_array($key, jvbNoSingleContentTypes()) && $value && $data['paged'] === 1) {
+ error_log('Formatted Highlighted item: ' . print_r($this->formatItem($value), true));
+ error_log('Items: ' . print_r($items, true));
+ array_unshift($items, $this->formatItem($value));
+ error_log('Items after unshift: ' . print_r($items, true));
+ }
+ return $items;
+ }
+
+ /**
+ * @param array $args
+ * @param array $context
+ *
+ * @return array
+ */
+ protected function applyContextFilters(array $args, array $context): array
+ {
+ if (!isset($context['type'])) {
+ return $args;
+ }
+
+ switch (true) {
+ case contentIsJVBUserType($context['type']):
+ $args['author'] = (int)get_post_meta($context['id'], BASE . 'link', true);
+ break;
+ case taxIsJVBContentTax($context['type']):
+ $args['post_type'] = is_array($args['post_type'])
+ ? $args['post_type']
+ : explode(',', $args['post_type']);
+
+ // Check if filtering global feed content
+ $globalFeedTypes = array_map('jvbCheckBase',
+ array_keys(Features::getTypesWithFeature('show_feed', 'content'))
+ );
+
+ if (array_intersect($args['post_type'], $globalFeedTypes)) {
+ $artists = jvbGetContentUsers($context['id']);
+ if (!empty($artists)) {
+ $args['author__in'] = $artists;
+ }
+ } else {
+ $args['tax_query'] = [
+ 'relation' => 'AND',
+ [
+ 'taxonomy' => BASE . $context['type'],
+ 'terms' => $context['id'],
+ ]
+ ];
+ }
+ break;
+ case taxonomy_exists(jvbCheckBase($context['type'])):
+ $args['tax_query'] = [
+ 'relation' => 'AND',
+ [
+ 'taxonomy' => BASE . $context['type'],
+ 'terms' => $context['id'],
+ ]
+ ];
+ break;
+ }
+ return $args;
+ }
+
+ /**
+ * @param array $args
+ * @param array $filters
+ *
+ * @return array
+ */
+ protected function applyFavouritesFilter(array $args, array $filters): array
+ {
+ if (!array_key_exists('favourites', $filters)) {
+ return $args;
+ }
+ error_log('Proceeding to check for favourites:');
+ global $wpdb;
+
+ // Get post types for the current filter
+ $post_types = is_array($args['post_type'])
+ ? $args['post_type']
+ : [$args['post_type']];
+
+ $favourites_table = $wpdb->prefix . BASE . 'favourites';
+ $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
+ error_log('CurrentUser ID: ' . print_r(get_current_user_id(), true));
+ $favourited_ids = $wpdb->get_col($wpdb->prepare(
+ "SELECT target_id FROM {$favourites_table}
WHERE user_id = %d AND type IN ($placeholders)",
- array_merge(
- [get_current_user_id()],
- $post_types
- )
- ));
+ array_merge(
+ [get_current_user_id()],
+ $post_types
+ )
+ ));
- if (empty($favourited_ids)) {
- // Force empty results
- $args['post__in'] = [0];
- return $args;
- }
+ if (empty($favourited_ids)) {
+ // Force empty results
+ $args['post__in'] = [0];
+ return $args;
+ }
- $args['post__in'] = isset($args['post__in'])
- ? array_intersect($args['post__in'], $favourited_ids)
- : $favourited_ids;
+ $args['post__in'] = isset($args['post__in'])
+ ? array_intersect($args['post__in'], $favourited_ids)
+ : $favourited_ids;
- return $args;
- }
+ return $args;
+ }
- /**
- * @param array $args
- *
- * @return array
- */
- protected function fetchFeedItems(array $args):array
- {
- if (in_array($args['post_type'], jvbContentTaxonomies())) {
- return $this->handleContentTaxonomies($args);
- }
- $args['fields'] = 'ids';
- // Get post IDs
- $query = new WP_Query($args);
+ /**
+ * @param array $args
+ *
+ * @return array
+ */
+ protected function fetchFeedItems(array $args): array
+ {
+ $postType = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+ $slug = jvbNoBase($postType);
- // Batch prefetch related data
- update_meta_cache('post', $query->posts);
- update_object_term_cache($query->posts, $args['post_type']);
+ if (Features::forContent($slug)->has('is_timeline')) {
+ $args['post_parent'] = 0;
+ }
+ if (in_array($slug, Features::getTypesWithFeature('is_content', 'taxonomy'))) {
+ return $this->handleContentTaxonomies($args);
+ }
+ $args['fields'] = 'ids';
+ // Get post IDs
+ $query = new WP_Query($args);
- // Format regular items
- $items = array_map(function ($post) {
- return $this->formatItem($post);
- }, $query->posts);
+ // Batch prefetch related data
+ update_meta_cache('post', $query->posts);
+ update_object_term_cache($query->posts, $args['post_type']);
- wp_reset_postdata();
- return [
- 'items' => $items,
- 'has_more' => $query->max_num_pages > $args['paged'],
- 'total' => $query->found_posts
- ];
- }
+ // Format regular items
+ $items = array_map(fn($post) => $this->formatItem($post), $query->posts);
- protected function handleContentTaxonomies(array $args):array
- {
+ wp_reset_postdata();
+ return [
+ 'items' => $items,
+ 'has_more' => $query->max_num_pages > $args['paged'],
+ 'total' => $query->found_posts
+ ];
+ }
- $taxonomy = jvbNoBase($args['post_type']);
- global $wpdb;
- $table = $wpdb->prefix.BASE.'content_'.$taxonomy;
+ protected function handleContentTaxonomies(array $args): array
+ {
- // Check if table exists
- if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
- return [
- 'items' => [],
- 'has_more' => false,
- 'total' => 0
- ];
- }
+ $taxonomy = jvbNoBase($args['post_type']);
+ global $wpdb;
+ $table = $wpdb->prefix . BASE . 'content_' . $taxonomy;
- // Build the query components
- $queryBuilder = $this->buildCustomTableQuery($args, $table, $taxonomy);
+ // Check if table exists
+ if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
+ return [
+ 'items' => [],
+ 'has_more' => false,
+ 'total' => 0
+ ];
+ }
- // Execute count query first
- $total = (int) $wpdb->get_var($queryBuilder['count_query']);
+ // Build the query components
+ $queryBuilder = $this->buildCustomTableQuery($args, $table, $taxonomy);
- // Execute main query if we have results
- $items = [];
- if ($total > 0) {
- $results = $wpdb->get_results($queryBuilder['main_query'],ARRAY_A);
- $items = array_map(function ($ID) use ($taxonomy) {
- return $this->formatItem($ID['term_id'], $taxonomy);
- }, $results);
- }
+ // Execute count query first
+ $total = (int)$wpdb->get_var($queryBuilder['count_query']);
- $page = $args['paged'] ?? 1;
- $per_page = $args['posts_per_page'] ?? $this->per_page;
- $has_more = ($page * $per_page) < $total;
+ // Execute main query if we have results
+ $items = [];
+ if ($total > 0) {
+ $results = $wpdb->get_results($queryBuilder['main_query'], ARRAY_A);
+ $items = array_map(
+ fn($ID) => $this->formatItem($ID['term_id'], $taxonomy),
+ $results
+ );
+ }
- return [
- 'items' => $items,
- 'has_more' => $has_more,
- 'total' => $total
- ];
- }
- /**
- * Build SQL query components for custom table
- * @param array $args WP_Query style arguments
- * @param string $table Table name
- * @param string $taxonomy Taxonomy type
- * @return array Query components
- */
- protected function buildCustomTableQuery(array $args, string $table, string $taxonomy): array
- {
- global $wpdb;
+ $page = $args['paged'] ?? 1;
+ $per_page = $args['posts_per_page'] ?? $this->per_page;
+ $has_more = ($page * $per_page) < $total;
- $where_conditions = ['1=1'];
- $joins = [];
- $params = [];
+ return [
+ 'items' => $items,
+ 'has_more' => $has_more,
+ 'total' => $total
+ ];
+ }
- // Handle search
- if (!empty($args['s'])) {
- $search = '%' . $wpdb->esc_like($args['s']) . '%';
- $where_conditions[] = "(ct.name LIKE %s OR ct.slug LIKE %s)";
- $params[] = $search;
- $params[] = $search;
- }
+ /**
+ * Build SQL query components for custom table
+ * @param array $args WP_Query style arguments
+ * @param string $table Table name
+ * @param string $taxonomy Taxonomy type
+ * @return array Query components
+ */
+ protected function buildCustomTableQuery(array $args, string $table, string $taxonomy): array
+ {
+ global $wpdb;
- // Handle context filters (e.g., filtering shops by style through relationships)
- if (!empty($args['context_filter'])) {
- $context_conditions = $this->buildContextConditions($args['context_filter'], $joins, $params, $taxonomy);
- if (!empty($context_conditions)) {
- $where_conditions[] = $context_conditions;
- }
- }
+ $where_conditions = ['1=1'];
+ $joins = [];
+ $params = [];
- // Handle taxonomy filters (tax_query)
- if (!empty($args['tax_query'])) {
- $tax_conditions = $this->buildTaxonomyConditions($args['tax_query'], $joins, $params, $taxonomy);
- if (!empty($tax_conditions)) {
- $where_conditions[] = $tax_conditions;
- }
- }
+ // Handle search
+ if (!empty($args['s'])) {
+ $search = '%' . $wpdb->esc_like($args['s']) . '%';
+ $where_conditions[] = "(ct.name LIKE %s OR ct.slug LIKE %s)";
+ $params[] = $search;
+ $params[] = $search;
+ }
- // Handle meta queries (custom fields in the table)
- if (!empty($args['meta_query'])) {
- $meta_conditions = $this->buildMetaConditions($args['meta_query'], $joins, $params, $taxonomy);
- if (!empty($meta_conditions)) {
- $where_conditions[] = $meta_conditions;
- }
- }
+ // Handle context filters (e.g., filtering shops by style through relationships)
+ if (!empty($args['context_filter'])) {
+ $context_conditions = $this->buildContextConditions($args['context_filter'], $joins, $params, $taxonomy);
+ if (!empty($context_conditions)) {
+ $where_conditions[] = $context_conditions;
+ }
+ }
- // Handle date queries
- if (!empty($args['date_query'])) {
- $date_conditions = $this->buildDateConditions($args['date_query'], $params);
- if (!empty($date_conditions)) {
- $where_conditions[] = $date_conditions;
- }
- }
+ // Handle taxonomy filters (tax_query)
+ if (!empty($args['tax_query'])) {
+ $tax_conditions = $this->buildTaxonomyConditions($args['tax_query'], $joins, $params, $taxonomy);
+ if (!empty($tax_conditions)) {
+ $where_conditions[] = $tax_conditions;
+ }
+ }
- // Handle specific IDs
- if (!empty($args['include'])) {
- $placeholders = implode(',', array_fill(0, count($args['include']), '%d'));
- $where_conditions[] = "ct.term_id IN ({$placeholders})";
- $params = array_merge($params, $args['include']);
- }
+ // Handle meta queries (custom fields in the table)
+ if (!empty($args['meta_query'])) {
+ $meta_conditions = $this->buildMetaConditions($args['meta_query'], $joins, $params, $taxonomy);
+ if (!empty($meta_conditions)) {
+ $where_conditions[] = $meta_conditions;
+ }
+ }
- if (!empty($args['exclude'])) {
- $placeholders = implode(',', array_fill(0, count($args['exclude']), '%d'));
- $where_conditions[] = "ct.term_id NOT IN ({$placeholders})";
- $params = array_merge($params, $args['exclude']);
- }
+ // Handle date queries
+ if (!empty($args['date_query'])) {
+ $date_conditions = $this->buildDateConditions($args['date_query'], $params);
+ if (!empty($date_conditions)) {
+ $where_conditions[] = $date_conditions;
+ }
+ }
- // Build ORDER BY
- $order_by = $this->buildOrderBy($args, $taxonomy);
+ // Handle specific IDs
+ if (!empty($args['include'])) {
+ $placeholders = implode(',', array_fill(0, count($args['include']), '%d'));
+ $where_conditions[] = "ct.term_id IN ({$placeholders})";
+ $params = array_merge($params, $args['include']);
+ }
- // Build LIMIT
- $page = $args['paged'] ?? 1;
- $per_page = $args['posts_per_page'] ?? $this->per_page;
- $offset = ($page - 1) * $per_page;
+ if (!empty($args['exclude'])) {
+ $placeholders = implode(',', array_fill(0, count($args['exclude']), '%d'));
+ $where_conditions[] = "ct.term_id NOT IN ({$placeholders})";
+ $params = array_merge($params, $args['exclude']);
+ }
- // Combine everything
- $joins_sql = !empty($joins) ? implode(' ', $joins) : '';
- $where_sql = implode(' AND ', $where_conditions);
+ // Build ORDER BY
+ $order_by = $this->buildOrderBy($args, $taxonomy);
- $base_query = "FROM {$table} ct
+ // Build LIMIT
+ $page = $args['paged'] ?? 1;
+ $per_page = $args['posts_per_page'] ?? $this->per_page;
+ $offset = ($page - 1) * $per_page;
+
+ // Combine everything
+ $joins_sql = !empty($joins) ? implode(' ', $joins) : '';
+ $where_sql = implode(' AND ', $where_conditions);
+
+ $base_query = "FROM {$table} ct
LEFT JOIN {$wpdb->terms} t ON ct.term_id = t.term_id
LEFT JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
{$joins_sql}
WHERE {$where_sql}";
- $count_query = "SELECT COUNT(DISTINCT ct.term_id) {$base_query}";
+ $count_query = "SELECT COUNT(DISTINCT ct.term_id) {$base_query}";
- $main_query = "SELECT ct.term_id
+ $main_query = "SELECT ct.term_id
{$base_query}
{$order_by}
LIMIT %d OFFSET %d";
- // Add limit parameters
- $count_params = $params;
- $main_params = array_merge($params, [$per_page, $offset]);
+ // Add limit parameters
+ $count_params = $params;
+ $main_params = array_merge($params, [$per_page, $offset]);
- return [
- 'main_query' => $wpdb->prepare($main_query, $main_params),
- 'count_query' => $wpdb->prepare($count_query, $count_params)
- ];
- }
+ return [
+ 'main_query' => $wpdb->prepare($main_query, $main_params),
+ 'count_query' => $wpdb->prepare($count_query, $count_params)
+ ];
+ }
- /**
- * Build context-based filter conditions (e.g., shops filtered by style relationships)
- * @param array $context_filter Context filter data
- * @param array &$joins Reference to joins array
- * @param array &$params Reference to params array
- * @param string $taxonomy Current taxonomy type
- * @return string SQL condition
- */
- protected function buildContextConditions(array $context_filter, array &$joins, array &$params, string $taxonomy): string
- {
- global $wpdb;
+ /**
+ * Build context-based filter conditions (e.g., shops filtered by style relationships)
+ * @param array $context_filter Context filter data
+ * @param array &$joins Reference to joins array
+ * @param array &$params Reference to params array
+ * @param string $taxonomy Current taxonomy type
+ * @return string SQL condition
+ */
+ protected function buildContextConditions(array $context_filter, array &$joins, array &$params, string $taxonomy): string
+ {
+ global $wpdb;
- $context_type = $context_filter['type'] ?? '';
- $context_id = $context_filter['id'] ?? 0;
+ $context_type = $context_filter['type'] ?? '';
+ $context_id = $context_filter['id'] ?? 0;
- if (empty($context_type) || empty($context_id)) {
- return '';
- }
+ if (empty($context_type) || empty($context_id)) {
+ return '';
+ }
- $relationships_table = $wpdb->prefix . BASE . 'taxonomy_relationships';
+ $relationships_table = $wpdb->prefix . BASE . 'taxonomy_relationships';
- switch ($context_type) {
- case 'style':
- case 'theme':
- case 'pstyle':
- // For shops filtered by style/theme through relationships
- if ($taxonomy === 'shop') {
- $joins[] = "INNER JOIN {$relationships_table} tr ON ct.term_id = tr.term_id";
- $where_condition = "tr.related_term_id = %d AND tr.related_taxonomy = %s";
- $params[] = $context_id;
- $params[] = BASE . $context_type;
- return $where_condition;
- }
- break;
+ switch ($context_type) {
+ case 'style':
+ case 'theme':
+ case 'pstyle':
+ // For shops filtered by style/theme through relationships
+ if ($taxonomy === 'shop') {
+ $joins[] = "INNER JOIN {$relationships_table} tr ON ct.term_id = tr.term_id";
+ $where_condition = "tr.related_term_id = %d AND tr.related_taxonomy = %s";
+ $params[] = $context_id;
+ $params[] = BASE . $context_type;
+ return $where_condition;
+ }
+ break;
- case 'city':
- // Filter by city
- if (isset($this->getCustomTableFields($taxonomy)['city'])) {
- $params[] = $context_id;
- return "ct.city = %d";
- }
- break;
- }
+ case 'city':
+ // Filter by city
+ if (isset($this->getCustomTableFields($taxonomy)['city'])) {
+ $params[] = $context_id;
+ return "ct.city = %d";
+ }
+ break;
+ }
- return '';
- }
+ return '';
+ }
- /**
- * Build taxonomy filter conditions for custom table
- * @param array $tax_query Tax query array
- * @param array &$joins Reference to joins array
- * @param array &$params Reference to params array
- * @param string $taxonomy Current taxonomy type
- * @return string SQL condition
- */
- protected function buildTaxonomyConditions(array $tax_query, array &$joins, array &$params, string $taxonomy): string
- {
- global $wpdb;
+ /**
+ * Build taxonomy filter conditions for custom table
+ * @param array $tax_query Tax query array
+ * @param array &$joins Reference to joins array
+ * @param array &$params Reference to params array
+ * @param string $taxonomy Current taxonomy type
+ * @return string SQL condition
+ */
+ protected function buildTaxonomyConditions(array $tax_query, array &$joins, array &$params, string $taxonomy): string
+ {
+ global $wpdb;
- $conditions = [];
- $relation = $tax_query['relation'] ?? 'AND';
+ $conditions = [];
+ $relation = $tax_query['relation'] ?? 'AND';
- foreach ($tax_query as $key => $query) {
- if ($key === 'relation' || !is_array($query)) {
- continue;
- }
+ foreach ($tax_query as $key => $query) {
+ if ($key === 'relation' || !is_array($query)) {
+ continue;
+ }
- $query_taxonomy = $query['taxonomy'] ?? '';
- $terms = (array)($query['terms'] ?? []);
- $field = $query['field'] ?? 'term_id';
- $operator = $query['operator'] ?? 'IN';
+ $query_taxonomy = $query['taxonomy'] ?? '';
+ $terms = (array)($query['terms'] ?? []);
+ $field = $query['field'] ?? 'term_id';
+ $operator = $query['operator'] ?? 'IN';
- if (empty($query_taxonomy) || empty($terms)) {
- continue;
- }
+ if (empty($query_taxonomy) || empty($terms)) {
+ continue;
+ }
- // Check if this taxonomy field exists in our custom table
- $custom_fields = $this->getCustomTableFields($taxonomy);
- $taxonomy_clean = str_replace(BASE, '', $query_taxonomy);
+ // Check if this taxonomy field exists in our custom table
+ $custom_fields = $this->getCustomTableFields($taxonomy);
+ $taxonomy_clean = str_replace(BASE, '', $query_taxonomy);
- if (isset($custom_fields[$taxonomy_clean])) {
- // Field exists in custom table - direct query
- $field_column = "ct.{$taxonomy_clean}";
+ if (isset($custom_fields[$taxonomy_clean])) {
+ // Field exists in custom table - direct query
+ $field_column = "ct.{$taxonomy_clean}";
- if ($field === 'slug') {
- // Need to convert slugs to IDs first
- $term_ids = [];
- foreach ($terms as $slug) {
- $term = get_term_by('slug', $slug, $query_taxonomy);
- if ($term) {
- $term_ids[] = $term->term_id;
- }
- }
- $terms = $term_ids;
- }
+ if ($field === 'slug') {
+ // Need to convert slugs to IDs first
+ $term_ids = [];
+ foreach ($terms as $slug) {
+ $term = get_term_by('slug', $slug, $query_taxonomy);
+ if ($term) {
+ $term_ids[] = $term->term_id;
+ }
+ }
+ $terms = $term_ids;
+ }
- if (!empty($terms)) {
- $placeholders = implode(',', array_fill(0, count($terms), '%d'));
- $conditions[] = "{$field_column} {$operator} ({$placeholders})";
- $params = array_merge($params, $terms);
- }
- } else {
- // Need to join with term relationships
- $join_alias = "tr_{$key}";
- $joins[] = "LEFT JOIN {$wpdb->term_relationships} {$join_alias} ON ct.term_id = {$join_alias}.object_id";
- $joins[] = "LEFT JOIN {$wpdb->term_taxonomy} tt_{$key} ON {$join_alias}.term_taxonomy_id = tt_{$key}.term_taxonomy_id";
+ if (!empty($terms)) {
+ $placeholders = implode(',', array_fill(0, count($terms), '%d'));
+ $conditions[] = "{$field_column} {$operator} ({$placeholders})";
+ $params = array_merge($params, $terms);
+ }
+ } else {
+ // Need to join with term relationships
+ $join_alias = "tr_{$key}";
+ $joins[] = "LEFT JOIN {$wpdb->term_relationships} {$join_alias} ON ct.term_id = {$join_alias}.object_id";
+ $joins[] = "LEFT JOIN {$wpdb->term_taxonomy} tt_{$key} ON {$join_alias}.term_taxonomy_id = tt_{$key}.term_taxonomy_id";
- if ($field === 'slug') {
- $joins[] = "LEFT JOIN {$wpdb->terms} t_{$key} ON tt_{$key}.term_id = t_{$key}.term_id";
- $field_column = "t_{$key}.slug";
- $term_values = $terms; // Use slugs directly
- } else {
- $field_column = "tt_{$key}.term_id";
- $term_values = array_map('intval', $terms);
- }
+ if ($field === 'slug') {
+ $joins[] = "LEFT JOIN {$wpdb->terms} t_{$key} ON tt_{$key}.term_id = t_{$key}.term_id";
+ $field_column = "t_{$key}.slug";
+ $term_values = $terms; // Use slugs directly
+ } else {
+ $field_column = "tt_{$key}.term_id";
+ $term_values = array_map('intval', $terms);
+ }
- $placeholders = implode(',', array_fill(0, count($term_values), $field === 'slug' ? '%s' : '%d'));
- $taxonomy_condition = "tt_{$key}.taxonomy = %s";
- $terms_condition = "{$field_column} {$operator} ({$placeholders})";
+ $placeholders = implode(',', array_fill(0, count($term_values), $field === 'slug' ? '%s' : '%d'));
+ $taxonomy_condition = "tt_{$key}.taxonomy = %s";
+ $terms_condition = "{$field_column} {$operator} ({$placeholders})";
- $conditions[] = "({$taxonomy_condition} AND {$terms_condition})";
- $params[] = $query_taxonomy;
- $params = array_merge($params, $term_values);
- }
- }
+ $conditions[] = "({$taxonomy_condition} AND {$terms_condition})";
+ $params[] = $query_taxonomy;
+ $params = array_merge($params, $term_values);
+ }
+ }
- return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : '';
- }
+ return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : '';
+ }
- /**
- * Build meta query conditions for custom table fields
- * @param array $meta_query Meta query array
- * @param array &$joins Reference to joins array
- * @param array &$params Reference to params array
- * @param string $taxonomy Taxonomy type
- * @return string SQL condition
- */
- protected function buildMetaConditions(array $meta_query, array &$joins, array &$params, string $taxonomy): string
- {
- global $wpdb;
- $conditions = [];
- $relation = $meta_query['relation'] ?? 'AND';
+ /**
+ * Build meta query conditions for custom table fields
+ * @param array $meta_query Meta query array
+ * @param array &$joins Reference to joins array
+ * @param array &$params Reference to params array
+ * @param string $taxonomy Taxonomy type
+ * @return string SQL condition
+ */
+ protected function buildMetaConditions(array $meta_query, array &$joins, array &$params, string $taxonomy): string
+ {
+ global $wpdb;
+ $conditions = [];
+ $relation = $meta_query['relation'] ?? 'AND';
- // Get fields for this taxonomy to know which are in the custom table
- $custom_fields = $this->getCustomTableFields($taxonomy);
+ // Get fields for this taxonomy to know which are in the custom table
+ $custom_fields = $this->getCustomTableFields($taxonomy);
- foreach ($meta_query as $key => $query) {
- if ($key === 'relation' || !is_array($query)) {
- continue;
- }
+ foreach ($meta_query as $key => $query) {
+ if ($key === 'relation' || !is_array($query)) {
+ continue;
+ }
- $meta_key = $query['key'] ?? '';
- $meta_value = $query['value'] ?? '';
- $compare = $query['compare'] ?? '=';
+ $meta_key = $query['key'] ?? '';
+ $meta_value = $query['value'] ?? '';
+ $compare = $query['compare'] ?? '=';
- if (empty($meta_key)) {
- continue;
- }
+ if (empty($meta_key)) {
+ continue;
+ }
- // Remove BASE prefix if present
- $clean_key = str_replace(BASE, '', $meta_key);
+ // Remove BASE prefix if present
+ $clean_key = str_replace(BASE, '', $meta_key);
- // Check if this field exists in our custom table
- if (isset($custom_fields[$clean_key])) {
- // Field is in custom table, query directly
- $column = "ct.{$clean_key}";
- $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params);
- if ($condition) {
- $conditions[] = $condition;
- }
- } else {
- // Field is in term meta, need to join
- $join_alias = "tm_{$key}";
- $joins[] = "LEFT JOIN {$wpdb->termmeta} {$join_alias} ON ct.term_id = {$join_alias}.term_id AND {$join_alias}.meta_key = %s";
- $params[] = $meta_key;
+ // Check if this field exists in our custom table
+ if (isset($custom_fields[$clean_key])) {
+ // Field is in custom table, query directly
+ $column = "ct.{$clean_key}";
+ $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params);
+ if ($condition) {
+ $conditions[] = $condition;
+ }
+ } else {
+ // Field is in term meta, need to join
+ $join_alias = "tm_{$key}";
+ $joins[] = "LEFT JOIN {$wpdb->termmeta} {$join_alias} ON ct.term_id = {$join_alias}.term_id AND {$join_alias}.meta_key = %s";
+ $params[] = $meta_key;
- $column = "{$join_alias}.meta_value";
- $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params);
- if ($condition) {
- $conditions[] = $condition;
- }
- }
- }
+ $column = "{$join_alias}.meta_value";
+ $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params);
+ if ($condition) {
+ $conditions[] = $condition;
+ }
+ }
+ }
- return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : '';
- }
+ return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : '';
+ }
- /**
- * Build comparison condition for meta fields
- * @param string $column Column name
- * @param mixed $value Value to compare
- * @param string $compare Comparison operator
- * @param array &$params Reference to params array
- * @return string SQL condition
- */
- protected function buildMetaComparison(string $column, $value, string $compare, array &$params): string
- {
- switch (strtoupper($compare)) {
- case 'LIKE':
- $params[] = '%' . $value . '%';
- return "{$column} LIKE %s";
+ /**
+ * Build comparison condition for meta fields
+ * @param string $column Column name
+ * @param mixed $value Value to compare
+ * @param string $compare Comparison operator
+ * @param array &$params Reference to params array
+ * @return string SQL condition
+ */
+ protected function buildMetaComparison(string $column, $value, string $compare, array &$params): string
+ {
+ switch (strtoupper($compare)) {
+ case 'LIKE':
+ $params[] = '%' . $value . '%';
+ return "{$column} LIKE %s";
- case 'NOT LIKE':
- $params[] = '%' . $value . '%';
- return "{$column} NOT LIKE %s";
+ case 'NOT LIKE':
+ $params[] = '%' . $value . '%';
+ return "{$column} NOT LIKE %s";
- case 'IN':
- if (is_array($value)) {
- $placeholders = implode(',', array_fill(0, count($value), '%s'));
- $params = array_merge($params, $value);
- return "{$column} IN ({$placeholders})";
- }
- break;
+ case 'IN':
+ if (is_array($value)) {
+ $placeholders = implode(',', array_fill(0, count($value), '%s'));
+ $params = array_merge($params, $value);
+ return "{$column} IN ({$placeholders})";
+ }
+ break;
- case 'NOT IN':
- if (is_array($value)) {
- $placeholders = implode(',', array_fill(0, count($value), '%s'));
- $params = array_merge($params, $value);
- return "{$column} NOT IN ({$placeholders})";
- }
- break;
+ case 'NOT IN':
+ if (is_array($value)) {
+ $placeholders = implode(',', array_fill(0, count($value), '%s'));
+ $params = array_merge($params, $value);
+ return "{$column} NOT IN ({$placeholders})";
+ }
+ break;
- case 'BETWEEN':
- if (is_array($value) && count($value) === 2) {
- $params[] = $value[0];
- $params[] = $value[1];
- return "{$column} BETWEEN %s AND %s";
- }
- break;
+ case 'BETWEEN':
+ if (is_array($value) && count($value) === 2) {
+ $params[] = $value[0];
+ $params[] = $value[1];
+ return "{$column} BETWEEN %s AND %s";
+ }
+ break;
- case '!=':
- case '<>':
- $params[] = $value;
- return "{$column} != %s";
+ case '!=':
+ case '<>':
+ $params[] = $value;
+ return "{$column} != %s";
- case '>':
- $params[] = $value;
- return "{$column} > %s";
+ case '>':
+ $params[] = $value;
+ return "{$column} > %s";
- case '>=':
- $params[] = $value;
- return "{$column} >= %s";
+ case '>=':
+ $params[] = $value;
+ return "{$column} >= %s";
- case '<':
- $params[] = $value;
- return "{$column} < %s";
+ case '<':
+ $params[] = $value;
+ return "{$column} < %s";
- case '<=':
- $params[] = $value;
- return "{$column} <= %s";
+ case '<=':
+ $params[] = $value;
+ return "{$column} <= %s";
- case '=':
- default:
- $params[] = $value;
- return "{$column} = %s";
- }
+ case '=':
+ default:
+ $params[] = $value;
+ return "{$column} = %s";
+ }
- return '';
- }
+ return '';
+ }
- /**
- * Build date query conditions
- * @param array $date_query Date query array
- * @param array &$params Reference to params array
- * @return string SQL condition
- */
- protected function buildDateConditions(array $date_query, array &$params): string
- {
- $conditions = [];
+ /**
+ * Build date query conditions
+ * @param array $date_query Date query array
+ * @param array &$params Reference to params array
+ * @return string SQL condition
+ */
+ protected function buildDateConditions(array $date_query, array &$params): string
+ {
+ $conditions = [];
- foreach ($date_query as $query) {
- if (!is_array($query)) continue;
+ foreach ($date_query as $query) {
+ if (!is_array($query)) continue;
- $column = $query['column'] ?? 'updated_at';
- $year = $query['year'] ?? null;
- $month = $query['month'] ?? null;
- $day = $query['day'] ?? null;
- $after = $query['after'] ?? null;
- $before = $query['before'] ?? null;
+ $column = $query['column'] ?? 'updated_at';
+ $year = $query['year'] ?? null;
+ $month = $query['month'] ?? null;
+ $day = $query['day'] ?? null;
+ $after = $query['after'] ?? null;
+ $before = $query['before'] ?? null;
- if ($year) {
- $params[] = $year;
- $conditions[] = "YEAR(ct.{$column}) = %d";
- }
+ if ($year) {
+ $params[] = $year;
+ $conditions[] = "YEAR(ct.{$column}) = %d";
+ }
- if ($month) {
- $params[] = $month;
- $conditions[] = "MONTH(ct.{$column}) = %d";
- }
+ if ($month) {
+ $params[] = $month;
+ $conditions[] = "MONTH(ct.{$column}) = %d";
+ }
- if ($day) {
- $params[] = $day;
- $conditions[] = "DAY(ct.{$column}) = %d";
- }
+ if ($day) {
+ $params[] = $day;
+ $conditions[] = "DAY(ct.{$column}) = %d";
+ }
- if ($after) {
- $params[] = $after;
- $conditions[] = "ct.{$column} > %s";
- }
+ if ($after) {
+ $params[] = $after;
+ $conditions[] = "ct.{$column} > %s";
+ }
- if ($before) {
- $params[] = $before;
- $conditions[] = "ct.{$column} < %s";
- }
- }
+ if ($before) {
+ $params[] = $before;
+ $conditions[] = "ct.{$column} < %s";
+ }
+ }
- return !empty($conditions) ? '(' . implode(' AND ', $conditions) . ')' : '';
- }
+ return !empty($conditions) ? '(' . implode(' AND ', $conditions) . ')' : '';
+ }
- /**
- * Build ORDER BY clause
- * @param array $args Query arguments
- * @param string $taxonomy Taxonomy type
- * @return string ORDER BY clause
- */
- protected function buildOrderBy(array $args, string $taxonomy): string
- {
- $orderby = $args['orderby'] ?? 'name';
- $order = $args['order'] ?? 'ASC';
+ /**
+ * Build ORDER BY clause
+ * @param array $args Query arguments
+ * @param string $taxonomy Taxonomy type
+ * @return string ORDER BY clause
+ */
+ protected function buildOrderBy(array $args, string $taxonomy): string
+ {
+ $orderby = $args['orderby'] ?? 'name';
+ $order = $args['order'] ?? 'ASC';
- // Validate order direction
- if (!in_array($order, ['ASC', 'DESC'])) {
- $order = 'ASC';
- }
+ // Validate order direction
+ if (!in_array($order, ['ASC', 'DESC'])) {
+ $order = 'ASC';
+ }
- if (str_contains($orderby, 'RAND')) {
- return "ORDER BY {$orderby}";
- }
+ if (str_contains($orderby, 'RAND')) {
+ return "ORDER BY {$orderby}";
+ }
- switch ($orderby) {
- case 'name':
- return "ORDER BY ct.name {$order}";
+ switch ($orderby) {
+ case 'name':
+ return "ORDER BY ct.name {$order}";
- case 'count':
- return "ORDER BY tt.count {$order}";
+ case 'count':
+ return "ORDER BY tt.count {$order}";
- case 'term_id':
- case 'id':
- return "ORDER BY ct.term_id {$order}";
+ case 'term_id':
+ case 'id':
+ return "ORDER BY ct.term_id {$order}";
- case 'slug':
- return "ORDER BY t.slug {$order}";
+ case 'slug':
+ return "ORDER BY t.slug {$order}";
- case 'date':
- case 'updated':
- return "ORDER BY ct.updated_at {$order}";
+ case 'date':
+ case 'updated':
+ return "ORDER BY ct.updated_at {$order}";
- default:
- // Check if it's a custom field in our table
- $custom_fields = $this->getCustomTableFields($taxonomy);
- if (isset($custom_fields[$orderby])) {
- return "ORDER BY ct.{$orderby} {$order}";
- }
+ default:
+ // Check if it's a custom field in our table
+ $custom_fields = $this->getCustomTableFields($taxonomy);
+ if (isset($custom_fields[$orderby])) {
+ return "ORDER BY ct.{$orderby} {$order}";
+ }
- // Default to name
- return "ORDER BY ct.name {$order}";
- }
- }
+ // Default to name
+ return "ORDER BY ct.name {$order}";
+ }
+ }
- /**
- * Get custom table fields for a taxonomy
- * @param string $taxonomy Taxonomy type
- * @return array Field definitions
- */
- protected function getCustomTableFields(string $taxonomy): array
- {
- return jvbContentTaxonomiesTableFields($taxonomy)['fields'] ?? [];
- }
+ /**
+ * Get custom table fields for a taxonomy
+ * @param string $taxonomy Taxonomy type
+ * @return array Field definitions
+ */
+ protected function getCustomTableFields(string $taxonomy): array
+ {
+ return jvbContentTaxonomiesTableFields($taxonomy)['fields'] ?? [];
+ }
+
+ /**
+ * Get available feed types (for block editor)
+ * Returns structured data about content types that can be shown in feed
+ */
+ public function getFeedTypes(WP_REST_Request $request): WP_REST_Response
+ {
+ // Check HTTP cache
+ $cache_check = $this->checkHeaders($request, ['feed_types']);
+ if ($cache_check) {
+ return $cache_check;
+ }
+
+ $feedTypes = $this->buildFeedTypesConfig();
+
+ $response = new WP_REST_Response($feedTypes);
+ return $this->addCacheHeaders($response);
+ }
+
+ public function getFeedTypesConfig():array
+ {
+ return $this->buildFeedTypesConfig();
+ }
+ /**
+ * Build feed types configuration from Features
+ */
+ protected function buildFeedTypesConfig(): array
+ {
+ if (!$this->checker) {
+ $this->checker = Checker::getInstance();
+ }
+ return $this->cache->remember(
+ 'contentTypes',
+ function () {
+ $config = [];
+
+ // Get content types with show_feed
+ $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
+ foreach ($contentTypes as $slug) {
+ $contentConfig = JVB_CONTENT[$slug] ?? null;
+ if (!$contentConfig) continue;
+
+ $config[$slug] = [
+ 'type' => 'content',
+ 'singular' => $contentConfig['singular'] ?? ucfirst($slug),
+ 'plural' => $contentConfig['plural'] ?? ucfirst($slug) . 's',
+ 'icon' => $slug,
+ 'taxonomies' => $this->checker->getTaxonomiesForContent($slug),
+ ];
+ }
+
+ // Get taxonomies with show_feed (content taxonomies)
+ $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
+ foreach ($taxonomies as $slug) {
+ $taxConfig = JVB_TAXONOMY[$slug] ?? null;
+ if (!$taxConfig || !($taxConfig['is_content'] ?? false)) {
+ continue;
+ }
+
+ $config[$slug] = [
+ 'type' => 'taxonomy',
+ 'singular' => $taxConfig['singular'] ?? ucfirst($slug),
+ 'plural' => $taxConfig['plural'] ?? ucfirst($slug) . 's',
+ 'icon' => $slug,
+ 'taxonomies' => [], // Content taxonomies don't have sub-taxonomies
+ 'for_content' => $taxConfig['for_content'] ?? [],
+ ];
+ }
+
+ return $config;
+ });
+ }
}
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index ec82f0d..ad47bff 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -134,14 +134,15 @@
public function handleLogin(WP_REST_Request $request): WP_REST_Response
{
+ $data = $request->get_json_params();
// Verify Turnstile
- if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
+ if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
return $this->error('Security verification failed', 'turnstile_failed', 403);
}
- $username = $request->get_param('user_email');
- $password = $request->get_param('user_password');
- $remember = (bool)$request->get_param('remember_me');
+ $username = sanitize_email($data['user_email'] ?? '');
+ $password = $data['user_password'] ?? '';
+ $remember = (bool)($data['remember_me'] ?? false);
// Check for account lockout
$lockout = $this->checkAccountLockout($username);
@@ -638,18 +639,6 @@
return true;
}
- 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);
- }
/**
* Helper to return error response
diff --git a/inc/rest/routes/MagicLinkRoutes.php b/inc/rest/routes/MagicLinkRoutes.php
index fc12edc..a422c6f 100644
--- a/inc/rest/routes/MagicLinkRoutes.php
+++ b/inc/rest/routes/MagicLinkRoutes.php
@@ -104,8 +104,14 @@
*/
public function sendMagicLink(WP_REST_Request $request): WP_REST_Response
{
+ $data = $request->get_json_params();
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
+ return $this->error('Security verification failed', 'turnstile_failed', 403);
+ }
$email = sanitize_email($request->get_param('email')??$request->get_param('user_email')??'');
- $type = sanitize_text_field($request->get_param('type'));
+ $type = sanitize_text_field($request->get_param('type')) ?? MagicLinkManager::TYPE_LOGIN;
$context = $request->get_param('context') ?? [];
error_log('SendMagicLink request: '.print_r($email, true));
@@ -122,12 +128,16 @@
// Check if email exists
$exists = email_exists($email);
- if (!$exists) {
-
+ if ($type === MagicLinkManager::TYPE_LOGIN && !$exists) {
return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User account not found'
- ], 400);
+ 'success' => true,
+ 'message' => 'Invalid email address'
+ ]);
+ }
+
+ if ($type === MagicLinkManager::TYPE_SIGNUP && $exists) {
+ // Redirect to login instead
+ $type = MagicLinkManager::TYPE_LOGIN;
}
// Send the magic link
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index a5070db..11b409d 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -1,7 +1,9 @@
<?php
namespace JVBase\rest\routes;
+use Exception;
use JVBase\JVB;
+use JVBase\managers\CacheManager;
use JVBase\rest\RestRouteManager;
use WP_REST_Request;
use WP_REST_Response;
@@ -92,20 +94,13 @@
$ids = $request->get_param('ids');
$limit = intval($request->get_param('limit'));
- // User-specific caching (keep this)
- $user_queue_timestamp = $this->getUserQueueTimestamp($user_id);
-
- $if_modified_since = $request->get_header('If-Modified-Since');
- if ($if_modified_since) {
- $if_modified_timestamp = strtotime($if_modified_since);
- if ($user_queue_timestamp <= $if_modified_timestamp) {
- return new WP_REST_Response(null, 304);
- }
+ // Use base class user-specific header checking
+ // This checks both 'queue' and 'user_{$user_id}' timestamps
+ $cache_check = $this->checkUserHeaders($request, $user_id, 'queue');
+ if ($cache_check) {
+ return $cache_check; // Returns 304 Not Modified
}
- header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $user_queue_timestamp) . ' GMT');
- header('Cache-Control: private, max-age=30');
-
global $wpdb;
$table = $wpdb->prefix . $this->table;
@@ -135,19 +130,22 @@
$operations = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
- // Format operations with improved data structure
+ // Format operations
foreach ($operations as &$op) {
$op = $this->formatOperation($op);
}
- return new WP_REST_Response([
+ $response = new WP_REST_Response([
'items' => $operations,
'total' => count($operations),
- 'timestamp' => date('c'), // ISO format
+ 'timestamp' => date('c'),
'has_more' => count($operations) === $limit,
'queue_stats' => $this->getQueueStats($user_id),
- 'server_time' => date('c') // Helpful for frontend time sync
+ 'server_time' => date('c')
]);
+
+ // Add cache headers (ETag, Last-Modified)
+ return $this->addCacheHeaders($response);
}
@@ -230,16 +228,6 @@
}
/**
- * Update user's queue timestamp when any operation changes
- * This should be called whenever an operation status changes
- */
- public function updateUserQueueTimestamp(int $user_id): void
- {
- $key = "{$user_id}_queue_timestamp";
- $this->cache->set($key, time());
- }
-
- /**
* Convert MySQL datetime to ISO 8601 timestamp with proper timezone
*/
protected function formatTimestamp(?string $mysql_datetime): ?string
@@ -249,14 +237,19 @@
}
try {
- // Create DateTime object from MySQL datetime (assuming UTC storage)
- $date = new DateTime($mysql_datetime, new DateTimeZone('UTC'));
+ // Get WordPress timezone - dates are stored in this timezone
+ $wp_timezone = wp_timezone();
- // Return ISO 8601 format with UTC timezone indicator
- return $date->format('c'); // e.g., "2025-07-23T22:57:35+00:00"
+ // Parse the datetime in WordPress timezone
+ $date = new DateTime($mysql_datetime, $wp_timezone);
+
+ // Convert to UTC for API consistency
+ $date->setTimezone(new DateTimeZone('UTC'));
+
+ // Return ISO 8601 format
+ return $date->format('c');
} catch (Exception $e) {
- // Fallback: return null if datetime is invalid
return null;
}
}
@@ -292,24 +285,6 @@
return $base_title;
}
- /**
- * Get user's queue last update timestamp from cache
- */
- protected function getUserQueueTimestamp(int $user_id): int
- {
- $key = "{$user_id}_queue_timestamp";
-
- // Use CacheManager for consistency
- $timestamp = $this->cache->get($key);
-
- if ($timestamp === false) {
- $timestamp = time();
- $this->cache->set($key, $timestamp);
- }
-
- return $timestamp;
- }
-
/**
* Update operation status (dismiss or retry)
*
@@ -321,7 +296,9 @@
$data = $request->get_json_params();
$ids = $data['ids'] ?? [];
$action = $data['action'] ?? '';
- $user_id = get_current_user_id();
+
+ $user_id = (int)$data['user'];
+
// Validate input
if (empty($ids) || !is_array($ids)) {
@@ -362,19 +339,13 @@
$result = $this->processQueueAction($action, $valid_operations, $user_id);
if ($result['success']) {
- $this->invalidateUserCache($user_id);
+ // Update timestamp for this user's queue
+ CacheManager::updateTimestamp("user_{$user_id}");
}
return new WP_REST_Response($result);
}
- protected function invalidateUserCache(int $user_id): void
- {
- $key = "{$user_id}_queue_timestamp";
- $this->cache->invalidate($key);
- $this->cache->set($key, time());
- }
-
protected function processQueueAction(string $action, array $operations, int $user_id): array
{
global $wpdb;
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index 7526566..1f8c726 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -5,6 +5,7 @@
use JVBase\rest\RestRouteManager;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
+use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
@@ -180,9 +181,8 @@
public function handleTermSelectionRequest(WP_REST_Request $request):WP_REST_Response
{
$data = $request->get_params();
- $taxonomy = jvbCheckBase($data['taxonomy']);
+ $taxonomy = jvbCheckBase($data['taxonomy'])??'';
- error_log('Term Request Data for '.$taxonomy.': '.print_r($data, true));
// Check HTTP cache headers
$cache_check = $this->checkHeaders($request, $taxonomy);
if ($cache_check) {
@@ -190,6 +190,11 @@
return $cache_check;
}
+ // Handle batch request (multiple taxonomies)
+ if (str_contains($taxonomy, ',')) {
+ return $this->handleBatchTermRequest($taxonomy, $data, $request);
+ }
+
if (array_key_exists('termIDs', $data)) {
$args = [
'taxonomy' => $taxonomy,
@@ -223,9 +228,10 @@
$taxonomy = BASE.$request->get_param('taxonomy');
$search = $request->get_param('search');
- $parent = (int)$request->get_param('parent');
- $page = max(1, (int)$request->get_param('page'));
- $per_page = max(20, (int)$request->get_param('per_page'));
+
+ $parent = (int)$data['parent']??0;
+ $page = max(1, (int)($data['page']??1));
+ $per_page = 25;
if (!taxonomy_exists($taxonomy)) {
return new WP_REST_Response([
@@ -371,9 +377,9 @@
$key = $this->cache->generateKey($args);
$cache = $this->cache->get($key);
- $cache = false;
+
if ($cache) {
- $response = new WP_ReST_Response($cache);
+ $response = new WP_REST_Response($cache);
return $this->addCacheHeaders($response);
}
@@ -406,48 +412,110 @@
return $this->addCacheHeaders($response);
}
+ protected function handleBatchTermRequest(string $taxonomy, array $data, WP_REST_Request $request):WP_REST_Response
+ {
+ $taxonomies = array_map('trim', explode(',', $taxonomy));
+ $all_terms = [];
+ $parent = (int)$data['parent']??0;
+ $page = max(1, (int)($data['page']??1));
+ $per_page = 25;
+ $mainArgs = [
+ 'hide_empty'=> false,
+ 'parent' => $parent,
+ 'number' => $per_page,
+ 'orderby' => 'name',
+ 'offset' => ($page -1) * $per_page,
+ ];
+
+ foreach ($taxonomies as $taxonomy) {
+ if (!taxonomy_exists(BASE.$taxonomy)) {
+ continue;
+ }
+ $args = $mainArgs;
+ $args['taxonomy'] = BASE.$taxonomy;
+
+ $all_terms = array_merge($all_terms, $this->formatTerms($args, $taxonomy));
+ }
+
+ $response = [
+ 'items' => $all_terms,
+ 'pagination'=> [
+ 'page' => $page,
+ 'per_page'=> $per_page,
+ 'has_more' => true,
+ ]
+ ];
+
+ $response = new WP_REST_Response($response);
+ return $this->addCacheHeaders($response);
+ }
+
+
/**
* @param array $args
* @param string $taxonomy
*
* @return array
*/
- protected function formatTerms(array $args, string $taxonomy):array
- {
- $terms = get_terms($args);
+ protected function formatTerms(array $args, string $taxonomy): array
+ {
+ return $this->cache->remember(
+ $this->cache->generateKey($args),
+ function() use ($args, $taxonomy) {
+ $terms = get_terms($args);
- if (is_wp_error($terms)) {
- return [];
- }
+ if (is_wp_error($terms)) {
+ return [];
+ }
+
+ $formatted_terms = [];
+ foreach ($terms as $term) {
+ $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, true);
+ }
+
+ return $formatted_terms;
+ }
+ );
+ }
- $formatted_terms = [];
- foreach ($terms as $term) {
- // Check for children explicitly
- $children_args = [
- 'taxonomy' => $taxonomy,
- 'parent' => $term->term_id,
- 'fields' => 'count',
- 'hide_empty' => false
- ];
- $count = wp_count_terms($children_args);
- $has_children = !is_wp_error($count) && $count > 0;
+ /**
+ * Format a single term with caching
+ *
+ * @param object $term WP_Term object
+ * @param string $taxonomy Full taxonomy name
+ *
+ * @return array Formatted term data
+ */
+ protected function formatSingleTerm(object $term, string $taxonomy): array
+ {
+ $cache_key = "{$term->term_id}_{$taxonomy}";
- $formatted_terms[] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'slug' => $term->slug,
- 'parent' => $term->parent,
- 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
- 'hasChildren' => $has_children,
- 'taxonomy' => $term->taxonomy,
- 'count' => $term->count,
- ];
- }
+ return $this->cache->remember($cache_key, function() use ($term, $taxonomy) {
+ $data = [
+ 'id' => $term->term_id,
+ 'name' => $term->name,
+ 'slug' => $term->slug,
+ 'parent' => $term->parent,
+ 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
+ 'taxonomy' => jvbNoBase($term->taxonomy),
+ 'count' => $term->count,
+ ];
- return $formatted_terms;
- }
+ $children_args = [
+ 'taxonomy' => $taxonomy,
+ 'parent' => $term->term_id,
+ 'fields' => 'count',
+ 'hide_empty' => false
+ ];
+ $count = wp_count_terms($children_args);
+ $data['hasChildren'] = !is_wp_error($count) && $count > 0;
+
+
+ return $data;
+ });
+ }
/**
* @param WP_REST_Request $request
@@ -498,16 +566,10 @@
$total_terms = wp_count_terms($count_args);
$formatted_terms = [];
- foreach ($terms as $term) {
- $formatted_terms[$term->term_id] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'parent' => $term->parent,
- 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
- 'hasChildren' => (bool)$term->has_children,
- 'count' => (int)$term->count
- ];
- }
+ foreach ($terms as $term) {
+ // Search results show path, so includeChildren = false for performance
+ $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false);
+ }
// Calculate pagination info
$total_pages = ceil($total_terms / $per_page);
@@ -698,15 +760,12 @@
$formatted_terms = [];
$is_hierarchical = is_taxonomy_hierarchical($taxonomy);
- foreach ($terms as $term) {
- $formatted_terms[$term->term_id] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'count' => $term->count,
- 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
- 'relationship_strength' => $term->relationship_count ?? 0
- ];
- }
+ foreach ($terms as $term) {
+ $formatted = $this->formatSingleTerm($term, $taxonomy, false);
+ // Add relationship strength which is unique to this method
+ $formatted['relationship_strength'] = $term->relationship_count ?? 0;
+ $formatted_terms[] = $formatted;
+ }
// Build response
$total_pages = ceil($total / $per_page);
@@ -790,16 +849,10 @@
$is_hierarchical = is_taxonomy_hierarchical($taxonomy);
// Format terms
- $formatted_terms = [];
- foreach ($terms as $term) {
- $formatted_terms[$term->term_id] = [
- 'id' => $term->term_id,
- 'name' => $term->name,
- 'count' => $term->count,
- 'parent' => $term->parent,
- 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy)
- ];
- }
+ $formatted_terms = [];
+ foreach ($terms as $term) {
+ $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false);
+ }
// Get total for pagination
$total_args = array_merge($args, ['fields' => 'count', 'number' => '']);
@@ -842,13 +895,15 @@
$name = sanitize_text_field($request->get_param('name'));
$parent = (int)$request->get_param('parent') ?: 0;
+
try {
// Check if term already exists
$existing = term_exists($name, jvbCheckBase($taxonomy), $parent);
if ($existing) {
- $term = get_term($existing['term_id'], jvbCheckBase($taxonomy));
+ $term = get_term($existing['term_id'], jvbCheckBase($taxonomy));
+ error_log('Existing Term: '.print_r($term, true));
return new WP_REST_Response([
'success' => false,
'message' => 'Term already exists',
@@ -861,7 +916,8 @@
}
- if (jvbSiteHasTermApproval()) {
+ if (Features::forMembership()->has('term_approval')) {
+ error_log('Term Approval required');
// Get approval routes instance
$approval_routes = JVB()->routes('approvals');
// Create approval request
@@ -885,13 +941,15 @@
]
];
} else {
+ error_log('Creating new Term: ');
$termID = wp_insert_term(
$name,
jvbCheckBase($taxonomy),
[
- 'parent' => absint($parent??0)
+ 'parent' => absint($parent)
]
);
+ error_log('Result: '.print_r($termID, true));
if (is_wp_error($termID)) {
throw new Exception('Failed to create new term');
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 56daf36..4a6cf48 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -1330,7 +1330,9 @@
$used_upload_ids = [];
$content = jvbCheckBase($data['content']);
+ error_log('[processTimelineUploads]Got content: '.print_r($content, true));
$config = Features::getConfig($content);
+ error_log('[processTimelineUploads]Got config: '.print_r($config, true));
$defaultTitle = 'New '.$config['singular']. ' ';
foreach ($data['posts'] as $index=> $post) {
$title = !empty($post['fields']['post_title'])
diff --git a/inc/utility/Features.php b/inc/utility/Features.php
index d5f76bb..422bad3 100644
--- a/inc/utility/Features.php
+++ b/inc/utility/Features.php
@@ -752,6 +752,7 @@
public static function getConfig(string $slug, ?string $type = null): array
{
+ $slug = jvbNoBase($slug);
$all = ['post', 'content', 'taxonomy', 'user'];
$types = (!$type || !in_array($type, $all)) ? $all : [$type];
diff --git a/inc/utility/Image.php b/inc/utility/Image.php
new file mode 100644
index 0000000..8ecbd83
--- /dev/null
+++ b/inc/utility/Image.php
@@ -0,0 +1,88 @@
+<?php
+namespace JVBase\utility;
+
+use JVBase\managers\CacheManager;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+/**
+ * Validates configuration arrays for content types, taxonomies, and user roles
+ * Catches errors early in the registration process
+ */
+class Image
+{
+ protected ?CacheManager $cache = null;
+
+ public function __construct()
+ {
+ $this->cache = CacheManager::for('images')->connectTo('post', 'attachment');
+ }
+
+ public function formatImage(int $ID, string $start = 'tiny', string $replace = 'large', bool $addLink = true, ?string $postSlug = null):string
+ {
+ $return = $this->cache->remember(
+ ['ID' => $ID, 'start' => $start, 'replace' => $replace],
+ function() use ($ID, $start, $replace) {
+ $img = wp_get_attachment_image_src($ID, $start);
+ if (!$img) {
+ return'';
+ }
+ $img = $img[0];
+
+ $data = $this->getGallerySizes($ID, $replace);
+
+
+
+ $alt = get_post_meta($ID, '_wp_attachment_image_alt', true);
+ $alt = ($alt=='')? '' : ' alt="'.$alt.'" ';
+ return '<img width="100%" height="auto" src="'.$img.'"'.$alt.$data.' loading="lazy" decoding="async">';
+ }
+ );
+
+ $aOpen = $aClose = '';
+ if ($addLink) {
+ if (!$postSlug) {
+ global $post;
+ $postSlug = $post->post_name;
+ }
+
+ $imgPost = get_post($ID);
+ if (!$imgPost) {
+ return $return;
+ }
+ $imgSlug = $imgPost->post_name;
+ $aOpen = '<a class="open-gallery" target="_blank" rel="nofollow" data-opens="gallery-'.$postSlug.'" data-focus="'.$postSlug.'-'.$imgSlug.'">';
+ $aClose = '</a>';
+ }
+ return $aOpen.$return.$aClose;
+ }
+
+ public function getGallerySizes(int $ID, string $replace):string
+ {
+ if (!wp_get_attachment_image_src($ID)) {
+ return '';
+ }
+ if (!has_image_size($replace)) {
+ $replace = 'large';
+ }
+
+ if ($replace == 'large') {
+ return 'data-small="' .
+ wp_get_attachment_image_src($ID)[0] . '" data-medium="' .
+ wp_get_attachment_image_src($ID, 'large')[0] . '" data-full="' .
+ wp_get_attachment_image_src($ID, 'full')[0] . '"';
+ } elseif ($replace == 'medium') {
+ return 'data-small="'.
+ wp_get_attachment_image_src($ID, 'large')[0].'" data-medium="'.
+ wp_get_attachment_image_src($ID, 'large')[0].'" data-full="'.
+ wp_get_attachment_image_src($ID, 'large')[0].'"';
+ } elseif ($replace == 'thumbnail') {
+ return 'data-small="'.
+ wp_get_attachment_image_src($ID, 'medium')[0].'" data-medium="'.
+ wp_get_attachment_image_src($ID, 'medium')[0].'" data-full="'.
+ wp_get_attachment_image_src($ID, 'medium')[0].'"';
+ }
+ return '';
+ }
+}
diff --git a/inc/utility/setup.php b/inc/utility/setup.php
index ffc5413..9cbe572 100644
--- a/inc/utility/setup.php
+++ b/inc/utility/setup.php
@@ -4,3 +4,4 @@
require(JVB_DIR.'/inc/utility/Features.php');
require(JVB_DIR.'/inc/utility/helpers.php');
require(JVB_DIR.'/inc/utility/Validator.php');
+require(JVB_DIR.'/inc/utility/Image.php');
diff --git a/jvb.php b/jvb.php
index 816a013..38dbb27 100644
--- a/jvb.php
+++ b/jvb.php
@@ -252,7 +252,7 @@
'jvb-utility',
JVB_URL.'assets/js/min/utility.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -266,7 +266,7 @@
'jvb-queue',
'jvb-data-store'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -279,7 +279,7 @@
'jvb-queue',
'jvb-data-store'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -294,7 +294,7 @@
'jvb-utility',
'jvb-data-store'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -307,7 +307,7 @@
[
'jvb-a11y'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true
@@ -320,7 +320,7 @@
'jvb-media',
JVB_URL.'assets/js/min/media.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -336,7 +336,7 @@
'jvb-modal',
'jvb-a11y'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -361,9 +361,11 @@
JVB_URL.'assets/js/min/gallery.min.js',
[
'jvb-utility',
- 'jvb-queue'
+// 'jvb-queue',
+ 'jvb-modal',
+// 'jvb-swiper',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -371,10 +373,23 @@
);
wp_register_script(
+ 'jvb-swiper',
+ JVB_URL.'assets/js/min/swiper.min.js',
+ [
+ 'jvb-utility',
+ ],
+ '1.0.1',
+ [
+ 'strategy' => 'defer',
+ 'in_footer' => true,
+ ]
+ );
+
+ wp_register_script(
'jvb-integrations',
JVB_URL.'assets/js/min/integrations.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true
@@ -412,7 +427,7 @@
'jvb-page-nav',
JVB_URL.'assets/js/min/page-nav.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -424,7 +439,7 @@
'jvb-a11y',
JVB_URL.'assets/js/min/a11y.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -438,7 +453,7 @@
[
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -450,7 +465,7 @@
'jvb-cache',
JVB_URL.'assets/js/min/cache.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -461,7 +476,7 @@
'jvb-data-store',
JVB_URL.'assets/js/min/dataStore.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -475,7 +490,7 @@
[
'jvb-a11y'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -489,7 +504,7 @@
[
'jvb-a11y'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -507,7 +522,7 @@
'jvb-utility',
'jvb-popup'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -526,7 +541,7 @@
'jvb-modal',
// 'jvb-loading'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -537,7 +552,7 @@
'jvb-creator',
JVB_URL.'assets/js/min/creator.min.js',
['jvb-selector'],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true
@@ -551,7 +566,7 @@
[
'jvb-selector'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -588,7 +603,7 @@
'jvb-a11y',
'jvb-utility',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -601,7 +616,7 @@
'jvb-a11y',
'jvb-utility',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -613,6 +628,7 @@
'jvb-uploader',
JVB_URL.'assets/js/min/uploader.min.js',
[
+ 'sortable-js',
'jvb-cache',
'jvb-a11y',
'jvb-utility',
@@ -623,7 +639,7 @@
'jvb-queue',
'jvb-notifications'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -671,7 +687,7 @@
[
'jvb-utility',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -691,7 +707,7 @@
'jvb-populate-form',
'jvb-quill',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -702,7 +718,7 @@
'jvb-populate-form',
JVB_URL.'assets/js/min/populate.min.js',
[],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -714,7 +730,7 @@
[
'quill-js'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -738,7 +754,7 @@
'jvb-view',
'jvb-modal'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -756,7 +772,7 @@
'jvb-error',
'jvb-populate-form'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -772,7 +788,7 @@
'jvb-form',
'jvb-queue'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -788,7 +804,7 @@
'jvb-form',
'jvb-queue'
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -809,7 +825,7 @@
'jvb-selector',
'jvb-post-selector',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -830,7 +846,7 @@
'jvb-selector',
'jvb-notifications',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -851,7 +867,7 @@
'jvb-selector',
'jvb-tabs',
],
- '1.0.0',
+ '1.0.1',
[
'strategy' => 'defer',
'in_footer' => true,
@@ -1031,6 +1047,10 @@
jvbAddScriptDependency('jvb-feed-view-script', 'jvb-queue');
jvbAddScriptDependency('jvb-feed-view-script', 'jvb-selector');
+ jvbAddScriptDependency('jvb-feed-view-script', 'jvb-data-store');
+ jvbAddScriptDependency('jvb-feed-view-script', 'jvb-cache');
+ jvbAddScriptDependency('jvb-feed-view-script', 'jvb-a11y');
+ jvbAddScriptDependency('jvb-feed-view-script', 'jvb-utility');
jvbAddScriptDependency('jvb-feed-view-script', 'jvb-gallery');
// jvbAddScriptDependency('jvb-feed-view-script', 'jvb-loading');
jvbAddScriptDependency('jvb-forms-view-script', 'jvb-queue');
diff --git a/src/feed/block.json b/src/feed/block.json
index 60f810b..3fec37e 100644
--- a/src/feed/block.json
+++ b/src/feed/block.json
@@ -50,10 +50,8 @@
"message": "This is a notice!"
}
},
- "render": "file:./render.php",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
- "viewScript": "file:./view.js",
- "dependencies": ["jvb-taxonomy-selector", "jvb-client-queue"]
+ "viewScript": "file:./view.js"
}
diff --git a/src/feed/edit.js b/src/feed/edit.js
index d6bc6ff..2a5228d 100644
--- a/src/feed/edit.js
+++ b/src/feed/edit.js
@@ -1,114 +1,256 @@
-import { __ } from '@wordpress/i18n';
-import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
-import { PanelBody, TextControl, ToggleControl, SelectControl, CheckboxControl } from '@wordpress/components';
+/**
+ * Feed Block - Edit Component
+ * Fetches available feed types from /jvb/v1/feed/types
+ * Allows configuration of content types and inherit query setting
+ */
+
import { useEffect, useState } from '@wordpress/element';
+import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
+import {
+ PanelBody,
+ CheckboxControl,
+ ToggleControl,
+ Spinner,
+ Notice
+} from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
-import './editor.scss';
+import { __ } from '@wordpress/i18n';
export default function Edit({ attributes, setAttributes }) {
- const blockProps = useBlockProps();
- const [availableTypes, setAvailableTypes] = useState({});
+ const [feedTypes, setFeedTypes] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
- useEffect(() => {
- apiFetch({ path: '/jvb/v1/types' }).then(types => {
- setAvailableTypes(JSON.parse(types));
- });
- }, []);
+ const blockProps = useBlockProps({
+ className: 'feed-block-editor'
+ });
- return (
- <>
- <InspectorControls>
- <PanelBody title={__('Feed Settings', 'jvb')}>
- <TextControl
- label={__('Title', 'jvb')}
- value={attributes.title}
- onChange={(title) => setAttributes({ title })}
- />
+ /**
+ * Fetch available feed types on component mount
+ */
+ useEffect(() => {
+ apiFetch({
+ path: '/jvb/v1/feed/types',
+ headers: {
+ 'If-Modified-Since': localStorage.getItem('feed_types_modified'),
+ }
+ })
+ .then(types => {
+ setFeedTypes(types);
+ setLoading(false);
- <ToggleControl
- label={__('Inherit Current Query', 'jvb')}
- help={__('Inherit filters from the current archive or taxonomy query', 'jvb')}
- checked={attributes.inheritQuery}
- onChange={(inheritQuery) => setAttributes({ inheritQuery })}
- />
+ // Store Last-Modified for future requests
+ // (apiFetch doesn't expose response headers easily,
+ // but the server will handle 304s)
- {!attributes.inheritQuery && (
- <div className="feed-content-types">
- <p className="components-base-control__label">
- {__('Content Types', 'jvb')}
- </p>
- <div className="checkbox-list">
- {Object.entries(availableTypes).map(([id, label]) => (
- <CheckboxControl
- key={id}
- label={label}
- checked={attributes.contentTypes.includes(id)}
- onChange={(isChecked) => {
- const newTypes = isChecked
- ? [...attributes.contentTypes, id]
- : attributes.contentTypes.filter(t => t !== id);
- setAttributes({contentTypes: newTypes});
- }}
- />
- ))}
- </div>
- <div className="select-all-wrapper">
- <CheckboxControl
- label={__('Select All', 'jvb')}
- checked={attributes.contentTypes.length === Object.keys(availableTypes).length}
- onChange={(isChecked) => {
- setAttributes({
- contentTypes: isChecked ? Object.keys(availableTypes) : []
- });
- }}
- />
- </div>
- </div>
- )}
+ // Initialize contentTypes if not set and not inheriting
+ if (!attributes.contentTypes && !attributes.inheritQuery) {
+ const firstType = Object.keys(types)[0];
+ if (firstType) {
+ setAttributes({ contentTypes: [firstType] });
+ }
+ }
+ })
+ .catch(err => {
+ console.error('Error loading feed types:', err);
+ setError(err.message);
+ setLoading(false);
+ });
+ }, [attributes.inheritQuery]);
- <SelectControl
- label={__('Items Per Page', 'jvb')}
- value={attributes.itemsPerPage}
- options={[
- {label: '12', value: 12},
- {label: '24', value: 24},
- {label: '36', value: 36}
- ]}
- onChange={(itemsPerPage) => setAttributes({itemsPerPage: parseInt(itemsPerPage)})}
- />
+ /**
+ * Toggle a content type in the selection
+ */
+ const toggleContentType = (slug, checked) => {
+ const currentTypes = attributes.contentTypes || [];
- <SelectControl
- label={__('Default Order', 'jvb')}
- value={attributes.defaultOrder}
- options={[
- {label: __('Newest First', 'jvb'), value: 'date_desc' },
- { label: __('Oldest First', 'jvb'), value: 'date_asc' },
- { label: __('Random', 'jvb'), value: 'random' }
- ]}
- onChange={(defaultOrder) => setAttributes({ defaultOrder })}
- />
- </PanelBody>
- </InspectorControls>
+ const newTypes = checked
+ ? [...currentTypes, slug]
+ : currentTypes.filter(t => t !== slug);
- <div {...blockProps}>
- <div className="feed-block-preview">
- <h2>{attributes.title}</h2>
- <div className="feed-filters">
- <div className="filter-preview">
- {attributes.contentTypes.map(type => (
- <span key={type} className="content-type-badge">
- {availableTypes[type]}
- </span>
- ))}
- </div>
- </div>
- <div className="feed-grid-placeholder">
- {[...Array(6)].map((_, i) => (
- <div key={i} className="grid-item-placeholder" />
- ))}
- </div>
- </div>
- </div>
- </>
- );
+ setAttributes({ contentTypes: newTypes });
+ };
+
+ /**
+ * Get friendly label for content type
+ */
+ const getTypeLabel = (slug, config) => {
+ return `${config.plural} (${config.type})`;
+ };
+
+ /**
+ * Group types by category for better UX
+ */
+ const groupedTypes = feedTypes ? {
+ content: Object.entries(feedTypes)
+ .filter(([_, config]) => config.type === 'content'),
+ taxonomy: Object.entries(feedTypes)
+ .filter(([_, config]) => config.type === 'taxonomy')
+ } : { content: [], taxonomy: [] };
+
+ return (
+ <div {...blockProps}>
+ <InspectorControls>
+ <PanelBody
+ title={__('Feed Settings', 'jvb')}
+ initialOpen={true}
+ >
+ <ToggleControl
+ label={__('Inherit from Page Context', 'jvb')}
+ help={
+ attributes.inheritQuery
+ ? __('Feed will adapt to the current page (profile, taxonomy, etc.)', 'jvb')
+ : __('Manually select content types to display', 'jvb')
+ }
+ checked={attributes.inheritQuery}
+ onChange={(value) => setAttributes({ inheritQuery: value })}
+ />
+
+ {!attributes.inheritQuery && (
+ <>
+ {loading && (
+ <div style={{ textAlign: 'center', padding: '20px' }}>
+ <Spinner />
+ <p>{__('Loading feed types...', 'jvb')}</p>
+ </div>
+ )}
+
+ {error && (
+ <Notice status="error" isDismissible={false}>
+ {__('Error loading feed types: ', 'jvb')} {error}
+ </Notice>
+ )}
+
+ {!loading && !error && feedTypes && (
+ <>
+ {groupedTypes.content.length > 0 && (
+ <>
+ <h4>{__('Content Types', 'jvb')}</h4>
+ {groupedTypes.content.map(([slug, config]) => (
+ <CheckboxControl
+ key={slug}
+ label={getTypeLabel(slug, config)}
+ checked={
+ attributes.contentTypes?.includes(slug) || false
+ }
+ onChange={(checked) =>
+ toggleContentType(slug, checked)
+ }
+ help={
+ config.taxonomies?.length > 0
+ ? `Filters: ${config.taxonomies.join(', ')}`
+ : null
+ }
+ />
+ ))}
+ </>
+ )}
+
+ {groupedTypes.taxonomy.length > 0 && (
+ <>
+ <h4 style={{ marginTop: '20px' }}>
+ {__('Content Taxonomies', 'jvb')}
+ </h4>
+ <p style={{ fontSize: '12px', color: '#757575' }}>
+ {__('These are collections that group other content', 'jvb')}
+ </p>
+ {groupedTypes.taxonomy.map(([slug, config]) => (
+ <CheckboxControl
+ key={slug}
+ label={getTypeLabel(slug, config)}
+ checked={
+ attributes.contentTypes?.includes(slug) || false
+ }
+ onChange={(checked) =>
+ toggleContentType(slug, checked)
+ }
+ help={
+ config.for_content?.length > 0
+ ? `Contains: ${config.for_content.join(', ')}`
+ : null
+ }
+ />
+ ))}
+ </>
+ )}
+
+ {!attributes.contentTypes?.length && (
+ <Notice status="warning" isDismissible={false}>
+ {__('Please select at least one content type', 'jvb')}
+ </Notice>
+ )}
+ </>
+ )}
+ </>
+ )}
+ </PanelBody>
+
+ <PanelBody
+ title={__('Display Settings', 'jvb')}
+ initialOpen={false}
+ >
+ <ToggleControl
+ label={__('Show Gallery View', 'jvb')}
+ help={__('Enable lightbox for images', 'jvb')}
+ checked={attributes.enableGallery || false}
+ onChange={(value) =>
+ setAttributes({ enableGallery: value })
+ }
+ />
+ </PanelBody>
+ </InspectorControls>
+
+ <div className="feed-block-placeholder">
+ <div className="feed-block-icon">
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
+ <rect x="3" y="3" width="7" height="7" fill="currentColor" opacity="0.3" />
+ <rect x="13" y="3" width="7" height="7" fill="currentColor" opacity="0.3" />
+ <rect x="3" y="13" width="7" height="7" fill="currentColor" opacity="0.3" />
+ <rect x="13" y="13" width="7" height="7" fill="currentColor" opacity="0.3" />
+ </svg>
+ </div>
+
+ <h3>{__('Feed Block', 'jvb')}</h3>
+
+ {attributes.inheritQuery ? (
+ <p className="feed-block-description">
+ {__('📍 Inheriting from page context', 'jvb')}
+ </p>
+ ) : (
+ <div className="feed-block-description">
+ {attributes.contentTypes?.length > 0 ? (
+ <>
+ <p><strong>{__('Showing:', 'jvb')}</strong></p>
+ <ul style={{
+ listStyle: 'none',
+ padding: '0',
+ margin: '8px 0'
+ }}>
+ {attributes.contentTypes.map(type => {
+ const config = feedTypes?.[type];
+ return (
+ <li key={type} style={{
+ padding: '4px 0',
+ color: '#2271b1'
+ }}>
+ ✓ {config?.plural || type}
+ </li>
+ );
+ })}
+ </ul>
+ </>
+ ) : (
+ <p style={{ color: '#d63638' }}>
+ {__('⚠️ No content types selected', 'jvb')}
+ </p>
+ )}
+ </div>
+ )}
+
+ <p className="feed-block-note">
+ {__('Feed will be displayed on the frontend', 'jvb')}
+ </p>
+ </div>
+ </div>
+ );
}
diff --git a/src/feed/style.scss b/src/feed/style.scss
index ed5cff5..b40d457 100644
--- a/src/feed/style.scss
+++ b/src/feed/style.scss
@@ -1,168 +1,690 @@
+//.feed-block {
+// max-width: var(--full);
+// margin: 0 auto;
+//
+// &:target {
+// scroll-snap-margin-top: 5rem;
+// scroll-margin-top: 5rem;
+// outline: 0;
+// border-radius: 0;
+// padding: 0;
+//
+// .feed-item {
+// outline: double var(--pink-0);
+// }
+// }
+//}
+//
+//.loading .feed-block {
+// opacity: .7;
+//}
+//
+//.label {
+// display: flex;
+// align-items: center;
+// gap: .25rem;
+// font-size: .9rem;
+// a:hover {
+// color: var(--pink-0);
+// }
+//}
+//
+///** Filters Form **/
+//.feed-filters {
+// margin: 2rem 0;
+// position: sticky;
+// top: 3rem;
+// z-index: 15;
+// background: var(--overlay-heavy);
+// padding: .25rem 3rem;
+// details[open] summary {
+// background-color: var(--overlay);
+// }
+// summary {
+// justify-content: flex-start;
+//
+// > * {
+// order: 3;
+// }
+// .label {
+// order: 1;
+// }
+// .filter-label {
+// order: 2;
+// }
+// &::after {
+// order: 4;
+// }
+// #favourites + label {
+// margin-left: auto;
+// }
+// #favourites + label:hover,
+// #favourites:checked + label {
+// border-color: var(--pink-0);
+// background-color: var(--pink-0);
+// color: var(--white);
+//
+// }
+// #favourites:checked + label {
+// animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+// }
+// }
+//
+// details[open],
+// summary:hover {
+// background-color: var(--overlay-heavy);
+// }
+//
+// &:has(#favourites) {
+// summary::after {
+// margin-left: 1rem;
+// }
+// }
+//}
+//summary > * {
+// order: 3;
+//}
+//summary .label {
+// order: 1;
+//}
+//.filter-label {
+// display: inline-block;
+// vertical-align: middle;
+// height: 1.3em;
+// order: 2;
+// margin: 0;
+// padding: 0;
+//
+// li {
+// list-style: none;
+// height: 0;
+// overflow: hidden;
+// &.active {
+// height: 100%;
+// }
+// }
+//}
+//.filter-group {
+// display: flex;
+// align-items: center;
+// gap: .5rem;
+// flex-wrap: wrap;
+// label {
+// font-weight: 100;
+// }
+// &:has(.order-by) {
+// justify-content: space-between;
+// }
+// .order-by,
+// .order-direction {
+// display: flex;
+// flex-wrap: wrap;
+// gap: .5rem;
+// flex: 1;
+// justify-content: flex-start;
+// .label {
+// width: 100%;
+// }
+//
+// label:has(.label) {
+// padding: 0 .35rem;
+// gap: .5rem;
+// .label {
+// font-size: 1rem;
+// }
+// }
+// }
+//}
+//.filter-group >.label {
+// width: 100%;
+//}
+//
+///**
+//Feed Grid
+// */
+//.feed-grid {
+// display: grid;
+// grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+// gap: .5rem;
+// margin-bottom: 2rem;
+// padding: 0 4rem;
+// --delay: 0s;
+// --increase: .1s;
+//}
+//.feed-empty-state {
+// grid-column: 1/-1;
+// text-align: center;
+// padding: 2rem;
+// background: var(--base-100);
+// border-radius: var(--innerRadius);
+// margin: 0 auto;
+// max-width: 600px;
+//}
+///**
+//Placeholders
+// */
+//.placeholder {
+// aspect-ratio: 1;
+// background: var(--base);
+// border: 1rem solid var(--base-50);
+// border-radius: 1rem;
+// display: flex;
+// justify-content: center;
+// align-items: center;
+//
+// .icon {
+// --w: 50%;
+// color: var(--base-200);
+// animation: dance 2.5s ease-in-out infinite;
+//
+// }
+//}
+//
+///**
+//Feed Items
+// */
+//.feed-item {
+// position: relative;
+// border-radius: 0.5rem;
+// overflow: hidden;
+// background: var(--base-50);
+// box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+// opacity: 0;
+// transition: opacity var(--transition-base) var(--delay);
+// height: fit-content;
+// padding: 0;
+//
+// img {
+// opacity: .7;
+// filter: grayscale(.5) sepia(.3) blur(7px);
+// }
+// &[data-loaded=true] {
+// img {
+// opacity: 1;
+// filter: none;
+// }
+// }
+//
+// a {
+// &::before,
+// &::after {
+// display: none;
+// }
+// }
+// details a {
+// font-size: clamp(1rem, 0.9306rem + 0.2222vw, 1.125rem);
+// }
+//
+// &[data-loaded] {
+// opacity: 1;
+// + [data-loaded] {
+// --delay: var(--delay) + var(--increase);
+// }
+// }
+//
+// &.highlighted {
+// box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1);
+// animation: highlight-puls 2s ease-in-out;
+// }
+// &[open],
+// &:hover {
+// .handle {
+// background-color: var(--overlay-pink-medium);
+// backdrop-filter: blur(5px);
+// }
+// }
+// summary {
+// width: calc(100% - 1rem);
+// height: 100%;
+// aspect-ratio: 1;
+// .handle {
+// position: absolute;
+// bottom: 0;
+// left: 0;
+// right: 0;
+// background-color: var(--overlay-light);
+// backdrop-filter: blur(5px);
+// border-radius: var(--innerRadius);
+// z-index: 1;
+// padding: .25rem .25rem .25rem 1.1rem;
+// }
+// &::after {
+// z-index: 11;
+// position: absolute;
+// bottom: .35rem;
+// right: .7rem;
+// width: 1.5rem;
+// height: 1.5rem;
+// cursor: pointer;
+// }
+// }
+//
+// label {
+// font-weight: normal;
+// text-transform: none;
+// .icon {
+// --w: 1.5em;
+// }
+// }
+//}
+///**
+//Load More Button
+// */
+//.load-more {
+// opacity: 1;
+// margin: 1rem auto;
+// width: 66.666%;
+// display: flex;
+// align-items: center;
+// gap: .5rem;
+// padding: .75rem 1.5rem;
+// background: var(--base);
+// color: var(--contrast);
+// border-radius: 4px;
+// font-size: var(--medium);
+// transition: all var(--transition-base);
+// border: 2px solid transparent;
+// &[hidden] {
+// opacity: 0;
+// transition: all var(--transition-base);
+// }
+// &:hover {
+// background: var(--pink-50);
+// border-color: var(--contrast);
+// color: var(--white);
+// }
+//}
+///**
+//favourite button
+// */
+//button.favourite {
+// position: absolute;
+// top: .5rem;
+// right: .5rem;
+// z-index: 10;
+// background: var(--overlay-medium);
+// border-radius: 50%;
+// box-shadow: var(--subtle);
+// border: none;
+// width: 2rem;
+// height: 2rem;
+// display: flex;
+// justify-content: center;
+// align-items: center;
+// backdrop-filter: blur(5px);
+// transition: all var(--transition-base);
+//
+// &:hover {
+// transform: scale(1.1);
+// color: var(--pink-0);
+// background: var(--base);
+// box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+// }
+//
+// &.favourited {
+// animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+// }
+//}
+//
+//
+///** Images **/
+//.feed-image {
+// display: block;
+// aspect-ratio: 1;
+// overflow: hidden;
+// width: 100%;
+// height: 100%;
+//}
+//.feed-images {
+// width: 100%;
+// height: 100%;
+// &.multi {
+// display :grid;
+// grid-template-columns: repeat(3, 1fr);
+// grid-auto-rows: 1fr;
+// gap: 4px;
+//
+// > a {
+// width: 100%;
+// height: 100%;
+// aspect-ratio: 1;
+// }
+// .feed-image {
+// grid-row: span 2;
+// grid-column: span 2;
+// }
+// }
+// img {
+// width: 100%;
+// height: 100%;
+// object-fit: cover;
+// transition: transform var(--timing) var(--function);
+// }
+// a:hover img {
+// transform: scale(1.05);
+// }
+//}
+//
+//.feed-item {
+// &:nth-of-type(4n+2) .multi .feed-image {
+// grid-column: 2 / span 2;
+// grid-row: 1 / span 2;
+// }
+// &:nth-of-type(4n+3) .multi .feed-image {
+// grid-row: 2 / span 2;
+// grid-column: 1 / span 2;
+// }
+// &:nth-of-type(4n+4) .multi .feed-image {
+// grid-column: 2 / span 2;
+// grid-row: 2 / span 2;
+// }
+//}
+//
+///** Item Information **/
+//.item-info {
+// padding: .25rem;
+// border-left: 1px solid var(--base-200);
+// >div + div {
+// margin-top: .5em;
+// position: relative;
+//
+// &::before {
+// content: '';
+// display: block;
+// position: absolute;
+// top: -.3em;
+// left: -.25rem;
+// width: 66.6%;
+// border-bottom: 1px solid var(--base-200);
+// }
+// }
+// h3 {
+// margin: 0 0 .5em 0!important;
+// font-size: 1.1rem;
+// font-family: var(--body);
+// font-weight: var(--bWeight);
+// }
+// span {
+// text-transform: uppercase;
+// display: flex;
+// align-items: center;
+// }
+// .icon {
+// --w: 1.1em;
+// margin-right: .5em;
+// display: inline-block;
+// vertical-align: middle;
+// }
+//}
+//.item-list {
+// ul {
+// margin: 0;
+// padding: .5em 0;
+// display: flex;
+// flex-wrap: wrap;
+// gap: .5rem;
+// li {
+// list-style: none;
+// }
+// }
+// a {
+// background-color: var(--pink-0);
+// border: 1px solid transparent;
+// border-radius: 4px;
+// color: var(--light-0);
+// padding: .25em;
+// line-height: .8;
+// &:visited {
+// background-color: var(--pink-100);
+// color: var(--white);
+// }
+// &:visited:hover,
+// &:visited:focus,
+// &:hover,
+// &:focus {
+// background-color: transparent;
+// border-color: var(--contrast);
+// color: var(--contrast);
+// }
+// }
+//}
+///**
+//Loading
+// */
+//.loading {
+// opacity: .7;
+//}
+//
+//.loading-overlay {
+// position: fixed;
+// top: 0;
+// left: 0;
+// right: 0;
+// bottom: 0;
+// background-color: var(--overlay-medium);
+// display: flex;
+// align-items: center;
+// justify-content: center;
+// transition: opacity 0.3s ease, visibility 0.3s ease;
+// z-index: 9999;
+// opacity: 0;
+// visibility: hidden;
+//}
+//.loading .loading-overlay {
+// opacity: 1;
+// visibility: visible;
+//
+// &::after {
+// content: '';
+// position: absolute;
+// z-index: -1;
+// inset: 0;
+// background: linear-gradient(
+// 90deg,
+// var(--shimmer)
+// );
+// animation: shimmer 3s ease-in-out infinite;
+// }
+//
+// .wrapper {
+// background-color: var(--overlay-heavy);
+// padding: 2rem;
+// border-radius: var(--innerRadius);
+// text-align: center;
+// max-width: 90%;
+// width: 400px;
+// height: 300px;
+// z-index: 5;
+// display: flex;
+// justify-content: center;
+// align-items: center;
+// position: relative;
+//
+// .spinner {
+// --h: 150px;
+// --w: calc(var(--h) * 2);
+// border-top: 5px solid var(--pink-0);
+// border-radius: 50%;
+// position: absolute;
+// width: var(--w);
+// height: var(--w);
+// top: calc(50% - var(--h));
+// left: calc(50% - var(--h));
+// opacity: .5;
+// z-index: 0;
+// animation: spin 1s var(--timing) infinite;
+// }
+// div.icon {
+// height: 50px;
+// width: 50px;
+//
+// .icon {
+// --w: 100%;
+// svg {
+// animation: dance 2s ease-in-out infinite;
+// transition: color 0.3s ease;
+// }
+// }
+// }
+// .status {
+// height: 200px;
+// width: 100%;
+// z-index: 5;
+// display: flex;
+// flex-direction: column;
+// align-items: center;
+//
+// h3 {
+// margin: 1.5rem 0 .25rem!important;
+// color: var(--contrast-200);
+// }
+// .message {
+// margin: 0;
+// max-width: 275px;
+// color: var(--contrast-100);
+// font-size: var(--small);
+// animation: flicker 2s infinite;
+// }
+// }
+// }
+//}
+//
+//
+///* Animations */
+//@keyframes highlight {
+// 0%, 100% {
+// box-shadow: none;
+// }
+// 50% {
+// box-shadow: 0 0 0 4px var(--pink-0);
+// }
+//}
+//
+//@keyframes pop {
+// 0% { transform: scale(1); }
+// 50% { transform: scale(1.3); }
+// 75% { transform: scale(0.9); }
+// 100% { transform: scale(1); }
+//}
+//
+//@keyframes bubble {
+// 50% { box-shadow: 19px 0 0 3px, 38px 0 0 7px, 57px 0 0 3px }
+// 100% { box-shadow: 19px 0 0 0, 38px 0 0 3px, 57px 0 0 7px }
+//}
+//@keyframes highlight-pulse {
+// 0%, 100% { box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1); }
+// 50% { box-shadow: 0 0 0 8px #FF0080, 0 12px 24px rgba(0, 0, 0, 0.15); }
+//}
+//
+//@keyframes spin {
+// to { transform: rotate(360deg); }
+//}
+//
+//@keyframes shimmer {
+// 0% { transform: translateX(-100%); }
+// 50%, 100% { transform: translateX(100%); }
+//}
+//@keyframes dance {
+// 0%, 100% { transform: rotate(-5deg) scale(1);}
+// 50% { transform: rotate(5deg) scale(1.1); }
+//}
+//@keyframes flicker {
+// 0% { opacity: 0.6; }
+// 50% { opacity: 1; }
+// 100% { opacity: 0.6; }
+//}
+//
+//
+///**
+//Accessibility
+// */
+//
+///* Keyboard navigable feed items */
+//.feed-item[tabindex="0"] {
+// position: relative;
+//}
+//
+//.feed-item[tabindex="0"]::after {
+// content: '';
+// position: absolute;
+// top: 0;
+// left: 0;
+// right: 0;
+// bottom: 0;
+// pointer-events: none;
+// border: 2px solid transparent;
+// transition: border-color 0.2s ease;
+//}
+//
+//.feed-item[tabindex="0"]:focus::after {
+// border-color: #FF0080;
+//}
+//
+//
+//.feed-block .item {
+// summary {
+// a {
+// background-color: var(--action-0);
+// display: flex;
+// gap: .25rem;
+// flex-wrap: nowrap;
+// aspect-ratio: 1;
+// position: relative;
+// }
+// img {
+// width: 50%;
+// height: 100%;
+// object-fit: cover;
+// }
+//
+// }
+//}
+
+
.feed-block {
- max-width: var(--full);
- margin: 0 auto;
-
- &:target {
- scroll-snap-margin-top: 5rem;
- scroll-margin-top: 5rem;
- outline: 0;
- border-radius: v0;
- padding: 0;
-
- .feed-item {
- outline: double var(--pink-0);
- }
+ .feed-filters {
+ padding: 1rem 0;
}
-}
-
-.loading .feed-block {
- opacity: .7;
-}
-
-.label {
- display: flex;
- align-items: center;
- gap: .25rem;
- font-size: .9rem;
- a:hover {
- color: var(--pink-0);
- }
-}
-
-/** Filters Form **/
-.feed-filters {
- margin: 2rem 0;
- position: sticky;
- top: 3rem;
- z-index: 15;
- background: var(--overlay-heavy);
- padding: .25rem 3rem;
- details[open] summary {
- background-color: var(--overlay);
- }
- summary {
- justify-content: flex-start;
-
- > * {
- order: 3;
- }
+ .filter-group {
+ position: relative;
+ padding: 2rem 0;
.label {
- order: 1;
+ position: absolute;
+ left: 0;
}
- .filter-label {
- order: 2;
+ > .label {
+ top: 0;
}
- &::after {
- order: 4;
- }
- #favourites + label {
- margin-left: auto;
- }
- #favourites + label:hover,
- #favourites:checked + label {
- border-color: var(--pink-0);
- background-color: var(--pink-0);
- color: var(--white);
-
- }
- #favourites:checked + label {
- animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- }
- }
-
- details[open],
- summary:hover {
- background-color: var(--overlay-heavy);
- }
-
- &:has(#favourites) {
- summary::after {
- margin-left: 1rem;
- }
- }
-}
-summary > * {
- order: 3;
-}
-summary .label {
- order: 1;
-}
-.filter-label {
- display: inline-block;
- vertical-align: middle;
- height: 1.3em;
- order: 2;
- margin: 0;
- padding: 0;
-
- li {
- list-style: none;
- height: 0;
- overflow: hidden;
- &.active {
- height: 100%;
- }
- }
-}
-.filter-group {
- display: flex;
- align-items: center;
- gap: .5rem;
- flex-wrap: wrap;
- label {
- font-weight: 100;
- }
- &:has(.order-by) {
- justify-content: space-between;
- }
- .order-by,
- .order-direction {
- display: flex;
- flex-wrap: wrap;
- gap: .5rem;
- flex: 1;
- justify-content: flex-start;
- .label {
- width: 100%;
- }
-
- label:has(.label) {
- padding: 0 .35rem;
- gap: .5rem;
- .label {
- font-size: 1rem;
+ button, label {
+ position: relative;
+ padding: .5rem;
+ height: max-content;
+ &:hover {
+ color: var(--action-contrast);
}
}
+ button:hover .label,
+ :checked + label .label,
+ label:hover .label {
+ opacity: 1;
+ visibility: visible;
+ }
+ &:has(label:hover) :checked + label .label,
+ button .label,
+ label .label {
+ --height: max-content;
+ opacity: 0;
+ visibility: hidden;
+ bottom: -2rem;
+ width: max-content;
+ white-space: nowrap;
+ font-weight: var(--bWeight);
+ }
+
+
}
}
-.filter-group >.label {
- width: 100%;
-}
-
-/**
-Feed Grid
- */
-.feed-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
- gap: .5rem;
- margin-bottom: 2rem;
- padding: 0 4rem;
- --delay: 0s;
- --increase: .1s;
-}
-.feed-empty-state {
- grid-column: 1/-1;
- text-align: center;
- padding: 2rem;
- background: var(--base-100);
- border-radius: var(--innerRadius);
- margin: 0 auto;
- max-width: 600px;
-}
-/**
-Placeholders
- */
+/** PLACEHOLDERS **/
.placeholder {
aspect-ratio: 1;
background: var(--base);
@@ -172,41 +694,74 @@
justify-content: center;
align-items: center;
- .icon {
+ i.icon {
--w: 50%;
color: var(--base-200);
-
- svg {
- animation: dance 2.5s ease-in-out infinite;
- }
+ animation: dance 2.5s ease-in-out infinite;
}
}
-/**
-Feed Items
- */
-.feed-item {
+
+/** FEED ITEM **/
+.feed.item {
position: relative;
border-radius: 0.5rem;
overflow: hidden;
background: var(--base-50);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- opacity: 0;
- transition: opacity var(--transition-base) var(--delay);
height: fit-content;
padding: 0;
img {
opacity: .7;
filter: grayscale(.5) sepia(.3) blur(7px);
- }
- &[data-loaded=true] {
- img {
+ transition: opacity var(--transition-base), filter var(--transition-base);
+ &[data-loaded=true] {
opacity: 1;
filter: none;
}
}
+ &[data-timeline] {
+ aspect-ratio: unset;
+ summary {
+ aspect-ratio: 3/2;
+ padding: 0 0 1rem;
+ span {
+ width: 50%;
+ position: absolute;
+ background-color: var(--action-0);
+ color: var(--action-contrast);
+ padding: .25rem .5rem;
+ &:first-of-type {
+ bottom: 0;
+ right: 50%;
+ text-align: right;
+ }
+ &:last-of-type {
+ top: 0;
+ left: 50%;
+ }
+ }
+ > a {
+ position: relative;
+ display: flex;
+ flex-wrap: nowrap;
+ width: 100%;
+ height: 100%;
+ }
+ }
+ img {
+ width: 50%;
+ object-fit: cover;
+ height: 100%;
+ &:first-of-type {
+ border-right: 1px solid var(--action-0);
+ }
+ }
+ }
+
+
a {
&::before,
&::after {
@@ -217,13 +772,6 @@
font-size: clamp(1rem, 0.9306rem + 0.2222vw, 1.125rem);
}
- &[data-loaded] {
- opacity: 1;
- + [data-loaded] {
- --delay: var(--delay) + var(--increase);
- }
- }
-
&.highlighted {
box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1);
animation: highlight-puls 2s ease-in-out;
@@ -269,356 +817,14 @@
}
}
}
-/**
-Load More Button
- */
-.load-more {
- opacity: 1;
- margin: 1rem auto;
- width: 66.666%;
- display: flex;
- align-items: center;
- gap: .5rem;
- padding: .75rem 1.5rem;
- background: var(--base);
- color: var(--contrast);
- border-radius: 4px;
- font-size: var(--medium);
- transition: all var(--transition-base);
- border: 2px solid transparent;
- &[hidden] {
- opacity: 0;
- transition: all var(--transition-base);
- }
- &:hover {
- background: var(--pink-50);
- border-color: var(--contrast);
- color: var(--white);
+
+
+.item-grid:has([data-timeline]) {
+ grid-template-columns: repeat(1, 1fr);
+}
+
+@media (min-width:768px) {
+ .item-grid:has([data-timeline]) {
+ grid-template-columns: repeat(2, 1fr);
}
}
-/**
-favourite button
- */
-button.favourite {
- position: absolute;
- top: .5rem;
- right: .5rem;
- z-index: 10;
- background: var(--overlay-medium);
- border-radius: 50%;
- box-shadow: var(--subtle);
- border: none;
- width: 2rem;
- height: 2rem;
- display: flex;
- justify-content: center;
- align-items: center;
- backdrop-filter: blur(5px);
- transition: all var(--transition-base);
-
- &:hover {
- transform: scale(1.1);
- color: var(--pink-0);
- background: var(--base);
- box-shadow: 0 4px 8px rgba(0,0,0,0.15);
- }
-
- &.favourited {
- animation: pop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- }
-}
-
-
-/** Images **/
-.feed-image {
- display: block;
- aspect-ratio: 1;
- overflow: hidden;
- width: 100%;
- height: 100%;
-}
-.feed-images {
- width: 100%;
- height: 100%;
- &.multi {
- display :grid;
- grid-template-columns: repeat(3, 1fr);
- grid-auto-rows: 1fr;
- gap: 4px;
-
- > a {
- width: 100%;
- height: 100%;
- aspect-ratio: 1;
- }
- .feed-image {
- grid-row: span 2;
- grid-column: span 2;
- }
- }
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- transition: transform var(--timing) var(--function);
- }
- a:hover img {
- transform: scale(1.05);
- }
-}
-
-.feed-item {
- &:nth-of-type(4n+2) .multi .feed-image {
- grid-column: 2 / span 2;
- grid-row: 1 / span 2;
- }
- &:nth-of-type(4n+3) .multi .feed-image {
- grid-row: 2 / span 2;
- grid-column: 1 / span 2;
- }
- &:nth-of-type(4n+4) .multi .feed-image {
- grid-column: 2 / span 2;
- grid-row: 2 / span 2;
- }
-}
-
-/** Item Information **/
-.item-info {
- padding: .25rem;
- border-left: 1px solid var(--base-200);
- >div + div {
- margin-top: .5em;
- position: relative;
-
- &::before {
- content: '';
- display: block;
- position: absolute;
- top: -.3em;
- left: -.25rem;
- width: 66.6%;
- border-bottom: 1px solid var(--base-200);
- }
- }
- h3 {
- margin: 0 0 .5em 0!important;
- font-size: 1.1rem;
- font-family: var(--body);
- font-weight: var(--bWeight);
- }
- span {
- text-transform: uppercase;
- display: flex;
- align-items: center;
- }
- .icon {
- --w: 1.1em;
- margin-right: .5em;
- display: inline-block;
- vertical-align: middle;
- }
-}
-.item-list {
- ul {
- margin: 0;
- padding: .5em 0;
- display: flex;
- flex-wrap: wrap;
- gap: .5rem;
- li {
- list-style: none;
- }
- }
- a {
- background-color: var(--pink-0);
- border: 1px solid transparent;
- border-radius: 4px;
- color: var(--light-0);
- padding: .25em;
- line-height: .8;
- &:visited {
- background-color: var(--pink-100);
- color: var(--white);
- }
- &:visited:hover,
- &:visited:focus,
- &:hover,
- &:focus {
- background-color: transparent;
- border-color: var(--contrast);
- color: var(--contrast);
- }
- }
-}
-/**
-Loading
- */
-.loading {
- opacity: .7;
-}
-
-.loading-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: var(--overlay-medium);
- display: flex;
- align-items: center;
- justify-content: center;
- transition: opacity 0.3s ease, visibility 0.3s ease;
- z-index: 9999;
- opacity: 0;
- visibility: hidden;
-}
-.loading .loading-overlay {
- opacity: 1;
- visibility: visible;
-
- &::after {
- content: '';
- position: absolute;
- z-index: -1;
- inset: 0;
- background: linear-gradient(
- 90deg,
- var(--shimmer)
- );
- animation: shimmer 3s ease-in-out infinite;
- }
-
- .wrapper {
- background-color: var(--overlay-heavy);
- padding: 2rem;
- border-radius: var(--innerRadius);
- text-align: center;
- max-width: 90%;
- width: 400px;
- height: 300px;
- z-index: 5;
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
-
- .spinner {
- --h: 150px;
- --w: calc(var(--h) * 2);
- border-top: 5px solid var(--pink-0);
- border-radius: 50%;
- position: absolute;
- width: var(--w);
- height: var(--w);
- top: calc(50% - var(--h));
- left: calc(50% - var(--h));
- opacity: .5;
- z-index: 0;
- animation: spin 1s var(--timing) infinite;
- }
- div.icon {
- height: 50px;
- width: 50px;
-
- .icon {
- --w: 100%;
- svg {
- animation: dance 2s ease-in-out infinite;
- transition: color 0.3s ease;
- }
- }
- }
- .status {
- height: 200px;
- width: 100%;
- z-index: 5;
- display: flex;
- flex-direction: column;
- align-items: center;
-
- h3 {
- margin: 1.5rem 0 .25rem!important;
- color: var(--contrast-200);
- }
- .message {
- margin: 0;
- max-width: 275px;
- color: var(--contrast-100);
- font-size: var(--small);
- animation: flicker 2s infinite;
- }
- }
- }
-}
-
-
-/* Animations */
-@keyframes highlight {
- 0%, 100% {
- box-shadow: none;
- }
- 50% {
- box-shadow: 0 0 0 4px var(--pink-0);
- }
-}
-
-@keyframes pop {
- 0% { transform: scale(1); }
- 50% { transform: scale(1.3); }
- 75% { transform: scale(0.9); }
- 100% { transform: scale(1); }
-}
-
-@keyframes bubble {
- 50% { box-shadow: 19px 0 0 3px, 38px 0 0 7px, 57px 0 0 3px }
- 100% { box-shadow: 19px 0 0 0, 38px 0 0 3px, 57px 0 0 7px }
-}
-@keyframes highlight-pulse {
- 0%, 100% { box-shadow: 0 0 0 4px #FF0080, 0 8px 16px rgba(0, 0, 0, 0.1); }
- 50% { box-shadow: 0 0 0 8px #FF0080, 0 12px 24px rgba(0, 0, 0, 0.15); }
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-@keyframes shimmer {
- 0% { transform: translateX(-100%); }
- 50%, 100% { transform: translateX(100%); }
-}
-@keyframes dance {
- 0%, 100% { transform: rotate(-5deg) scale(1);}
- 50% { transform: rotate(5deg) scale(1.1); }
-}
-@keyframes flicker {
- 0% { opacity: 0.6; }
- 50% { opacity: 1; }
- 100% { opacity: 0.6; }
-}
-
-
-/**
-Accessibility
- */
-
-/* Keyboard navigable feed items */
-.feed-item[tabindex="0"] {
- position: relative;
-}
-
-.feed-item[tabindex="0"]::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- pointer-events: none;
- border: 2px solid transparent;
- transition: border-color 0.2s ease;
-}
-
-.feed-item[tabindex="0"]:focus::after {
- border-color: #FF0080;
-}
-
diff --git a/src/feed/view.js b/src/feed/view.js
index 29859f9..abb91e5 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -1,745 +1,659 @@
class FeedBlock {
constructor() {
- this.cache = window.jvbCache;
- this.a11y = window.jvbA11y;
- this.loading = window.jvbLoading;
- this.error = window.jvbError;
-
-
this.container = document.querySelector('section.feed-block');
if (!this.container) {
return;
}
- this.openGallery = false;
+ this.a11y = window.jvbA11y;
+ this.cache = new window.jvbCache('feed');
+ this.error = window.jvbError;
- this.initElements();
- this.addPlaceholders();
this.config = {
- api: feedSettings.apiUrl,
- nonce: feedSettings.nonce,
- user: jvbSettings.currentUser || null,
source: '',
context: '',
highlight: null,
gallery: false,
- showAuthor: true,
- showDate: false,
- view: localStorage.getItem('feedViewMode') || 'grid',
+ view: this.cache.get('feedView') || 'grid',
... this.container.dataset
};
- this.taxonomies = {};
- this.rendered = {};
+ this.initElements();
+ this.initFilters();
- this.feed = {
- imageLoadThreshold: 5,
- lazyLoadOffset: '100px',
- gallery: [],
- loaded: 0,
- intsersectionObserver: null,
- templates: new Map()
- };
- this.isLoading = false;
- this.hasMore = true;
- this.retries = {
- count: 0,
- max: 3,
- delay: 1000
- };
- this.page = 1;
- this.order = 'DESC';
- this.orderby = 'date';
-
- this.gallery = (this.config.gallery) ? new window.jvbGallery(document.querySelector('dialog.gallery'), {
- imageWrapper: '.item',
- loadMore: ()=>this.fetchFeed.bind(this)
- }) : false;
- this.initListeners();
- if (this.page === 1) {
- this.processURLFilters();
- } else {
- this.updateFilters();
- }
-
+ this.loadWhenAble();
}
+
+ loadWhenAble() {
+ if ('requestIdleCallback' in window) {
+ requestIdleCallback(() => {
+ this.initTaxonomies();
+ this.initStore();
+ this.initListeners();
+ this.initGallery();
+ }, { timeout: 2000 });
+ } else {
+ setTimeout(() => {
+ this.initTaxonomies();
+ this.initStore();
+ this.initListeners();
+ this.initGallery();
+ }, 100);
+ }
+ }
+
initElements() {
- this.filterSelector = 'form.feed-filters';
- this.filterForm = this.container.querySelector(this.filterSelector);
- this.grid = this.container.querySelector('.item-grid');
- this.loadMore = this.container.querySelector('.load-more');
- this.filterControls = this.container.querySelector('.filter-actions');
- this.contentTypes = Array.from(this.filterForm.querySelectorAll('input[name="content"]')).map(
- content => {
- return content.value;
- });
- this.selectedTerms = this.container.querySelector('.selected-items-section .selected-items');
+ this.currentTaxonomies = new Set(); // Allowed Taxonomies, grabbed from active buttons
+ this.taxonomyFilters = {};
+ this.elements = {
+ filterTrigger: '[data-filter]',
+ filters: {
+ content: '[data-filter="content"]',
+ orderby: '[data-filter="orderby"]',
+ order: '[data-filter="order"]',
+ match: '[data-filter="match"]',
+ favourites: '[data-filter="favourites"]',
+ taxonomy: '[data-filter^="taxonomy"]'
+ },
+ selectedTax: '.selected-terms',
+ 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]');
+ if (this.ui.content.length > 0) {
+ this.contentTypes = Array.from(
+ this.ui.content
+ ).map(content => content.value);
+ } else {
+ this.contentTypes = [this.container.dataset['content']];
+ }
+ if (this.ui.taxonomies.length>0) {
+ this.taxonomies = Array.from(
+ this.ui.taxonomies,
+ ).map(content => content.dataset.taxonomy);
+ } else {
+ this.taxonomies = [];
+ }
}
- initListeners() {
- window.addEventListener('popstate', this.handlePopState.bind(this));
- document.addEventListener('click', this.handleClick.bind(this));
- document.addEventListener('change', this.handleChange.bind(this));
+ async initTaxonomies() {
+ this.selector = window.jvbSelector;
+ const buttons = document.querySelectorAll('[data-filter="taxonomy"]');
-
- // Intersection observer for lazy loading
- if ('IntersectionObserver' in window) {
- this.imageObserver = new IntersectionObserver(entries => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- this.loadImage(entry.target);
- this.imageObserver.unobserve(entry.target);
- }
- });
- }, {
- rootMargin: '100px',
- threshold: 0.1
+ this.selector.isInitializing = true;
+ buttons.forEach((button) => {
+ const taxonomy = button.dataset.taxonomy;
+ this.currentTaxonomies.add(taxonomy);
+ this.selector.registerFilterButton(button, {
+ button: button,
+ buttonSelector: '[data-filter="taxonomy"]',
+ selected: this.ui.selectedTax
});
- }
- // Resize observer for responsive images
- if ('ResizeObserver' in window) {
- this.resizeObserver = new ResizeObserver(window.debounce(() => {
- this.updateImageSizes();
- }, 250));
+ });
- // Observe the container
- this.resizeObserver.observe(this.container);
+ // Make this non-blocking
+ setTimeout(() => {
+ this.selector.batchFetchTaxonomies();
+ this.selector.isInitializing = false;
+ }, 0);
+
+ this.selector.subscribe((event, data) => {
+ if (event === 'selected-terms') this.handleTaxonomyChange(data);
+ });
+ }
+
+ handleTaxonomyChange(data) {
+ const { terms, taxonomy } = data;
+
+ // Update only the current taxonomy's terms
+ if (terms.size > 0) {
+ this.taxonomyFilters[taxonomy] = Array.from(terms.keys());
} else {
- // Fallback to window resize
- window.addEventListener('resize', window.debounce(() => {
- this.updateImageSizes();
- }, 250));
+ // Remove taxonomy if no terms selected
+ delete this.taxonomyFilters[taxonomy];
}
- this.taxonomies = {};
- this.container.querySelectorAll('.jvb-selector:not([hidden])').forEach(selector => {
- let taxonomy = selector.dataset.taxonomy;
- if (!Object.hasOwn(this.taxonomies, taxonomy)) {
- this.taxonomies[taxonomy] = new window.jvbTaxonomySelector(
- selector,
- {
- multiple: true,
- feed: true,
- selected: {},
- onClose: () => this.setSelectedTerms(taxonomy),
+ // Build filters object with all taxonomies
+ let filters = {
+ page: 1
+ };
+
+ // Add taxonomy filters if any exist
+ if (Object.keys(this.taxonomyFilters).length > 0) {
+ filters.taxonomy = this.taxonomyFilters;
+ }
+
+ this.updateFilter(filters);
+ }
+
+ clearAllTaxonomies() {
+ this.taxonomyFilters = {};
+ window.removeChildren(this.ui.selectedTax);
+
+ this.updateFilter({
+ taxonomy: null,
+ page: 1
+ });
+ }
+
+ initFilters() {
+ //defaults
+ this.filters = {
+ content: this.contentTypes[0],
+ orderby: 'date',
+ order: 'desc',
+ page: 1
+ };
+ if (this.config.context) this.filters.context = this.config.context;
+ if (this.config.source) this.filters.source = this.config.source;
+
+ //check the cache
+ this.processCachedFilters();
+ //check url
+ this.processURLFilters();
+
+ // Set initial UI state
+ this.syncUIToFilters();
+ }
+ syncUIToFilters() {
+ // Check radio buttons
+ Object.entries(this.filters).forEach(([key, value]) => {
+ const input = this.ui.filterContainer.querySelector(`[data-filter="${key}"][value="${value}"]`);
+ if (input) {
+ input.checked = true;
+ }
+ });
+
+ // Update content-specific visibility
+ this.updateContentFor(this.filters.content);
+ }
+ nextPage() {
+ this.store.setFilter('page', this.store.filters.page++);
+ }
+
+ initStore() {
+ this.addPlaceholders();
+ this.store = window.jvbStore.register(
+ 'feed',
+ {
+ endpoint: 'feed',
+ keyPath: 'id',
+ indexes: [
+ { name: 'content', keyPath: 'content'},
+ { name: 'taxonomy', keyPath: 'taxonomy'},
+ { name: 'user', keyPath: 'user'},
+ { name: 'date', keyPath: 'modified'},
+ { name: 'title', keyPath: 'title'}
+ ],
+ filters: this.filters,
+ TTL: 6 * 60 * 60 * 1000,
+ showLoading: true,
+ required: 'content',
+ delayFetch: true
+ }
+ );
+
+ this.store.subscribe((event, data) => {
+ switch (event) {
+ case 'data-loaded':
+ this.renderItems();
+ this.ui.loadMore.hidden = true;
+ if (this.store.lastResponse && this.store.lastResponse['has_more']) {
+ this.ui.loadMore.hidden = !this.store.lastResponse['has_more'];
}
- );
+ break;
}
});
}
-
- /**
- * Handle browser history navigation
- */
- handlePopState(e) {
- if (e.state && e.state.filters) {
- if(this.processURLFilters()){
- // Load items with updated filters
- this.resetPage();
- this.fetchFeed();
-
- // Announce to screen readers
- this.a11y.announce('Feed filters updated from browser history.');
- }
+ initGallery() {
+ this.gallery = (this.config.gallery) ? window.jvbGallery : false;
+ if (this.gallery) {
+ this.gallery.subscribe((event, data) => {
+ if (event === 'load-more' && this.store.lastResponse) {
+ if (this.store.lastResponse['has_more']) {
+ this.nextPage();
+ }
+ }
+ });
}
}
+ processCachedFilters() {
+ Object.keys(this.filters).forEach(filter => {
+ let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`);
+ if (cached && cached !== this.filters[filter]){
+ this.filters[filter] = cached;
+ }
+ });
+ }
+
processURLFilters() {
- const params = new URLSearchParams(window.location.search);
- //No parameters to process
- if (!params.toString()) {
- this.updateFilters();
- return;
+ if (this.filters.page > 1) {
+ return false;
}
+ const params = new URLSearchParams(window.location.search);
- let filters = ['content', 'order', 'orderby', 'favourites','match'];
-
+ if (!params.toString()) {
+ return false;
+ }
+ let filters = ['content', 'order', 'orderby', 'favourites', 'match'];
filters.forEach(filter => {
- let value = params.get('f_'+filter);
- params.delete('f_'+filter);
- if (value && this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`)) {
- this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`).checked = true;
+ let value = params.get(`f_${filter}`);
+ if (value) {
+ this.filters[filter] = value;
+ let input = this.ui.filters[filter];
+ if (input) {
+ input.checked = true;
+ }
}
});
- let unprocessed = {};
- for (var [key, value] of Object.entries(Object.fromEntries(params))) {
-
- key = key.replace('f_','');
- if (this.contentTypes.includes(key)) {
- this.openGallery = value;
- } else {
- this.taxonomies[key].addTermsFromURL(value);
- this.setSelectedTerms(key);
+ let hasTaxonomy = false;
+ // Load taxonomy filters from URL
+ params.forEach((value, key) => {
+ if (key.startsWith('f_tax_')) {
+ hasTaxonomy = true;
+ const taxonomy = key.replace('f_tax_', '');
+ if (!this.taxonomyFilters[taxonomy]) {
+ this.taxonomyFilters[taxonomy] = [];
+ }
+ this.taxonomyFilters[taxonomy] = value.split(',').map(Number);
}
- }
-
- this.updateFilters();
- }
-
- handleClick(e) {
- if (e.target.classList.contains('load-more') || e.target.closest('.load-more')) {
- this.fetchFeed(false);
- e.target.disabled = true;
- } else if (e.target.classList.contains('clear-filters') || e.target.closest('.clear-filters')) {
- this.resetFilters();
- } else if (this.config.gallery && e.target.closest('.feed-image')) {
- this.gallery.handleGalleryOpen(e);
- } else if (e.target.classList.contains('.remove-item') || e.target.closest('.remove-item')) {
- let tag = e.target.closest('.selected-item');
- let taxonomy = tag.dataset.taxonomy;
- this.taxonomies[taxonomy].removeSelectedTerm(tag.dataset.id);
- this.setSelectedTerms(taxonomy);
- this.updateFilters();
- }
- }
- handleChange(e) {
- if (e.target.closest(this.filterSelector)) {
- this.resetPage();
- window.removeChildren(this.grid);
- this.addPlaceholders();
- //update filters
- this.updateFilters();
- }
- }
-
- updateFilters() {
- this.page = 1;
- const params = new URLSearchParams(window.location.search);
-
- let filters = Object.fromEntries(new FormData(this.filterForm));
-
- let contents = [];
- for (let [key, value] of Object.entries(filters)) {
- let set = false;
- switch (key) {
- case 'content':
- if (value !== this.contentTypes[0]) {
- set = true;
+ });
+ if (hasTaxonomy) {
+ for (let [tax, ids] in Object.entries(this.taxonomyFilters)) {
+ let button = this.ui.filterContainer.querySelector(`[data-taxonomy="${tax}"]`);
+ if (button) {
+ if (button.dataset.fieldId) {
+ let field = this.selector.get(button.dataset.fieldId);
+ field.selectedTerms = new Set(ids);
+ this.selector.initFieldDisplay(button.dataset.fieldId);
} else {
- params.delete('f_'+key);
+ this.selector.registerField(button, {
+ button: button,
+ buttonSelector: '[data-filter="taxonomy"]',
+ selected: this.ui.selectedTax,
+ selectedItems: ids
+ });
}
- break;
- case 'orderby':
- if (value !== 'date') {
- set = true;
- }
- break;
- case 'order':
- if (value !== 'desc') {
- set = true;
- }
- break;
- default:
- set = true;
+ }
}
- if (!set) {
- params.delete('f_'+key);
- }
-
-
- if (set && value !== false && value !== '') {
- params.set('f_'+key, value);
- }
- if (value !== '') {
- contents.push(value);
- }
-
- const newURL = `${window.location.pathname}?${params.toString()}`;
- history.pushState(filters, '', newURL);
-
}
-
- this.filters = filters;
- this.updateContentFor(filters.content);
-
- this.updateFilterControls();
-
- this.loading.setContent(contents);
- this.fetchFeed(true);
- }
-
- updateFilterControls() {
- this.filterControls.hidden = this.selectedTerms.children.length < 2;
+ return true;
}
/**
- * Toggles taxonomy selectors and certain order/orderby options
- * depending on current content
- * @param content
+ * Update URL with current filters (for sharing/bookmarking)
*/
- updateContentFor(content) {
- this.filterForm.querySelectorAll('.jvb-selector').forEach(tax => {
- let hasContent = tax.dataset.for.includes(content);
- tax.hidden = !hasContent;
- if (!hasContent) {
- let t = tax.dataset.taxonomy;
- this.clearSelectedTerms(t);
+ updateURL() {
+ const params = new URLSearchParams();
+
+ // Add simple filters
+ ['content', 'order', 'orderby', 'match'].forEach(key => {
+ if (this.filters[key]) {
+ params.set(`f_${key}`, this.filters[key]);
}
});
- this.filterForm.querySelectorAll('input[data-for]').forEach(toggle => {
- toggle.hidden = !toggle.dataset.for.includes(content);
+
+ // Add taxonomy filters
+ Object.entries(this.taxonomyFilters).forEach(([taxonomy, terms]) => {
+ if (terms.length > 0) {
+ params.set(`f_tax_${taxonomy}`, terms.join(','));
+ }
});
- this.filterForm.querySelectorAll('input[name="order"]').forEach(order => {
- order.hidden = this.filters.order === 'random';
- });
+
+ // Update URL without reload
+ const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
+ window.history.pushState({ filters: this.filters }, '', newURL);
}
- clearSelectedTerms(taxonomy) {
- this.filterForm.querySelector(`input[name="${taxonomy}"]`).value = '';
- if (Object.hasOwn(this.taxonomies, taxonomy)) {
- this.taxonomies[taxonomy].selectedItems = {};
- }
- }
-
- setSelectedTerms(taxonomy) {
- let input = this.filterForm.querySelector(`input[name="${taxonomy}"]`);
- input.value = '';
- let selected = this.taxonomies[taxonomy].selectedTerms;
- if (!window.isEmptyObject(selected)) {
- let ids = Object.keys(selected);
- input.value = ids.join(',');
- }
- this.updateFilters();
- }
-
-
- nextPage() {
- if (this.hasMore) {
- this.page++;
- }
- }
- resetPage() {
- this.page = 1;
- this.hasMore = true;
- }
- resetState() {
- this.resetPage(true);
- this.isLoading = false;
- this.retries = {
- count: 0,
- max: 3,
- delay: 1000
- };
- }
-
- resetFilters() {
- this.filterForm.reset();
- //check the first content
- this.filterForm.querySelector('input[name="content"]').checked = true;
- this.filterForm.querySelector('input[name="orderby"][value="date"]').checked = true;
- this.page = 1;
- this.updateFilters();
- }
-
-
- buildFilterRequest() {
-
- let filters = {};
-
- for (let [filter, value] of Object.entries(this.filters)) {
- if (value !== false && value !== '') {
- filters[filter] = value;
- }
- }
- filters.page = parseInt(this.page);
- if (this.container.dataset.context) {
- filters.context = this.container.dataset.context;
- }
- if (this.container.dataset.source) {
- filters.source = this.container.dataset.source;
- }
- return new URLSearchParams(filters).toString();
- }
-
- async fetchFeed(reset = false, force = false) {
- if (this.isLoading) {
- return false;
- }
- this.loading.showLoading(this.filters);
- try {
- if (this.page === 1) {
- window.removeChildren(this.grid);
- this.addPlaceholders();
- }
-
- const data = await this.cache.fetchWithCache(
- `${this.config.api}feed?${this.buildFilterRequest()}`,
- {
- method: 'GET',
- },
- {
- context: 'feed',
- forceRefresh: true
- // forceRefresh: force
- }
- );
-
- //Handle empty results
- if (!data || !data.items || data.items.length === 0) {
- if (this.page === 1) {
- this.showEmptyState();
- }
- this.hasMore = false;
- return false;
- } else {
- this.hasMore = data['has_more'];
-
- this.renderItems(data.items, this.page > 1);
-
- if (this.hasMore) {
- this.nextPage();
- }
- return true;
- }
- } catch (error) {
- this.handleError(error);
- } finally {
- this.loading.hideLoading();
- if (this.openGallery !== false) {
- this.gallery.openWhenReady = this.openGallery;
- this.openGallery = false;
- }
- this.loadMore.disabled = false;
- this.loadMore.hidden = !this.hasMore;
- }
- }
-
- removePlaceholders() {
- if (this.grid.querySelector('.placeholder')) {
- window.removeChildren(this.grid);
- }
- }
- showEmptyState() {
- window.removeChildren(this.grid);
- let template = window.getTemplate('emptyState');
- let isFavourite = Object.hasOwn(this.filters, 'favourites') && this.filters.favourites === true;
- if (isFavourite) {
- [
- template.querySelector('h3').textContent,
- template.querySelector('p:first-of-type').textContent,
- template.querySelector('p:last-of-type').textContent,
- ] = [
- '♡ BLANK CANVAS ♡',
- 'You haven\'t fallen in love with any pieces... yet!',
- 'Hit that heart icon when something stops your scroll — your dream collection is waiting to start.'
- ];
- }
- this.grid.append(template);
- this.a11y.announceEmpty(isFavourite);
-
- }
- handleError(error){
- return this.error.handleApiError(
- error,
- {
- component: 'Feed Block',
- action: 'loaditems'
- },
- () => this.fetchFeed()
- );
- }
-
- addPlaceholders() {
- let total = this.contentTypes.length - 1;
- for (let i = 0; i < 9; i++) {
- let template = window.getTemplate('placeholderTemplate');
- let rand = Math.floor(Math.random()*total+1);
- let icon = window.getIcon(this.contentTypes[rand]).cloneNode(true);
-
- template.append(icon);
- this.grid.append(template);
- }
- }
- renderItems(items, append = false) {
- //Clear the grid if we aren't appending
- if (!append) {
- window.removeChildren(this.grid);
+ renderItems() {
+ let items = this.store.getFiltered();
+ if (this.store.filters['page'] === 1) {
+ window.removeChildren(this.ui.grid);
this.addPlaceholders();
}
-
- //Bail early if no items
if (items.length === 0) {
- this.a11y.announceUpdate(0, append);
+ this.a11y.announceItems(0, this.store.filters['page'] > 0);
return;
}
- //Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();
-
const batchSize = 10;
+
const processBatch = (startIndex) => {
const endIndex = Math.min(startIndex + batchSize, items.length);
for (let i = startIndex; i < endIndex; i++) {
const item = items[i];
const element = this.createItemElement(item);
- fragment.appendChild(element);
- this.imageObserver.observe(element);
+ fragment.appendChild(element);
}
if (endIndex < items.length) {
- requestAnimationFrame(() => {
- processBatch(endIndex);
- });
+ requestAnimationFrame(() => processBatch(endIndex));
} else {
this.removePlaceholders();
- //all batches are processed, append fragment
- this.grid.appendChild(fragment);
+ this.ui.grid.append(fragment);
+
+ // Observe images after adding to DOM
+ this.observeImages(this.ui.grid);
+
if (this.config.gallery) {
this.gallery.updateGalleryItems(this.gallery.getGalleryItems());
}
- this.a11y.makeNavigable(this.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
- this.a11y.announceItems(items.length, append, this.hasMore);
+
+ this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
+ this.a11y.announceItems(items.length, this.store.filters['page'] > 1, this.store.hasMore);
}
};
if (items.length > 0) {
processBatch(0);
} else {
- this.a11y.announceUpdate(0, append);
+ this.a11y.announceItems(0, this.store.filters['page'] >1, false);
}
+
+ this.ui.filters.match.hidden = window.isEmptyObject(this.taxonomyFilters);
+ this.ui.clearFilter.hidden = window.isEmptyObject(this.taxonomyFilters);
}
/**
- * Creates a feed-item. Used by RenderItems
+ *
+ * @param {object} item
*/
createItemElement(item) {
- if(!this.rendered[item.icon]) {
- this.rendered[item.icon] = new Map();
+ let template = window.getTemplate('feed-item');
+ if (Object.hasOwn(template.dataset, 'timeline')) {
+ return this.createTimelineElement(item, template);
}
- if (this.rendered[item.icon].has(item.id)) {
- return this.rendered[item.icon].get(item.id);
- }
-
- const favourited = window.isFavourited(item.icon, item.id)??false;
- const template = window.getTemplate('feed-item');
-
- template.id = `${item.icon}-${item.id}`;
- template.dataset.id = item.id;
- template.classList.add(item.icon);
-
- if (item['umami_view']) {
- this.buildUmamiData(template, item['umami_view']);
- }
-
- let favouriteButton = template.querySelector('button.favourite');
- [
- favouriteButton.dataset.id,
- favouriteButton.dataset.type,
- favouriteButton.dataset.artist,
- favouriteButton.title
- ] = [
- item.id,
- item.icon,
- item['user_id'],
- (favourited) ? 'Remove from Favourites' : 'Add to Favourites'
- ];
-
- let order = item.order;
- let single = template.querySelector('.item');
- let list = template.querySelector('.item-list');
- let img = template.querySelector('.feed-images');
- let summary = template.querySelector('summary');
- let info = template.querySelector('.item-info');
-
- for (let [index, id] of Object.entries(order)) {
- let target;
- let config = item[id];
- if (id === 'title') {
- target = template.querySelector('h3 a');
- if (item.title !== '') {
- [
- target.textContent,
- target.href,
- target.url
- ] = [
- item.title,
- item.url,
- `Learn more about this ${item.icon}`
- ];
- if (item.icon !== '') {
- target.closest('h3').prepend(window.getIcon(item.icon));
- }
- if (item.umami_click) {
- this.buildUmamiData(target, item.umami_click);
- }
- } else {
- target.remove();
- }
- } else if (Object.hasOwn(config, 'terms')) {
- //Taxonomy list
- if (config.terms.length === 0) {
- continue;
- }
- let taxonomy = list.cloneNode(true);
- let label = taxonomy.querySelector('.label');
- let termList = taxonomy.querySelector('ul');
- let listItem = taxonomy.querySelector('li');
-
- if (config.label) {
- label.textContent = config.label;
- }
- if (config.icon) {
- label.prepend(window.getIcon(config.icon));
- }
- if (!config.label && !config.icon){
- label.remove();
- }
-
- config.terms.forEach(term => {
- let termItem = listItem.cloneNode(true);
- let link = termItem.querySelector('a');
- [
- link.href,
- link.title,
- link.textContent
- ] = [
- term.url,
- `Learn more about ${term.title}`,
- term.title
- ];
- if (term.umami_click.length > 0) {
- this.buildUmamiData(link, term.umami_click);
- }
- termList.append(termItem);
- });
-
- listItem.remove();
- info.appendChild(taxonomy);
- } else if (Object.hasOwn(config, 'value') && config.value !== '') {
- let itemInfo = single.cloneNode(true);
- let label = itemInfo.querySelector('.label');
- let link = itemInfo.querySelector('a');
- let p = itemInfo.querySelector('p');
- if (Object.hasOwn(config, 'label')) {
- label.textContent = config.label;
- }
- if (Object.hasOwn(config, 'icon')) {
- label.prepend(window.getIcon(config.icon));
- }
- if (!Object.hasOwn(config, 'icon') && !Object.hasOwn(config, 'label')) {
- label.remove();
- }
- if (Object.hasOwn(config, 'url')) {
- p.remove();
- [
- link.textContent,
- link.href,
- link.title
- ] = [
- config.value,
- config.url,
- `Learn more about ${config.value}`
- ];
- } else {
- link.remove();
- p.textContent = config.value;
- }
- info.appendChild(itemInfo);
- } else if (id === 'image') {
- let images = summary.querySelector('.feed-images');
- let img = images.querySelector('a');
-
- let main = img.cloneNode(true);
- if (!this.config.gallery) {
- main.href = item.url;
- }
-
- main.classList.add('feed-image');
- this.buildImageData(main.querySelector('img'), item.image);
- images.append(main);
-
- if (item.content?.length > 0) {
- images.classList.add('multi');
- item.content.forEach(c => {
- let image = img.cloneNode(true);
- if (!this.config.gallery) {
- image.href = c.url;
- }
- let itemImg = image.querySelector('img');
- itemImg.src = c.image.small;
- itemImg.alt = c.image.alt;
- images.append(image);
- });
- }
- img.remove();
- }
- }
- single.remove();
- list.remove();
-
- this.rendered[item.icon].set(item.id, template);
-
return template;
}
- buildImageData(img, data){
- if (typeof data.tiny !== 'string') {
- return;
- }
+ createTimelineElement(item, template) {
+ let [
+ main,
+ link,
+ beforeImg,
+ afterImg,
+ afterText,
+ started,
+ lastTreated,
+ total,
+ termList,
+ timeline
+ ] = [
+ template,
+ template.querySelector('a'),
+ template.querySelector('img.before'),
+ template.querySelector('img.after'),
+ template.querySelector('summary span:last-of-type'),
+ template.querySelector('p.started time'),
+ template.querySelector('p.updated time'),
+ template.querySelector('p.total b'),
+ template.querySelector('.term-list'),
+ Object.values(item.fields.order)
+ ];
+ let numberTreatments = timeline.length - 1;
+ let beforeImgData = item.images[timeline[0]['post_thumbnail']];
+ let afterImgData = item.images[timeline[numberTreatments]['post_thumbnail']];
+
[
- img.src,
- img.dataset.small,
- img.dataset.medium,
- img.dataset.large,
- img.alt
- ] =
- [
- data.tiny,
- data.small,
- data.medium,
- data.large,
- data.alt
- ];
+ main.dataset.id,
+ link.href,
+ beforeImg.src,
+ beforeImg.dataset.small,
+ beforeImg.dataset.medium,
+ afterImg.src,
+ afterImg.dataset.small,
+ afterImg.dataset.medium,
+ afterText.textContent,
+ started.textContent,
+ lastTreated.textContent,
+ total.textContent
+ ] = [
+ item.id,
+ item.url,
+ beforeImgData['tiny'],
+ beforeImgData.small,
+ beforeImgData.medium,
+ afterImgData['tiny'],
+ afterImgData.small,
+ afterImgData.medium,
+ `${afterText.textContent} ${numberTreatments} Tx`,
+ timeline[0].date??item.date,
+ timeline[numberTreatments].date??'',
+ `${numberTreatments} Treatments`
+ ];
+ return template;
}
- buildUmamiData(item, data){
- for(let [key, value] of Object.entries(data)){
- item.dataset[key] = value;
+ addPlaceholders() {
+ let total = this.contentTypes.length;
+ for (let i = 0; i < 12; i++) {
+ let template = window.getTemplate('placeholderTemplate');
+
+ let rand = Math.floor(Math.random() * total);
+ let icon;
+ if (this.ui.content.length > 0) {
+ icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true);
+ } else {
+ icon = window.getIcon(this.container.dataset.icon);
+ }
+ template.appendChild(icon);
+ this.ui.grid.appendChild(template);
}
}
- /**
- * Load Image, used by renderItems
- * @param element
- */
- loadImage(element) {
- const img = element.querySelector('img');
- if (!img) return;
- const size = this.getImageSize();
+ removePlaceholders() {
+ if (this.ui.grid.querySelector('.placeholder')) {
+ window.removeChildren(this.ui.grid);
+ }
+ }
- img.src = img.dataset[size] || img.dataset.src;
- element.setAttribute('data-loaded', 'true');
+
+
+ /**
+ *
+ * @param {object} filters {name: value}
+ */
+ updateFilter(filters) {
+ //double check filters are what we're expecting
+ let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)];
+
+ filters = Object.keys(filters)
+ .filter(key => allowed.includes(key))
+ .reduce((obj, key) => {
+ obj[key] = filters[key];
+ return obj;
+ }, {});
+
+ if (window.getDifferences.map(this.filters, filters)) {
+ this.filters = { ...this.filters, ...filters }; // Merge instead of replace
+ this.updateURL();
+ this.store.setFilters(filters);
+ }
+ }
+ /**
+ * Update visible filters based on selected content type
+ */
+ updateContentFor(contentType) {
+ // Update taxonomy filter visibility
+ const taxonomyButtons = this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]');
+ taxonomyButtons.forEach(button => {
+ const forTypes = button.dataset.for?.split(',') || [];
+ button.hidden = forTypes.length > 0 && !forTypes.includes(contentType);
+ });
+
+ // Update ordering options
+ const orderButtons = this.ui.filterContainer.querySelectorAll('[data-for]');
+ orderButtons.forEach(button => {
+ const forTypes = button.dataset.for?.split(',') || [];
+ if (forTypes.length > 0) {
+ button.hidden = !forTypes.includes(contentType);
+ // Uncheck if hiding
+ if (button.hidden && button.checked) {
+ button.checked = false;
+ }
+ }
+ });
+
+ // Update order direction visibility based on selected orderby
+ const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked');
+ this.updateOrderDirectionVisibility(orderBy?.value);
}
/**
- * Updates the image size according to screen size
+ * Show/hide order direction based on orderby selection
*/
- updateImageSizes() {
- const size = this.getImageSize();
- const items = this.grid.querySelectorAll('.item');
- items.forEach(item => {
- const img = item.querySelector('img');
- if (img && img.dataset[size] && img.src !== img.dataset[size]) {
- img.src = img.dataset[size];
+ updateOrderDirectionVisibility(orderBy) {
+ const orderDirection = this.ui.filterContainer.querySelector('.order-direction');
+ if (orderDirection) {
+ const forOrders = orderDirection.dataset.forOrder?.split(',') || [];
+ orderDirection.hidden = forOrders.length > 0 && !forOrders.includes(orderBy);
+ }
+ }
+ /*********************************************************************
+ LISTENERS
+ *********************************************************************/
+ initListeners() {
+ this.popStateHandler = this.handlePopState.bind(this);
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+ this.imageObserver = null;
+ this.resizeObserver = null;
+ if ('IntersectionObserver' in window) {
+ this.imageObserver = new IntersectionObserver(entries => {
+ entries.forEach(entry => {
+ this.loadImage(entry.target);
+ this.imageObserver.unobserve(entry.target);
+ });
+ }, {
+ rootMargin: '100px',
+ threshold: .1
+ });
+ }
+
+ if ('ResizeObserver' in window) {
+ this.resizeObserver = new ResizeObserver(window.debounce(() => {
+ this.updateImageSizes();
+ }, 250));
+ } else {
+ window.addEventListener('resize', window.debounce(()=> {
+ this.updateImageSizes();
+ }, 250));
+ }
+
+ window.addEventListener('popstate', this.popStateHandler);
+ document.addEventListener('click', this.clickHandler);
+ document.addEventListener('change', this.changeHandler);
+ }
+
+ loadImage(img) {
+ const src = this.getAppropriateImageSize(img);
+ if (src && src !== img.src) {
+ img.src = src;
+ img.dataset.loaded = 'true';
+ }
+ }
+
+ getAppropriateImageSize(img) {
+ const width = window.innerWidth;
+
+ if (width < 768 && img.dataset.small) {
+ return img.dataset.small;
+ } else if (img.dataset.medium) {
+ return img.dataset.medium;
+ }
+
+ return img.src; // Fallback to current src
+ }
+
+ observeImages(container) {
+ const images = container.querySelectorAll('img[data-small], img[data-medium]');
+ images.forEach(img => {
+ if (!img.dataset.loaded) {
+ this.imageObserver.observe(img);
}
});
}
- /**
- * Get image size based on screen width
- */
- getImageSize() {
- const width = window.innerWidth;
- if (width > 1024) return 'medium';
- if (width > 500) return 'medium';
- return 'small';
+
+ handlePopState(e) {
+ if (e.state?.filters) {
+ if (this.processURLFilters()) {
+ this.store.setFilters(this.filters);
+ this.a11y.announce('Feed filters updated from browser history');
+ }
+ }
}
+ handleClick(e) {
+ if (window.targetCheck(e, this.elements.loadMore)) {
+ this.nextPage();
+ } else if (window.targetCheck(e, this.elements.clearFilter)) {
+ this.clearAllTaxonomies();
+ } else if (window.targetCheck(e, '.remove-item')) {
+ this.handleRemoveSelectedTerm(e);
+ }
+ }
+
+ handleRemoveSelectedTerm(e) {
+ const selectedItem = e.target.closest('.selected-item');
+ if (!selectedItem) return;
+
+ const termId = parseInt(selectedItem.dataset.id);
+ const taxonomy = selectedItem.dataset.taxonomy;
+
+ // Remove from filters
+ if (this.taxonomyFilters[taxonomy]) {
+ this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy]
+ .filter(id => id !== termId);
+
+ if (this.taxonomyFilters[taxonomy].length === 0) {
+ delete this.taxonomyFilters[taxonomy];
+ }
+ }
+
+ // Remove from UI
+ selectedItem.remove();
+
+ // Update filters
+ this.updateFilter({
+ taxonomy: Object.keys(this.taxonomyFilters).length > 0
+ ? this.taxonomyFilters
+ : null,
+ page: 1
+ });
+ }
+
+ handleChange(e) {
+ let target = e.target;
+ if (Object.hasOwn(target.dataset, 'filter')) {
+ if (target.dataset.filter === 'content') {
+ this.updateContentFor(target.value);
+ this.updateFilter({ content: target.value, page: 1 });
+ } else if (target.dataset.filter === 'orderby') {
+ this.updateOrderDirectionVisibility(target.value);
+ this.updateFilter({ orderby: target.value, page: 1 });
+ } else if (target.dataset.filter === 'order') {
+ this.updateFilter({ order: target.value, page: 1 });
+ } else if (target.dataset.filter === 'match') {
+ this.updateFilter({ match: target.checked ? 'all' : 'any', page: 1 });
+ } else if (target.dataset.filter === 'favourites') {
+ this.updateFilter({ favourites: target.checked, page: 1 });
+ }
+ }
+ }
}
-document.addEventListener('DOMContentLoaded', () => {
+
+document.addEventListener('DOMContentLoaded', function() {
window.feedBlock = new FeedBlock();
});
diff --git a/src/feed/viewOld.js b/src/feed/viewOld.js
index 347e997..29859f9 100644
--- a/src/feed/viewOld.js
+++ b/src/feed/viewOld.js
@@ -1,968 +1,529 @@
class FeedBlock {
- static LOADING_QUIPS = JSON.parse(feedSettings.quips);
- constructor(container){
+ constructor() {
+ this.cache = window.jvbCache;
+ this.a11y = window.jvbA11y;
+ this.loading = window.jvbLoading;
+ this.error = window.jvbError;
- this.cache = window.jvbCache;
- this.eventHandlers = new Map();
- this.rendered = {};
+ this.container = document.querySelector('section.feed-block');
+ if (!this.container) {
+ return;
+ }
- // Store container references
- this.container = container;
- this.a11y = window.jvbA11y;
+ this.openGallery = false;
+ this.initElements();
+ this.addPlaceholders();
+ this.config = {
+ api: feedSettings.apiUrl,
+ nonce: feedSettings.nonce,
+ user: jvbSettings.currentUser || null,
+ source: '',
+ context: '',
+ highlight: null,
+ gallery: false,
+ showAuthor: true,
+ showDate: false,
+ view: localStorage.getItem('feedViewMode') || 'grid',
+ ... this.container.dataset
+ };
+ this.taxonomies = {};
+ this.rendered = {};
- //For tracking cache and current requests
- this.currentRequest = null;
- this.timeoutId = null;
+ this.feed = {
+ imageLoadThreshold: 5,
+ lazyLoadOffset: '100px',
+ gallery: [],
+ loaded: 0,
+ intsersectionObserver: null,
+ templates: new Map()
+ };
- // Initialize Config
- this.initConfig();
+ this.isLoading = false;
+ this.hasMore = true;
+ this.retries = {
+ count: 0,
+ max: 3,
+ delay: 1000
+ };
+ this.page = 1;
+ this.order = 'DESC';
+ this.orderby = 'date';
+ this.gallery = (this.config.gallery) ? new window.jvbGallery(document.querySelector('dialog.gallery'), {
+ imageWrapper: '.item',
+ loadMore: ()=>this.fetchFeed.bind(this)
+ }) : false;
+ this.initListeners();
+ if (this.page === 1) {
+ this.processURLFilters();
+ } else {
+ this.updateFilters();
+ }
- // Setup error handler
- this.error = window.jvbError;
-
- this.resetState();
- this.state.firstLoad = false;
- this.state.URLProcessed = false;
- this.highlightGot = false;
-
- this.selectorInstances = {};
-
- this.elements = {
- filters: this.container.querySelector('form.feed-filters'),
- selected: this.container.querySelector('.selected-items'),
- clearFilters: this.container.querySelector('button.clear-filters'),
- grid: this.container.querySelector('.feed-grid'),
- loadMore: this.container.querySelector('button.load-more'),
- spinner: this.container.querySelector('.loading-spinner'),
- loading: this.container.querySelector('.feed-overlay'),
- matchAll: this.container.querySelector('.filter-actions .toggle-text'),
- }
- this.filters = {
- content: this.getCurrentContent(),
- taxonomies: {},
- favourites: false,
- orderby: 'date',
- order: 'desc',
- }
-
- this.feed = {
- imageLoadThreshold: 5,
- lazyLoadOffset: '100px',
- gallery: [],
- loaded: 0,
- intersectionObserver: null,
- templates: new Map()
- }
- this.imageObserver = null;
- this.resizeObserver = null;
-
- this.highlight = null;
- //Loading settings
- this.loadingIndex = 0;
- this.quips = this.initializeQuips();
- this.loadingMessage = this.container.querySelector('.loading-message');
- this.dotsElement = this.container.querySelector('.loading-dots');
- this.quipInterval = null;
- this.loadingOptions = {
- loadingMessages: {},
- cycleInterval: 2000, //time between loading messages
- }
-
- this.initFilters();
- if(this.container.dataset.gallery){
- this.initGallery();
- this.setupGalleryAccessibility();
- }
- this.initListeners();
- if(!this.state.URLProcessed){
- this.updateFilters();
- }
-
- this.selectedListeners = this.checkSelectedClicks.bind(this);
- this.updateSelectedListeners();
- }
-
- initConfig() {
- // Get settings from container data attribute
- const settings = JSON.parse(this.container.dataset.settings || '{}');
- let content = Array.from(this.container.querySelectorAll('input[name="content"]')).map(content=> content.value);
- this.config = {
- api: feedSettings.apiUrl,
- nonce: feedSettings.nonce,
- currentUser: jvbSettings.currentUser || null,
-
- content: content[0],
- contentTypes: content,
- taxonomies: Array.from(this.container.querySelectorAll('.jvb-selector')).map(taxonomy => taxonomy.dataset.taxonomy),
-
- // Source information for analytics
- source: this.container.dataset.source || '',
- context: this.container.dataset.context || '',
-
- // Optional highlight
- highlight: null,
-
- // Gallery mode
- isGallery: this.container.dataset.gallery || false,
- showAuthor: !this.container.dataset.gallery || true,
- showDate: this.container.dataset.gallery || false,
-
-
- // User preferences
- viewMode: localStorage.getItem('feedViewMode') || 'grid',
- }
-
- if(settings.isGallery){
- this.config.highlight = this.getHighlight();
- }
- }
-
- initFilters(){
- this.updateContentFor(this.getCurrentContent());
- }
-
- checkSelectedClicks(e){
- if(e.target.closest('.remove-item')){
- let tag = e.target.closest('.selected-item');
-
- // Uncheck checkbox if it exists
- let taxonomy = tag.dataset.taxonomy;
- this.clearSelectedTerm(tag.dataset.id, taxonomy);
- // Remove tag
- tag.remove();
-
- // Update clear filters button visibility
- this.updateClearFiltersButton();
- this.updateFilters();
- this.updateSelectedListeners();
- }
- }
-
- updateSelectedListeners(){
- this.elements.selected.removeEventListener('click', this.selectedListeners);
- if(this.elements.selected.children.length>0){
- this.elements.selected.addEventListener('click', this.selectedListeners);
- }
- }
-
- handleFilterChange(e){
- if(e.target.closest('.jvb-selector')){
- return;
- }
- this.resetPage();
- if(e.target.name === 'content'){
- let content = e.target.value;
- this.updateContentFor(content);
- }
- this.updateFilters();
- }
- handleLoadMore(){
- if (this.state.loading || !this.state.hasMore) {
- return;
- }
- this.fetchFeed();
- }
- initListeners(){
- window.addEventListener('popstate', this.handlePopState.bind(this));
- this.addEvent(this.elements.filters, 'change', (e) => this.handleFilterChange(e));
- this.addEvent(this.elements.filters, 'submit', e => e.preventDefault());
- this.addEvent(this.elements.loadMore, 'click', () => this.handleLoadMore());
- this.addEvent(this.elements.clearFilters, 'click', () => this.clearSelectedTaxonomies());
- if(this.config.isGallery){
- this.addEvent(this.elements.grid, 'click', (e) =>this.handleGalleryOpen(e));
- }
-
- // Intersection observer for lazy loading
- if ('IntersectionObserver' in window) {
- this.imageObserver = new IntersectionObserver(entries => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- this.loadImage(entry.target);
- this.imageObserver.unobserve(entry.target);
- }
- });
- }, {
- rootMargin: this.feed.lazyLoadOffset,
- threshold: 0.1
- });
- }
- // Resize observer for responsive images
- if ('ResizeObserver' in window) {
- this.resizeObserver = new ResizeObserver(window.debounce(() => {
- this.updateImageSizes();
- }, 250));
-
- // Observe the container
- this.resizeObserver.observe(this.container);
- } else {
- // Fallback to window resize
- window.addEventListener('resize', window.debounce(() => {
- this.updateImageSizes();
- }, 250));
- }
- }
- handleGalleryOpen(e){
- const feedImage = e.target.closest('.feed-image');
- if(feedImage){
- const item = feedImage.closest('.feed-item');
- if(item) {
- const index = Array.from(this.container.querySelectorAll('.feed-item')).indexOf(item);
- if(index !== -1){
- this.openGallery(index);
- }
- }
- }
- }
- initGallery(){
- this.gallery = {
- items: this.getGalleryItems() || [],
- index: 0,
- touchStart: null,
- touchEnd: null,
- minSwipe: 50,
- modal: this.createGalleryModal(),
- keyHandler: null,
- loading: false
- }
- document.body.appendChild(this.gallery.modal);
- }
-
- /**
- * Handle browser history navigation
- */
- handlePopState(e) {
- if (e.state && e.state.filters) {
- if(this.processURLFilters()){
-
- // Load items with updated filters
- this.resetPage();
- this.fetchFeed();
-
- // Announce to screen readers
- this.a11y.announce('Feed filters updated from browser history.');
- }
- }
- }
-
- processURLFilters(){
- const params = new URLSearchParams(window.location.search);
- if (!params.toString()) {
- return false; // No parameters to process
- }
- // Initialize content type
- const content = params.get('f_content');
- params.delete('f_content');
- if (content && this.elements.filters.querySelector(`input[value="${content}"]`)) {
- this.elements.filters.querySelector(`input[value="${content}"]`).checked = true;
- this.filters.content = content;
- }
-
- // Initialize order
- const order = params.get('f_order');
- params.delete('f_order');
- if (order && this.elements.filters.querySelector(`input[name="order"][value="${order}"]`)) {
- this.elements.filters.querySelector(`input[name="order"][value="${order}"]`).checked = true;
- this.filters.order = order;
- }
-
- // Initialize orderby
- const orderby = params.get('f_orderby');
- params.delete('f_orderby');
- if (orderby && this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`)) {
- this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`).checked = true;
- this.filters.orderby = orderby;
- }
-
- // Initialize favourites filter
- if (params.get('f_favourites') === 'true' && this.config.currentUser !== null) {
- params.delete('f_favourites');
- const favCheckbox = this.elements.filters.querySelector(`input[name="favourites_only"]`);
- if (favCheckbox) favCheckbox.checked = true;
- this.filters.favourites = true;
- }
-
- // Initialize match, if present
- if(params.get('f_match') === 'all'){
- params.delete('f_match');
- this.elements.matchAll.querySelector('input').checked = true;
- }
-
- window.removeChildren(this.elements.selected);
-
- let filters = JSON.parse(JSON.stringify(this.filters));
- let unprocessed = {};
- for (var [key, value] of Object.entries(Object.fromEntries(params))) {
- if (key.startsWith('f_') ) {
- var taxName = key.replace('f_', '');
- let cache = this.cache.getItem(taxName+'List');
- if(!Object.keys(filters['taxonomies']).includes(taxName)){
- filters.taxonomies[taxName] = {};
- }
-
- // Handle both single values and comma-separated values
- const termIds = value.includes(',') ? value.split(',') : [value];
- termIds.forEach(termId=>{
- if(cache && cache.hasOwnProperty(termId)){
- filters.taxonomies[taxName][termId] = cache[termId].name;
- let tag = this.createFilterTag(taxName, termId, cache[termId].name);
- this.elements.selected.appendChild(tag);
- }else{
- if(!unprocessed.hasOwnProperty(taxName)){
- unprocessed[taxName] = [];
- }
- unprocessed[taxName].push(termId);
- }
- });
- }
- }
-
- this.filters = filters;
-
- if(!isEmptyObject(unprocessed)){
- this.fetchTermDetails(unprocessed);
- }
-
- if(this.config.isGallery){
- this.getHighlight();
- }
-
- this.updateContentFor(content);
- this.updateSelectedListeners();
- return true;
-
- }
-
- async loadFromURL() {
-
- if(this.processURLFilters()){
- // Announce to screen readers
- this.a11y.announce('Feed filters updated from URL.');
- }
- return true;
- }
-
- //We already checked the cache, now fetch the remaining term information
- async fetchTermDetails(terms) {
- let params = new URLSearchParams(terms);
-
- // Otherwise fetch the term details
- try {
- // Format the URL - might need to adjust based on your API structure
- const url = `${this.config.api}terms/check?`+params.toString();
-
- const termData = await this.cache.fetchWithCache(
- url, {
- method: 'GET',
- },
- {
- });
-
- for(const [taxonomy, terms] of Object.entries(termData.terms)){
- if(!this.filters.taxonomies.hasOwnProperty(taxonomy)){
- this.filters.taxonomies[taxonomy] = {};
- }
-
- this.cache.setItem(taxonomy+'List', terms, taxonomy);
-
- for(const [termId, termData] of Object.entries(terms)){
- this.filters.taxonomies[taxonomy][termId] = termData.name;
- let tag = this.createFilterTag(taxonomy, termId, termData.name);
- this.elements.selected.appendChild(tag);
- }
- this.updateSelectedListeners();
- }
-
- } catch (error) {
- console.error(`Error fetching term details for ${terms}:`, error);
-
- }
- }
-
- //State Management
- nextPage(hasMore = true){
- this.state.page++;
- this.state.hasMore = hasMore;
- }
- resetPage(hasMore = true){
- this.state.page = 1;
- this.state.hasMore = hasMore;
- }
- resetState() {
- this.state = {
- page: 1,
- loading: false,
- hasMore: true,
- retries: {
- count: 0,
- max: 3,
- delay: 1000
- }
- }
- }
-
- updateFilters(reset = true){
- let updated = false;
- let content = this.getCurrentContent();
- let favourites = this.getFavouritesOnly();
- let order = this.getCurrentOrder();
- let orderby = this.getCurrentOrderby();
- let taxonomies = this.getSelectedTaxonomies();
-
- if(this.filters.content !== content ||
- this.filters.favourites !== favourites ||
- this.filters.order !== order ||
- this.filters.orderby !== orderby ||
- this.filters.taxonomies !== taxonomies ){
- updated = true;
- }
- (this.filters.content !== content)??this.updateContentFor(this.filters.content);
-
- this.filters.content = content;
- this.filters.favourites = favourites;
- this.filters.order = order;
- this.filters.orderby = orderby;
- this.filters.taxonomies = taxonomies;
-
- if(this.state.firstLoad){
- this.updateURL();
- }else{
- this.state.firstLoad = true;
- }
-
-
- if(reset){
- this.resetPage();
- }
- this.fetchFeed();
- }
- getCurrentContent(){
- return this.elements.filters.querySelector('input[name="content"]:checked').value;
- }
- getFavouritesOnly(){
- return this.elements.filters.querySelector('input[name="favourites_only"]')?.checked ??false;
- }
- getCurrentOrder(){
- return this.elements.filters.querySelector('input[name="order"]:checked').value;
- }
- getCurrentOrderby(){
- return this.elements.filters.querySelector('input[name="orderby"]:checked').value;
- }
-
- getCurrentTaxonomies(){
- let taxonomies = this.filters.taxonomies;
- let out = {};
- if(!isEmptyObject(taxonomies)){
- for(var [tax, terms] of Object.entries(taxonomies)){
- if(!isEmptyObject(terms)){
- out[tax] = Object.values(terms);
- }
- }
- }
- return out;
- }
- updateContentFor(content){
-
- this.elements.filters.querySelectorAll('.jvb-selector').forEach(tax=> {
- tax.hidden = !tax.dataset.for.includes(content);
- //Ensure any selected Taxonomies are removed
- if(!tax.dataset.for.includes(content)){
- this.clearSelectedTerms(tax);
- }
- //TODO: Ensure clean up of filtered out window.jvbTaxonomySelector instances?
- //Maybe cache them?
- let taxonomy = tax.dataset.taxonomy;
- if(tax.dataset.for.includes(content)){
- if(!this.selectorInstances.hasOwnProperty(taxonomy)){
- this.selectorInstances[taxonomy] = new window.jvbTaxonomySelector(tax, {
- multiple: true,
- feed: true,
- selected: this.filters[taxonomy]??{},
- onClose: () => this.setSelectedTerms(taxonomy)
- });
- }
- }else if(this.selectorInstances.hasOwnProperty(taxonomy)){
- this.clearSelectedTerms(taxonomy);
- delete this.selectorInstances[taxonomy];
- }
- });
- this.elements.filters.querySelectorAll('input[data-for]').forEach(toggle=>{
- toggle.hidden = !toggle.dataset.for.includes(content);
- });
- this.elements.filters.querySelectorAll('input[name="order"]').forEach(order=>{
- order.hidden = this.filters.orderby === 'random';
- });
- }
- updateURL(){
- const params = new URLSearchParams();
-
- let taxonomies = this.filters.taxonomies;
- if(!isEmptyObject(taxonomies)){
- for(var [tax, terms] of Object.entries(taxonomies)){
- if(!isEmptyObject(terms)){
- params.set('f_'+tax, Object.keys(terms));
- }
-
- }
- }
-
- // Clone to avoid modifying original
- let filters = JSON.parse(JSON.stringify(this.filters));
- delete filters.taxonomies;
- for(const [key, value] of Object.entries(filters)){
- if(value !== false){
- params.set('f_'+key, value);
- }
- }
- if(this.elements.matchAll.querySelector('input:checked')){
- params.set('f_match', 'all');
- }
- const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
- history.pushState({ filters }, '', newUrl);
- }
-
- /**
- * Feed Fetching
- * @param reset clear grid
- * @param force force cache busting
- * @returns {Promise<void>}
- */
- async fetchFeed(reset = false, force = false){
- if(this.state.loading){
- return;
- }
-
- this.updateLoading(true);
- try{
- // Track URL processing
- if(this.state.page === 1){
- reset = true;
- await this.loadFromURL();
- }
-
- const params = this.buildFilterRequest();
- if(this.elements.matchAll.querySelector('input:checked')){
- params.append('match', true);
- }
- params.append('page', this.state.page);
- params.append('source', this.config.source);
- params.append('context', this.config.context);
- if(this.config.highlight){
- params.append('highlight', JSON.stringify(this.config.highlight));
- }
-
- //fetch data with cache busting
- const data = await this.cache.fetchWithCache(
- `${this.config.api}feed?${params.toString()}`,
- {
- method: 'GET',
- },
- {
- context: 'feed',
- forceRefresh: true,
- // forceRefresh: force,
- }
- )
-
- console.log(data, 'Fetched data: ');
-
- //Clear grid on first page
- if(this.state.page === 1){
- window.removeChildren(this.elements.grid);
- }
-
- //Handle empty results
- if(!data || !data.items || data.items.length === 0){
- if(this.state.page === 1){
- this.showEmptyState();
- }
- this.state.hasMore = false;
- }else{
- this.state.hasMore = data.hasMore;
- if(this.state.hasMore){
- this.nextPage();
- }
- //Render items
- this.renderItems(data.items, this.state.page > 1);
-
- }
-
- }catch(error){
- this.handleError(error);
- }finally{
- this.updateLoading(false);
- this.elements.loadMore.hidden = !this.state.hasMore;
- }
-
- }
-
- buildFilterRequest(){
- // Clone to avoid modifying original
- const filters = JSON.parse(JSON.stringify(this.filters));
-
- if(filters.taxonomies && !isEmptyObject(filters.taxonomies)){
- let temp = {};
- for(var [tax, terms] of Object.entries(filters.taxonomies)){
- if(!isEmptyObject(terms)){
- temp[tax] =Object.keys(terms);
- }
- }
- delete filters.taxonomies;
- if(!isEmptyObject(temp)){
- filters.taxonomies = JSON.stringify(temp);
- }
- }else{
- delete filters.taxonomies;
- }
-
-
- if(!filters.favourites){
- delete filters.favourites;
- }
- return new URLSearchParams(filters);
- }
-
-
- handleError(error){
- return this.error.handleApiError(
- error,
- {
- component: 'Feed Block',
- action: 'loaditems'
- },
- () => this.fetchFeed()
- );
- }
-
-
-
- getHighlight() {
- if(!this.config.highlight){
- const searchParams = new URLSearchParams(window.location.search);
-
- // Check for content type parameters
- this.config.contentTypes.forEach(type => {
- if (searchParams.has(type)) {
- this.config.highlight = {};
- this.config.highlight[type] = searchParams.get(type);
- }
+ }
+ initElements() {
+ this.filterSelector = 'form.feed-filters';
+ this.filterForm = this.container.querySelector(this.filterSelector);
+ this.grid = this.container.querySelector('.item-grid');
+ this.loadMore = this.container.querySelector('.load-more');
+ this.filterControls = this.container.querySelector('.filter-actions');
+ this.contentTypes = Array.from(this.filterForm.querySelectorAll('input[name="content"]')).map(
+ content => {
+ return content.value;
});
- }
- return this.config.highlight;
- }
+ this.selectedTerms = this.container.querySelector('.selected-items-section .selected-items');
+ }
- /**
- * Loading
- */
- updateLoading(loading) {
- this.state.loading = loading;
- if (loading) {
- this.showLoading();
-
- let content = this.filters.content;
- content = (content === 'artwork') ? content : content+'s';
- let tax = '';
- let taxonomies = this.getCurrentTaxonomies();
- if(!isEmptyObject(taxonomies)){
- let total = 0;
- total = Object.values(taxonomies).map((tax)=> total+tax.length);
- let all = [];
- let join = (total[0] === 2) ? ' and ' : ', ';
- tax = Object.values(taxonomies).map((tax) => all.push(tax.join(join)));
- tax = all.join(', ');
- let index = tax.lastIndexOf(',')+1;
- if(index> 0){
- tax = tax.substr(0, index)+' and'+tax.substr(index);
- }
-
- }
+ initListeners() {
+ window.addEventListener('popstate', this.handlePopState.bind(this));
+ document.addEventListener('click', this.handleClick.bind(this));
+ document.addEventListener('change', this.handleChange.bind(this));
- this.a11y.announce(`Checking for more ${tax} ${content}...`);
- } else {
- this.hideLoading();
- }
+ // Intersection observer for lazy loading
+ if ('IntersectionObserver' in window) {
+ this.imageObserver = new IntersectionObserver(entries => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ this.loadImage(entry.target);
+ this.imageObserver.unobserve(entry.target);
+ }
+ });
+ }, {
+ rootMargin: '100px',
+ threshold: 0.1
+ });
+ }
+ // Resize observer for responsive images
+ if ('ResizeObserver' in window) {
+ this.resizeObserver = new ResizeObserver(window.debounce(() => {
+ this.updateImageSizes();
+ }, 250));
- // Update loading spinner
- this.elements.spinner.hidden = !loading;
+ // Observe the container
+ this.resizeObserver.observe(this.container);
+ } else {
+ // Fallback to window resize
+ window.addEventListener('resize', window.debounce(() => {
+ this.updateImageSizes();
+ }, 250));
+ }
- // Update load more button
- this.elements.loadMore.disabled = loading;
-
- }
+ this.taxonomies = {};
+ this.container.querySelectorAll('.jvb-selector:not([hidden])').forEach(selector => {
+ let taxonomy = selector.dataset.taxonomy;
+ if (!Object.hasOwn(this.taxonomies, taxonomy)) {
+ this.taxonomies[taxonomy] = new window.jvbTaxonomySelector(
+ selector,
+ {
+ multiple: true,
+ feed: true,
+ selected: {},
+ onClose: () => this.setSelectedTerms(taxonomy),
+ }
+ );
+ }
+ });
+ }
- /**
- * Show the loading overlay
- */
- showLoading() {
- this.hideBody();
- this.elements.loading.classList.add('active');
- this.startQuipCycle();
- document.body.classList.add('loading');
- }
+ /**
+ * Handle browser history navigation
+ */
+ handlePopState(e) {
+ if (e.state && e.state.filters) {
+ if(this.processURLFilters()){
+ // Load items with updated filters
+ this.resetPage();
+ this.fetchFeed();
- /**
- * Hide the loading overlay
- */
- hideLoading() {
- this.showBody();
- this.container.classList.remove('active');
- this.stopQuipCycle();
- document.body.classList.remove('loading');
- }
+ // Announce to screen readers
+ this.a11y.announce('Feed filters updated from browser history.');
+ }
+ }
+ }
+
+ processURLFilters() {
+ const params = new URLSearchParams(window.location.search);
+ //No parameters to process
+ if (!params.toString()) {
+ this.updateFilters();
+ return;
+ }
+
+ let filters = ['content', 'order', 'orderby', 'favourites','match'];
+
+ filters.forEach(filter => {
+ let value = params.get('f_'+filter);
+ params.delete('f_'+filter);
+ if (value && this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`)) {
+ this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`).checked = true;
+ }
+ });
+
+ let unprocessed = {};
+ for (var [key, value] of Object.entries(Object.fromEntries(params))) {
+
+ key = key.replace('f_','');
+ if (this.contentTypes.includes(key)) {
+ this.openGallery = value;
+ } else {
+ this.taxonomies[key].addTermsFromURL(value);
+ this.setSelectedTerms(key);
+ }
+ }
+
+ this.updateFilters();
+ }
+
+ handleClick(e) {
+ if (e.target.classList.contains('load-more') || e.target.closest('.load-more')) {
+ this.fetchFeed(false);
+ e.target.disabled = true;
+ } else if (e.target.classList.contains('clear-filters') || e.target.closest('.clear-filters')) {
+ this.resetFilters();
+ } else if (this.config.gallery && e.target.closest('.feed-image')) {
+ this.gallery.handleGalleryOpen(e);
+ } else if (e.target.classList.contains('.remove-item') || e.target.closest('.remove-item')) {
+ let tag = e.target.closest('.selected-item');
+ let taxonomy = tag.dataset.taxonomy;
+ this.taxonomies[taxonomy].removeSelectedTerm(tag.dataset.id);
+ this.setSelectedTerms(taxonomy);
+ this.updateFilters();
+ }
+ }
+ handleChange(e) {
+ if (e.target.closest(this.filterSelector)) {
+ this.resetPage();
+ window.removeChildren(this.grid);
+ this.addPlaceholders();
+ //update filters
+ this.updateFilters();
+ }
+ }
+
+ updateFilters() {
+ this.page = 1;
+ const params = new URLSearchParams(window.location.search);
+
+ let filters = Object.fromEntries(new FormData(this.filterForm));
+
+ let contents = [];
+ for (let [key, value] of Object.entries(filters)) {
+ let set = false;
+ switch (key) {
+ case 'content':
+ if (value !== this.contentTypes[0]) {
+ set = true;
+ } else {
+ params.delete('f_'+key);
+ }
+ break;
+ case 'orderby':
+ if (value !== 'date') {
+ set = true;
+ }
+ break;
+ case 'order':
+ if (value !== 'desc') {
+ set = true;
+ }
+ break;
+ default:
+ set = true;
+ }
+ if (!set) {
+ params.delete('f_'+key);
+ }
- /**
- * Start cycling through loading messages
- */
- startQuipCycle() {
- if (this.quipInterval) {
- clearInterval(this.quipInterval);
- }
+ if (set && value !== false && value !== '') {
+ params.set('f_'+key, value);
+ }
+ if (value !== '') {
+ contents.push(value);
+ }
- if (!this.quips.length) return;
+ const newURL = `${window.location.pathname}?${params.toString()}`;
+ history.pushState(filters, '', newURL);
- // Set initial message
- this.updateMessage(this.quips[0]);
- this.container.classList.remove('changing');
+ }
- this.quipInterval = setInterval(() => {
- this.container.classList.add('changing');
+ this.filters = filters;
+ this.updateContentFor(filters.content);
- setTimeout(() => {
- this.loadingIndex = (this.loadingIndex + 1) % this.quips.length;
- this.updateMessage(this.quips[this.loadingIndex]);
+ this.updateFilterControls();
- setTimeout(() => {
- this.container.classList.remove('changing');
- }, 50);
- }, 350);
- }, this.loadingOptions.cycleInterval);
- }
+ this.loading.setContent(contents);
+ this.fetchFeed(true);
+ }
- /**
- * Stop cycling through loading messages
- */
- stopQuipCycle() {
- if (this.quipInterval) {
- clearInterval(this.quipInterval);
- this.quipInterval = null;
- }
- }
+ updateFilterControls() {
+ this.filterControls.hidden = this.selectedTerms.children.length < 2;
+ }
- /**
- * Update the loading message
- */
- updateMessage(quipData) {
- if (!this.loadingMessage) return;
+ /**
+ * Toggles taxonomy selectors and certain order/orderby options
+ * depending on current content
+ * @param content
+ */
+ updateContentFor(content) {
+ this.filterForm.querySelectorAll('.jvb-selector').forEach(tax => {
+ let hasContent = tax.dataset.for.includes(content);
+ tax.hidden = !hasContent;
+ if (!hasContent) {
+ let t = tax.dataset.taxonomy;
+ this.clearSelectedTerms(t);
+ }
+ });
+ this.filterForm.querySelectorAll('input[data-for]').forEach(toggle => {
+ toggle.hidden = !toggle.dataset.for.includes(content);
+ });
+ this.filterForm.querySelectorAll('input[name="order"]').forEach(order => {
+ order.hidden = this.filters.order === 'random';
+ });
+ }
- const icon = feedSettings?.icons?.[quipData.icon] || '';
- this.loadingMessage.innerHTML = `${icon}<p>${quipData.quip}</p>`;
- }
+ clearSelectedTerms(taxonomy) {
+ this.filterForm.querySelector(`input[name="${taxonomy}"]`).value = '';
+ if (Object.hasOwn(this.taxonomies, taxonomy)) {
+ this.taxonomies[taxonomy].selectedItems = {};
+ }
+ }
- /**
- * Set a custom loading message
- */
- setMessage(message, icon = null) {
- if (!this.loadingMessage) return;
-
- this.stopQuipCycle();
-
- const iconHtml = icon ? feedSettings?.icons?.[icon] || '' : '';
- this.loadingMessage.innerHTML = `${iconHtml}<p>${message}</p>`;
- }
-
- /**
- * Shuffle an array (Fisher-Yates algorithm)
- */
- shuffleArray(array) {
- const newArray = [...array];
- for (let i = newArray.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [newArray[i], newArray[j]] = [newArray[j], newArray[i]];
- }
- return newArray;
- }
-
- /**
- * Initialize quips for loading messages
- */
- initializeQuips() {
- // Map pstyle, artstyle, etc. to their base types for quips
- const typeMapping = {
- 'pstyle': 'style',
- 'artstyle': 'style',
- 'arttheme': 'theme',
- 'artmedium': 'style',
- 'artform': 'style',
- 'placement': 'style',
- 'colour': 'style'
- };
-
- // Gather all quips based on content types and taxonomies
- const allQuips = [];
-
- // Add content type quips
- if (FeedBlock.LOADING_QUIPS[this.filters.content]) {
- FeedBlock.LOADING_QUIPS[this.filters.content].forEach(quip => {
- allQuips.push({
- icon: this.filters.content,
- quip: quip
- });
- });
- }
-
- // Add taxonomy quips
- if(this.filters.taxonomies && !isEmptyObject(this.filters.taxonomies)){
- Object.keys(this.filters.taxonomies).forEach(taxonomy => {
- const baseType = typeMapping[taxonomy] || taxonomy;
- if (FeedBlock.LOADING_QUIPS[baseType]) {
- FeedBlock.LOADING_QUIPS[baseType].forEach(quip => {
- allQuips.push({
- icon: taxonomy,
- quip: quip
- });
- });
- }
- });
- }
+ setSelectedTerms(taxonomy) {
+ let input = this.filterForm.querySelector(`input[name="${taxonomy}"]`);
+ input.value = '';
+ let selected = this.taxonomies[taxonomy].selectedTerms;
+ if (!window.isEmptyObject(selected)) {
+ let ids = Object.keys(selected);
+ input.value = ids.join(',');
+ }
+ this.updateFilters();
+ }
- // Shuffle the quips array
- return this.shuffleArray(allQuips);
- }
+ nextPage() {
+ if (this.hasMore) {
+ this.page++;
+ }
+ }
+ resetPage() {
+ this.page = 1;
+ this.hasMore = true;
+ }
+ resetState() {
+ this.resetPage(true);
+ this.isLoading = false;
+ this.retries = {
+ count: 0,
+ max: 3,
+ delay: 1000
+ };
+ }
- /**
- * Feed Grid
- */
+ resetFilters() {
+ this.filterForm.reset();
+ //check the first content
+ this.filterForm.querySelector('input[name="content"]').checked = true;
+ this.filterForm.querySelector('input[name="orderby"][value="date"]').checked = true;
+ this.page = 1;
+ this.updateFilters();
+ }
- renderItems(items, append = false) {
- // Reset if not appending
- if (!append) {
- window.removeChildren(this.elements.grid);
- this.feed.loaded = 0;
- this.feed.gallery = [];
- }
- // Add items to gallery if in gallery mode
- if (this.config.isGallery) {
- this.feed.gallery = this.feed.gallery.concat(items);
- }
+ buildFilterRequest() {
- // Bail early if no items
- if (items.length === 0) {
- this.a11y.announceUpdate(0, append);
- return;
- }
+ let filters = {};
- // Use DocumentFragment for better performance
- const fragment = document.createDocumentFragment();
+ for (let [filter, value] of Object.entries(this.filters)) {
+ if (value !== false && value !== '') {
+ filters[filter] = value;
+ }
+ }
+ filters.page = parseInt(this.page);
+ if (this.container.dataset.context) {
+ filters.context = this.container.dataset.context;
+ }
+ if (this.container.dataset.source) {
+ filters.source = this.container.dataset.source;
+ }
+ return new URLSearchParams(filters).toString();
+ }
- // Process items in batches for better performance
- const batchSize = 10;
- const processBatch = (startIndex) => {
- const endIndex = Math.min(startIndex + batchSize, items.length);
+ async fetchFeed(reset = false, force = false) {
+ if (this.isLoading) {
+ return false;
+ }
+ this.loading.showLoading(this.filters);
+ try {
+ if (this.page === 1) {
+ window.removeChildren(this.grid);
+ this.addPlaceholders();
+ }
- // Process this batch
- for (let i = startIndex; i < endIndex; i++) {
- const item = items[i];
- const element = this.createItemElement(item);
- fragment.appendChild(element);
+ const data = await this.cache.fetchWithCache(
+ `${this.config.api}feed?${this.buildFilterRequest()}`,
+ {
+ method: 'GET',
+ },
+ {
+ context: 'feed',
+ forceRefresh: true
+ // forceRefresh: force
+ }
+ );
- // Lazy load images beyond threshold
- if (this.feed.loaded >= this.feed.imageLoadThreshold && this.imageObserver) {
- this.imageObserver.observe(element);
- } else {
- this.loadImage(element);
- }
+ //Handle empty results
+ if (!data || !data.items || data.items.length === 0) {
+ if (this.page === 1) {
+ this.showEmptyState();
+ }
+ this.hasMore = false;
+ return false;
+ } else {
+ this.hasMore = data['has_more'];
- this.feed.loaded++;
- }
+ this.renderItems(data.items, this.page > 1);
- // If we have more items, process next batch in next frame
- if (endIndex < items.length) {
- requestAnimationFrame(() => {
- processBatch(endIndex);
- });
- } else {
- // All batches processed, append fragment
- this.elements.grid.appendChild(fragment);
- if(this.container.dataset.gallery){
- console.log(this.getGalleryItems());
- this.updateGalleryItems(this.getGalleryItems());
- //pagination already updated, so it'll be page 2
- if(this.getHighlight() && !this.highlightGot){
- this.highlightGot = true;
- this.openGallery(0);
- }
- }
- this.a11y.makeNavigable(this.elements.grid.querySelectorAll('.feed-item:not([data-keyboard-nav])'));
- this.a11y.announceItems(items.length, append, this.state.hasMore);
- }
- };
+ if (this.hasMore) {
+ this.nextPage();
+ }
+ return true;
+ }
+ } catch (error) {
+ this.handleError(error);
+ } finally {
+ this.loading.hideLoading();
+ if (this.openGallery !== false) {
+ this.gallery.openWhenReady = this.openGallery;
+ this.openGallery = false;
+ }
+ this.loadMore.disabled = false;
+ this.loadMore.hidden = !this.hasMore;
+ }
+ }
- // Start processing the first batch
- if (items.length > 0) {
- processBatch(0);
- } else {
- this.a11y.announceUpdate(0, append);
- }
- }
+ removePlaceholders() {
+ if (this.grid.querySelector('.placeholder')) {
+ window.removeChildren(this.grid);
+ }
+ }
+ showEmptyState() {
+ window.removeChildren(this.grid);
+ let template = window.getTemplate('emptyState');
+ let isFavourite = Object.hasOwn(this.filters, 'favourites') && this.filters.favourites === true;
+ if (isFavourite) {
+ [
+ template.querySelector('h3').textContent,
+ template.querySelector('p:first-of-type').textContent,
+ template.querySelector('p:last-of-type').textContent,
+ ] = [
+ '♡ BLANK CANVAS ♡',
+ 'You haven\'t fallen in love with any pieces... yet!',
+ 'Hit that heart icon when something stops your scroll — your dream collection is waiting to start.'
+ ];
+ }
+ this.grid.append(template);
+ this.a11y.announceEmpty(isFavourite);
- /**
- * Load image for an item
- */
- loadImage(element) {
- const img = element.querySelector('img');
- if (!img) return;
- const size = this.getImageSize();
- img.src = img.dataset[size] || img.dataset.src;
- delete img.dataset.src;
+ }
+ handleError(error){
+ return this.error.handleApiError(
+ error,
+ {
+ component: 'Feed Block',
+ action: 'loaditems'
+ },
+ () => this.fetchFeed()
+ );
+ }
- element.setAttribute('data-loaded', 'true');
- }
+ addPlaceholders() {
+ let total = this.contentTypes.length - 1;
+ for (let i = 0; i < 9; i++) {
+ let template = window.getTemplate('placeholderTemplate');
+ let rand = Math.floor(Math.random()*total+1);
+ let icon = window.getIcon(this.contentTypes[rand]).cloneNode(true);
- /**
- * Update image sizes based on screen width
- */
- updateImageSizes() {
- const size = this.getImageSize();
+ template.append(icon);
+ this.grid.append(template);
+ }
+ }
+ renderItems(items, append = false) {
+ //Clear the grid if we aren't appending
+ if (!append) {
+ window.removeChildren(this.grid);
+ this.addPlaceholders();
+ }
- // Update only visible images that aren't already loaded with the right size
- const items = this.elements.grid.querySelectorAll('.feed-item[data-loaded="true"]');
- items.forEach(item => {
- const img = item.querySelector('img');
- if (img && img.dataset[size] && img.src !== img.dataset[size]) {
- img.src = img.dataset[size];
- }
- });
- }
- /**
- * Create an element for a feed item
- */
- createItemElement(item) {
+ //Bail early if no items
+ if (items.length === 0) {
+ this.a11y.announceUpdate(0, append);
+ return;
+ }
- if(!this.rendered[item.icon]){
- this.rendered[item.icon] = new Map();
- }
+ //Use DocumentFragment for better performance
+ const fragment = document.createDocumentFragment();
- if(this.rendered[item.icon].has(item.id)){
- return this.rendered[item.icon].get(item.id);
- }
+ const batchSize = 10;
+ const processBatch = (startIndex) => {
+ const endIndex = Math.min(startIndex + batchSize, items.length);
- const favourited = window.isFavourited(item.icon, item.id)??false;
- const template = window.getTemplate('feed-item');
+ for (let i = startIndex; i < endIndex; i++) {
+ const item = items[i];
+ const element = this.createItemElement(item);
+ fragment.appendChild(element);
- // Set unique attributes
- template.id = `${item.icon}-${item.id}`;
- template.classList.add(item.icon);
+ this.imageObserver.observe(element);
+ }
- if (item.umami_view) {
- this.buildUmamiData(template, item.umami_view);
+ if (endIndex < items.length) {
+ requestAnimationFrame(() => {
+ processBatch(endIndex);
+ });
+ } else {
+ this.removePlaceholders();
+ //all batches are processed, append fragment
+ this.grid.appendChild(fragment);
+ if (this.config.gallery) {
+ this.gallery.updateGalleryItems(this.gallery.getGalleryItems());
+ }
+ this.a11y.makeNavigable(this.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
+ this.a11y.announceItems(items.length, append, this.hasMore);
+ }
+ };
+
+ if (items.length > 0) {
+ processBatch(0);
+ } else {
+ this.a11y.announceUpdate(0, append);
+ }
+ }
+
+ /**
+ * Creates a feed-item. Used by RenderItems
+ */
+ createItemElement(item) {
+ if(!this.rendered[item.icon]) {
+ this.rendered[item.icon] = new Map();
+ }
+ if (this.rendered[item.icon].has(item.id)) {
+ return this.rendered[item.icon].get(item.id);
+ }
+
+ const favourited = window.isFavourited(item.icon, item.id)??false;
+ const template = window.getTemplate('feed-item');
+
+ template.id = `${item.icon}-${item.id}`;
+ template.dataset.id = item.id;
+ template.classList.add(item.icon);
+
+ if (item['umami_view']) {
+ this.buildUmamiData(template, item['umami_view']);
}
let favouriteButton = template.querySelector('button.favourite');
@@ -974,7 +535,7 @@
] = [
item.id,
item.icon,
- item.user_id,
+ item['user_id'],
(favourited) ? 'Remove from Favourites' : 'Add to Favourites'
];
@@ -985,7 +546,7 @@
let summary = template.querySelector('summary');
let info = template.querySelector('.item-info');
- for (let [index, id] of Object.entries(order)){
+ for (let [index, id] of Object.entries(order)) {
let target;
let config = item[id];
if (id === 'title') {
@@ -1009,7 +570,7 @@
} else {
target.remove();
}
- } else if (Object.hasOwn(config, 'terms')) {
+ } else if (Object.hasOwn(config, 'terms')) {
//Taxonomy list
if (config.terms.length === 0) {
continue;
@@ -1084,7 +645,10 @@
let img = images.querySelector('a');
let main = img.cloneNode(true);
- main.href = item.url;
+ if (!this.config.gallery) {
+ main.href = item.url;
+ }
+
main.classList.add('feed-image');
this.buildImageData(main.querySelector('img'), item.image);
images.append(main);
@@ -1093,7 +657,9 @@
images.classList.add('multi');
item.content.forEach(c => {
let image = img.cloneNode(true);
- image.href = c.url;
+ if (!this.config.gallery) {
+ image.href = c.url;
+ }
let itemImg = image.querySelector('img');
itemImg.src = c.image.small;
itemImg.alt = c.image.alt;
@@ -1106,552 +672,74 @@
single.remove();
list.remove();
- this.rendered[item.icon].set(item.id, template);
+ this.rendered[item.icon].set(item.id, template);
- return template;
- }
+ return template;
+ }
- buildImageData(img, data){
+ buildImageData(img, data){
if (typeof data.tiny !== 'string') {
return;
}
- [
- img.src,
- img.dataset.small,
- img.dataset.medium,
- img.dataset.large,
- img.alt
- ] =
- [
- data.tiny,
- data.small,
- data.medium,
- data.large,
- data.alt
- ];
- }
+ [
+ img.src,
+ img.dataset.small,
+ img.dataset.medium,
+ img.dataset.large,
+ img.alt
+ ] =
+ [
+ data.tiny,
+ data.small,
+ data.medium,
+ data.large,
+ data.alt
+ ];
+ }
- buildUmamiData(item, data){
- for(let [key, value] of Object.entries(data)){
- item.dataset[key] = value;
- }
- }
+ buildUmamiData(item, data){
+ for(let [key, value] of Object.entries(data)){
+ item.dataset[key] = value;
+ }
+ }
- /**
- * Show empty state
- */
- showEmptyState() {
- const message = this.filters.favourites
- ? `<div class="feed-empty-state">
- <h3>♡ BLANK CANVAS ♡</h3>
- <p>You haven't fallen in love with any pieces... yet!</p>
- <p>Hit that heart icon when something stops your scroll.</p>
- <p>Your dream collection is waiting to start.</p>
- </div>`
- : `<div class="feed-empty-state">
- <h3>NOTHING HERE...</h3>
- <p>Try tweaking those filters.</p>
- <p>Edmonton's got talent - let's find it.</p>
- </div>`;
+ /**
+ * Load Image, used by renderItems
+ * @param element
+ */
+ loadImage(element) {
+ const img = element.querySelector('img');
+ if (!img) return;
+ const size = this.getImageSize();
- this.elements.grid.innerHTML = message;
- this.a11y.announceEmpty(this.filters.favourites);
- }
+ img.src = img.dataset[size] || img.dataset.src;
+ element.setAttribute('data-loaded', 'true');
+ }
- /**
- * Clear the grid
- */
- clearGrid() {
- this.a11y.announce('Items cleared.');
- window.removeChildren(this.elements.grid);
- this.feed.loaded = 0;
- }
-
- /**
- * Get image size based on screen width
- */
- getImageSize() {
- const width = window.innerWidth;
- if (width > 1024) return 'medium';
- if (width > 500) return 'medium';
- return 'small';
- }
-
- /**
- * Get gallery items for the gallery modal
- */
- getGalleryItems() {
- return Array.from(this.container.querySelectorAll('.feed-item'))
- .map(item => {
- const img = item.querySelector('img');
- if (!img) return null;
-
- return {
- id: item.querySelector('button.favourite').dataset.id,
- small: img.dataset.small || img.src,
- large: img.dataset.medium || img.src,
- full: img.dataset.full || img.src,
- alt: img.alt || '',
- fav: item.querySelector('button.favourite')?.cloneNode(true),
- info: item.querySelector('.item-info')?.cloneNode(true)
- };
- })
- .filter(Boolean);
- }
-
- /**
- * Clean up resources when component is destroyed
- */
- addEvent(element, event, handler, options) {
- if (!element) return;
-
- const boundHandler = handler.bind(this);
- element.addEventListener(event, boundHandler, options);
-
- // Store for cleanup
- if (!this.eventHandlers.has(element)) {
- this.eventHandlers.set(element, []);
- }
- this.eventHandlers.get(element).push({ event, handler: boundHandler });
- }
- destroy() {
- // Clean up observers
- if (this.imageObserver) {
- this.imageObserver.disconnect();
- this.imageObserver = null;
- }
-
- if (this.resizeObserver) {
- this.resizeObserver.disconnect();
- this.resizeObserver = null;
- }
-
- // Clean up all event listeners
- this.eventHandlers.forEach((handlers, element) => {
- handlers.forEach(({ event, handler }) => {
- element.removeEventListener(event, handler);
- });
- });
- this.eventHandlers.clear();
-
- // Clean up timers
- if (this.quipInterval) {
- clearInterval(this.quipInterval);
- }
-
- if (this.timeoutId) {
- clearTimeout(this.timeoutId);
- }
-
- // Clear template cache and other state
- this.feed.templates.clear();
- this.feed.gallery = [];
- this.feed.loaded = 0;
- }
- /** Extra Term Handling **/
-
- getSelectedTaxonomies(){
- let taxonomies = {};
- for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){
- taxonomies[taxonomy] = instance.selectedItems;
- }
- return taxonomies;
- }
- /**
- * Get selected values for a taxonomy
- */
- getSelectedTerms(taxonomy) {
- const selectedItems = this.elements.selected.querySelectorAll(
- `.selected-item[data-taxonomy="${taxonomy}"]`
- );
-
- return Array.from(selectedItems).map(item => item.dataset.id);
- }
-
- clearSelectedTaxonomies(){
- window.removeChildren(this.elements.selected);
- if(!isEmptyObject(this.selectorInstances)){
- for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){
- instance.selectedItems = {};
- }
- }
-
- this.elements.matchAll.querySelector(input).checked = false;
-
- this.filters.taxonomies = {};
- this.updateFilters();
- }
- setSelectedTerms(taxonomy){
- if(this.selectorInstances[taxonomy]){
- let selected = this.selectorInstances[taxonomy].selectedItems;
- if(!isEmptyObject(selected)){
-
- this.filters.taxonomies[taxonomy] = selected;
- for(var [id, name] of Object.entries(selected)){
- this.elements.selected.appendChild(this.createFilterTag(taxonomy, id, name));
- }
- this.updateFilters();
- this.updateSelectedListeners();
- }else{
- delete this.filters.taxonomies[taxonomy];
- }
-
- }
- }
- clearSelectedTerm(termId, taxonomy){
- let container = this.container.querySelector('.filters');
- let input = container.querySelector(`li[data-id="${termId}"] input`);
- input.checked = false
- delete this.selectorInstances[taxonomy].selectedItems[termId];
- }
- clearSelectedTerms(taxonomy){
-
- if(!isEmptyObject(this.filters.taxonomies) && this.filters.taxonomies.hasOwnProperty(taxonomy)){
- delete this.filters.taxonomies[taxonomy];
- }
- if(!isEmptyObject(this.selectorInstances) && this.selectorInstances.hasOwnProperty(taxonomy)){
- this.selectorInstances[taxonomy].selectedItems = {};
- }
-
-
- const selectedItems = this.elements.selected.querySelectorAll(
- `.selected-item[data-taxonomy="${taxonomy}"]`
- );
-
- if(selectedItems.length > 0){
- selectedItems.forEach(item => {
- item.remove();
- });
- }
-
- // Update clear filters button visibility
- this.updateClearFiltersButton();
- }
- updateClearFiltersButton(){
- if (!this.elements.clearFilters) return;
-
- let filters = this.elements.selected.children.length;
-
- const hasFilters = filters > 0;
-
- const hasMultiple = filters > 1;
-
- this.elements.clearFilters.hidden = !hasFilters;
-
- this.elements.filters.classList.toggle('has-filters', hasFilters);
-
- this.elements.matchAll.hidden = !hasMultiple;
- }
-
-
- /**
- * Create a filter tag element
- */
- createFilterTag(taxonomy, id, name) {
- const tag = window.getTemplate('selectedTerm');
-
- tag.dataset.taxonomy = taxonomy;
- tag.dataset.id = id;
- let icon = window.getIcon(taxonomy);
- let span = tag.querySelector('span');
- let button = tag.querySelector('button');
- tag.prepend(icon);
- span.classList.add('filter-name');
- span.classList.remove('item-name');
- [span.textContent, button.remove] =
- [escapeHtml(name), `Remove ${escapeHtml(name)}`];
-
-
-
- return tag;
- }
-
- /**
- * Gallery
- **/
- openGallery(index){
- this.gallery.index = index;
- this.gallery.modal.showModal();
- this.hideBody();
-
- this.bindGalleryEvents();
- //show current image
- this.updateDisplay(index);
- //preload adjacent images
- this.preloadImages();
-
- // Announce initial state
- this.a11y.announce(`Image ${this.gallery.index + 1} of ${this.gallery.items.length}. Use arrow keys to navigate.`)
- this.a11y.trapFocus(this.gallery.modal);
-
- }
-
- /**
- * Create the modal element
- */
- createGalleryModal() {
- const modal = document.createElement('dialog');
- modal.className = 'gallery-modal';
- modal.setAttribute('aria-modal', 'true');
- modal.setAttribute('aria-label', 'Image Gallery');
-
- modal.innerHTML = `
- <button class="gallery-close" aria-label="Close gallery">
- ${jvbSettings.icons.close}
- </button>
-
- <button class="gallery-nav gallery-prev" aria-label="Previous image">
- ${jvbSettings.icons.prev}
- </button>
-
- <button class="gallery-nav gallery-next" aria-label="Next image">
- ${jvbSettings.icons.next}
- </button>
-
- <div class="gallery-content">
- <img src="" alt="" class="gallery-image">
- <details>
- <summary>DETAILS</summary>
- <div class="item-info"></div>
- </details>
- </div>
-
- <div class="gallery-favourite"></div>
- <div class="gallery-counter"><span id="gallery-index">1</span> / <span class="total"></span></div>
- `;
-
- return modal;
- }
-
-
- /**
- * Bind event handlers
- */
- bindGalleryEvents() {
- // Close button
- this.gallery.modal.querySelector('.gallery-close').addEventListener('click', () => this.closeGallery());
-
- // Navigation buttons
- const prevBtn = this.gallery.modal.querySelector('.gallery-prev');
- const nextBtn = this.gallery.modal.querySelector('.gallery-next');
-
- prevBtn.addEventListener('click', () => this.navigate(-1));
- nextBtn.addEventListener('click', () => this.navigate(1));
-
- // Keyboard navigation
- this.gallery.keyHandler = (e) => {
- switch (e.key) {
- case 'ArrowLeft':
- this.navigate(-1);
- break;
- case 'ArrowRight':
- this.navigate(1);
- break;
- case 'Escape':
- this.closeGallery();
- break;
- }
- };
- document.addEventListener('keydown', this.gallery.keyHandler);
-
- // Touch events
- this.gallery.modal.addEventListener('touchstart', (e) => {
- this.gallery.touchStart = e.touches[0].clientX;
- });
-
- this.gallery.modal.addEventListener('touchmove', (e) => {
- this.gallery.touchEnd = e.touches[0].clientX;
- });
-
- this.gallery.modal.addEventListener('touchend', () => {
- if (!this.gallery.touchStart || !this.gallery.touchEnd) return;
-
- const distance = this.gallery.touchStart - this.gallery.touchEnd;
- const isLeftSwipe = distance > this.gallery.minSwipe;
- const isRightSwipe = distance < -this.gallery.minSwipe;
-
- if (isLeftSwipe) {
- this.navigate(1);
- } else if (isRightSwipe) {
- this.navigate(-1);
- }
-
- this.gallery.touchStart = null;
- this.gallery.touchEnd = null;
- });
- }
-
- /**
- * Navigate to previous/next image
- */
- async navigate(direction) {
- const newIndex = this.gallery.index + direction;
-
- // Check if out of bounds
- if (newIndex < 0 || newIndex >= this.gallery.items.length) {
- this.a11y.announceNavigation(newIndex, this.gallery.items.length,direction < 0, direction > 0)
- return;
- }
-
- // Update current index
- this.gallery.index = newIndex;
-
- // Update display
- this.updateDisplay(newIndex);
-
- // Preload adjacent images
- this.preloadImages();
-
- // Announce to screen readers
- this.a11y.announceNavigation(this.gallery.index,this.gallery.items.length);
-
- //Load more if we're near the end
- if (direction > 0 && newIndex >= (this.gallery.items.length - 3) && this.state.hasMore) {
- await this.fetchFeed();
- this.updateGalleryItems(this.getGalleryItems());
- }
- }
-
- /**
- * Preload adjacent images
- */
- preloadImages() {
- // Preload current, previous and next images
- [-1, 0, 1].forEach(offset => {
- const index = this.gallery.index + offset;
- if (index >= 0 && index < this.gallery.items.length) {
- const img = new Image();
- const item = this.gallery.items[index];
-
- if (window.innerWidth < 1000) {
- img.src = item.large || item.src;
- } else {
- img.src = item.full || item.src;
- }
- }
- });
- }
-
- /**
- * Update display with current image
- */
- updateDisplay(index) {
- const item = this.gallery.items[index];
- if (!item) return;
-
- // Get elements
- const favourite = this.gallery.modal.querySelector('.gallery-favourite');
- const image = this.gallery.modal.querySelector('.gallery-image');
- const counter = this.gallery.modal.querySelector('.gallery-counter');
- const info = this.gallery.modal.querySelector('.item-info');
-
- // Update image
- image.src = window.innerWidth < 1000 ?
- (item.large || item.src) :
- (item.full || item.src);
-
- image.alt = item.alt || '';
-
- // Update favourite button
- if (favourite && item.fav) {
- window.removeChildren(favourite);
- favourite.appendChild(item.fav.cloneNode(true));
- }
-
- // Update info
- if (info && item.info) {
- window.removeChildren(info);
- const clone = item.info.cloneNode(true);
- info.appendChild(clone);
- }
-
- // Update counter
- counter.textContent = `${this.gallery.index + 1} / ${this.gallery.items.length}`;
-
- // Update navigation buttons
- this.updateNavigationButtons();
- }
-
- /**
- * Update navigation button visibility
- */
- updateNavigationButtons() {
- const prevBtn = this.gallery.modal.querySelector('.gallery-prev');
- const nextBtn = this.gallery.modal.querySelector('.gallery-next');
-
- prevBtn.classList.toggle('end', this.gallery.index > 0 ? '' : 'none');
- nextBtn.classList.toggle('end', this.gallery.index < this.gallery.items.length - 1 ? '' : 'none');
- }
-
- /**
- * Close the gallery
- */
- closeGallery() {
- this.showBody();
- // Remove event listeners
- document.removeEventListener('keydown', this.gallery.keyHandler);
- this.a11y.announce('Gallery closed.');
-
- this.gallery.modal.close();
-
- // Reset state
- this.gallery.keyHandler = null;
- }
-
- /**
- * Update gallery items
- * @param {Array} newItems - New gallery items
- */
- updateGalleryItems(newItems) {
- // Store original current index and item
- const currentItem = this.gallery.items[this.gallery.index];
-
- // Update items array
- this.gallery.items = newItems;
-
- // Try to keep the same item selected
- if (currentItem) {
- // Find the same item in the new array by matching source
- const newIndex = this.gallery.items.findIndex(item =>
- item.full === currentItem.full ||
- item.large === currentItem.large
- );
-
- if (newIndex !== -1) {
- this.gallery.index = newIndex;
- }
- }
-
- // Update navigation buttons
- this.updateNavigationButtons();
- }
-
- /**
- * Ensure gallery is accessible
- */
- setupGalleryAccessibility() {
- // Add ARIA attributes
- this.gallery.modal.setAttribute('aria-modal', 'true');
- this.gallery.modal.setAttribute('aria-label', 'Image Gallery');
- }
+ /**
+ * Updates the image size according to screen size
+ */
+ updateImageSizes() {
+ const size = this.getImageSize();
+ const items = this.grid.querySelectorAll('.item');
+ items.forEach(item => {
+ const img = item.querySelector('img');
+ if (img && img.dataset[size] && img.src !== img.dataset[size]) {
+ img.src = img.dataset[size];
+ }
+ });
+ }
+ /**
+ * Get image size based on screen width
+ */
+ getImageSize() {
+ const width = window.innerWidth;
+ if (width > 1024) return 'medium';
+ if (width > 500) return 'medium';
+ return 'small';
+ }
- hideBody(){
- document.body.style.overflow = 'hidden';
- }
- showBody(){
- document.body.style.overflow = '';
- }
}
-
-// Initialize feed blocks when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
- document.querySelectorAll('.feed-block').forEach(container => {
- // Initialize with both the container and overlay
- window.feedBlock = new FeedBlock(container);
- });
+ window.feedBlock = new FeedBlock();
});
-
-
-function isEmptyObject(obj) {
- return Object.keys(obj).length === 0;
-}
diff --git a/src/gmbreviews/render.php b/src/gmbreviews/render.php
index 47ad2b5..7aa2dcb 100644
--- a/src/gmbreviews/render.php
+++ b/src/gmbreviews/render.php
@@ -108,6 +108,7 @@
<?php
foreach ($reviews as $review) {
$reviewer = $review['reviewer']['displayName'] ?? 'Anonymous';
+ $reviewer = strtok($reviewer, ' ');
$profilePhoto = $review['reviewer']['profilePhotoUrl'] ?? '';
$rating = $review['starRating'] ?? 0;
$rating = match($rating) {
@@ -122,20 +123,27 @@
$date = $review['updateTime'] ?? '';
?>
<li>
- <article class="review">
- <header class="row btw">
+ <blockquote class="review">
+ <?php
+ // Review text
+ if (!empty($comment)) { ?>
+ <div class="content review">
+ <?= apply_filters('wpautop', $comment) ?>
+ </div>
+ <?php } ?>
+ <cite class="row start nowrap">
<?php if (!empty($profilePhoto)) { ?>
<img src="<?=esc_url($profilePhoto)?>"
- alt="<?=esc_attr($reviewer)?>"
- 'loading="lazy">
+ alt="<?=esc_attr($reviewer)?>"
+ 'loading="lazy">
<?php } else { ?>
<div class="avatar">
- <?= jvbIcon('user-circle')?>
+ <?= jvbIcon('user-circle')?>
</div>
<?php } ?>
- <div class="col end">
- <h4><?= esc_html($reviewer)?></h4>
+ <div class="row start wrap">
+ <p><?= esc_html($reviewer)?></p>
<?php
// Date
if ($showDate && !empty($date)) {
@@ -154,16 +162,8 @@
</div>
<?php } ?>
</div>
-
- </header>
- <?php
- // Review text
- if (!empty($comment)) { ?>
- <div class="review">
- <?= apply_filters('the_content', $comment) ?>
- </div>
- <?php } ?>
- </article>
+ </cite>
+ </blockquote>
</li>
<?php
}
diff --git a/src/gmbreviews/style.scss b/src/gmbreviews/style.scss
index a2edaf3..339f5dc 100644
--- a/src/gmbreviews/style.scss
+++ b/src/gmbreviews/style.scss
@@ -1,4 +1,5 @@
.gmb-reviews {
+ max-width: var(--alignWide);
> .row.btw {
.button {
width: 100%;
@@ -9,8 +10,10 @@
}
}
.stars {
- display: inline-block;
- vertical-align: middle;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: nowrap;
}
ul {
list-style: none;
@@ -19,13 +22,62 @@
li {
margin: 2rem 0;
position:relative;
- &:nth-of-type(odd) {
- left: -2rem;
+ @media (min-width: 768px) {
+ &:nth-of-type(odd) {
+ left: -2rem;
+ }
+ &:nth-of-type(even) {
+ right: -2rem;
+ }
}
- &:nth-of-type(even) {
- right: -2rem;
+
+ }
+ }
+ blockquote {
+ margin:0;
+ padding: 0;
+ .content {
+ border-width: 4px 1px;
+ &::after {
+ border-width: 4px 1px;
+ }
+ &::before {
+ border-width: 8px;
+ bottom: -4px;
}
}
+ cite {
+ position: relative;
+
+ img {
+ width: 4.5rem;
+ position: absolute;
+ left: -8rem;
+ top: 0;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ .wrap {
+ --wrap: wrap;
+
+ p, time {
+ max-width: 49%;
+ }
+
+ .stars {
+ width: 100%;
+ }
+ }
+ }
+ time {
+ white-space: nowrap;
+ }
+ }
+ .stars .icon {
+ background-color: var(--action-0);
}
article {
padding: 1rem;
diff --git a/src/timeline/block.json b/src/timeline/block.json
new file mode 100644
index 0000000..9e219a1
--- /dev/null
+++ b/src/timeline/block.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/timeline",
+ "version": "0.1.0",
+ "title": "Timeline",
+ "category": "jvb",
+ "icon": "shortcode",
+ "description": "Outputs a single timeline post in a cool way",
+ "example": {},
+ "supports": {
+ "html": false,
+ "align": ["wide", "full"]
+ },
+ "textdomain": "jvb",
+ "selectors": {
+ "root": ".timeline"
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
diff --git a/src/timeline/edit.js b/src/timeline/edit.js
new file mode 100644
index 0000000..14f0ce6
--- /dev/null
+++ b/src/timeline/edit.js
@@ -0,0 +1,38 @@
+/**
+ * Retrieves the translation of text.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * React hook that is used to mark the block wrapper element.
+ * It provides all the necessary props like the class name.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
+ * Those files can contain any CSS code that gets applied to the editor.
+ *
+ * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
+ */
+import './editor.scss';
+
+/**
+ * The edit function describes the structure of your block in the context of the
+ * editor. This represents what the editor will render when the block is used.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
+ *
+ * @return {Element} Element to render.
+ */
+export default function Edit() {
+ return (
+ <p { ...useBlockProps() }>
+ { __( 'Will output the timeline', 'jvb' ) }
+ </p>
+ );
+}
diff --git a/src/timeline/editor.scss b/src/timeline/editor.scss
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/timeline/editor.scss
diff --git a/src/timeline/index.js b/src/timeline/index.js
new file mode 100644
index 0000000..d82621b
--- /dev/null
+++ b/src/timeline/index.js
@@ -0,0 +1,33 @@
+/**
+ * Registers a new block provided a unique name and an object defining its behavior.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
+ */
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
+ * All files containing `style` keyword are bundled together. The code used
+ * gets applied both to the front of your site and to the editor.
+ *
+ * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
+ */
+import './style.scss';
+
+/**
+ * Internal dependencies
+ */
+import Edit from './edit';
+import metadata from './block.json';
+
+/**
+ * Every block starts by registering a new block type definition.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
+ */
+registerBlockType( metadata.name, {
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+} );
diff --git a/src/timeline/index.php b/src/timeline/index.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/timeline/index.php
diff --git a/src/timeline/render.php b/src/timeline/render.php
new file mode 100644
index 0000000..52aded4
--- /dev/null
+++ b/src/timeline/render.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
+ */
+?>
+<p <?php echo get_block_wrapper_attributes(); ?>>
+ <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
+</p>
diff --git a/src/timeline/style.scss b/src/timeline/style.scss
new file mode 100644
index 0000000..2f6ac8d
--- /dev/null
+++ b/src/timeline/style.scss
@@ -0,0 +1,135 @@
+main {
+ --gap: 0;
+ section:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+#at-a-glance {
+ max-width: var(--alignWide);
+ --gap: 0;
+ img {
+ width: 100%;
+ height: auto;
+ border: 2px solid var(--action-0);
+ }
+ h3 {
+ font-size: var(--small);
+ }
+ .before {
+ img {
+ border-right-width: 1px;
+ border-left: 0;
+ border-top: 0;
+ }
+ }
+ .after {
+ img {
+ border-left-width: 1px;
+ border-right: 0;
+ border-bottom: 0;
+ }
+ }
+}
+
+.timeline-point.timeline-point {
+ --lineWidth: 1px;
+ --gap: 2rem;
+ padding: 0;
+ margin:0;
+ background-color: var(--base);
+ max-width: 100vw;
+ position: relative;
+ overflow: hidden;
+ .open-gallery {
+ width: 40%;
+ border-radius: 4px;
+ position: sticky;
+ padding: .5rem;
+ }
+ .info {
+ padding: 1rem .5rem .5rem;
+ width: 60%;
+ position: relative;
+ h2 {
+ margin: 0 0 .5rem;
+ font-size: var(--medium);
+ position: relative;
+ .icon {
+ --w: 2.5rem;
+ transform: rotate(-90deg);
+ position: absolute;
+ left: -2.5rem;
+ top: .25rem;
+ background-color: var(--action-100);
+ }
+ }
+
+ }
+ &::before,
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ left: 45%;
+ height: 100%;
+ width: var(--lineWidth);
+ background-color: var(--action-0);
+ //box-shadow: var(--action-shadow);
+ }
+ &::before {
+ height: 1rem;
+ }
+ &::after {
+ top: 4rem;
+ }
+ &#before-treatment::before,
+ &:last-of-type::after {
+ display: none;
+ }
+}
+@media (min-width:768px) {
+ #at-a-glance {
+ h3 {
+ font-size: var(--xlarge);
+ }
+ }
+ .timeline-point.timeline-point {
+ --gap: 4rem;
+ .open-gallery {
+ width: 50%;
+ }
+ .info {
+ width: 50%;
+ padding: 25vh 1rem 1rem;
+ h2 {
+ .icon {
+ --w: 4rem;
+ left: -4.15rem;
+ top: 0;
+ }
+ }
+ a {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ }
+
+ time {
+ text-transform: uppercase;
+ font-size: var(--small);
+ }
+ }
+ &::before,
+ &::after {
+ left: calc(50% + 2rem);
+ }
+
+ &::before {
+ height: calc(25vh - 2rem);
+ }
+ &::after {
+ top: calc(25vh + 6rem);
+ }
+ }
+}
diff --git a/src/timeline/view.js b/src/timeline/view.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/timeline/view.js
diff --git a/webpack.jvb.js b/webpack.jvb.js
index df580d7..e2efb38 100644
--- a/webpack.jvb.js
+++ b/webpack.jvb.js
@@ -17,7 +17,8 @@
'form': './assets/js/concise/FormController.js',
'favourites': './assets/js/concise/FrontendFavourites.js',
'votes': './assets/js/concise/FrontendVotes.js',
- 'gallery': './assets/js/Gallery.js',
+ 'gallery': './assets/js/concise/Gallery.js',
+ 'swiper': './assets/js/concise/Swiper.js',
'maps': './assets/js/dash/GoogleMaps.js',
'handleSelection': './assets/js/concise/HandleSelection.js',
'integrations': './assets/js/dash/Integrations.js',
--
Gitblit v1.10.0