From 58e8ae0759ccfa97c478ccae4e0778bdce70966f Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 22 Jan 2026 22:40:02 +0000
Subject: [PATCH] =DirectoryManager.php updates, some javascript tweaks for CRUD.js, and minor style changes
---
inc/ui/index.php | 3
inc/blocks/SummaryBlock.php | 4
jvb.php | 4
inc/rest/routes/NotificationsRoutes.php | 24
inc/helpers/ui.php | 1
inc/forms/TaxonomySelector.php | 13
inc/rest/routes/TermRoutes.php | 76
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 27
inc/rest/routes/ContentRoutes.php | 10
inc/managers/CRUDManager.php | 4
assets/js/concise/UtilityFunctions.js | 26
inc/rest/routes/FeedRoutes.php | 6
assets/js/min/index.php | 3
build/timeline/style-index-rtl.css | 2
assets/js/min/utility.min.js | 2
src/timeline/style.scss | 2
assets/js/concise/CRUD.js | 109 +
inc/managers/queue/index.php | 3
JVBase.php | 1
inc/blocks/TimelineBlock.php | 78 +
inc/managers/queue/executors/ContentExecutor.php | 452 +++++----
inc/managers/EmailManager.php | 24
inc/ui/CRUDSkeleton.php | 3
inc/managers/queue/executors/index.php | 3
src/feed/style.scss | 29
assets/js/admin/index.php | 3
build/feed/style-index-rtl.css | 2
src/forms/view.js | 14
assets/js/min/selector.min.js | 2
inc/managers/SEO/index.php | 3
assets/js/concise/FormController.js | 434 ++++++++-
build/feed/style-index.css | 2
inc/managers/DirectoryManager.php | 497 ++++++++--
inc/registry/CheckCustomTables.php | 4
inc/rest/routes/FormRoutes.php | 170 +++
assets/js/concise/UploadManager.js | 137 ++-
inc/blocks/FeedBlock.php | 5
inc/managers/SEO/BreadcrumbManager.php | 6
src/feed/view.js | 12
inc/registry/PostTypeRegistrar.php | 1
src/index.php | 3
assets/js/min/crud.min.js | 2
base/index.php | 3
assets/js/concise/PopulateForm.js | 52 +
inc/helpers/renderFields.php | 11
assets/css/forms.min.css | 2
assets/js/concise/TaxonomyCreator.js | 29
build/forms/view.asset.php | 2
assets/css/admin/index.php | 3
build/timeline/style-index.css | 2
inc/meta/MetaForm.php | 35
inc/rest/routes/UploadRoutes.php | 39
assets/js/min/populate.min.js | 2
inc/integrations/GoogleMyBusiness.php | 4
inc/rest/routes/QueueRoutes.php | 4
assets/css/nav.min.css | 2
inc/managers/queue/executors/UploadExecutor.php | 1
assets/js/concise/TaxonomySelector.js | 63 +
assets/js/min/uploader.min.js | 2
inc/managers/ScriptLoader.php | 2
inc/utility/Image.php | 5
assets/js/min/creator.min.js | 2
build/feed/view.asset.php | 2
assets/js/concise/DataStore.js | 35
inc/meta/MetaSanitizer.php | 2
/dev/null | 0
assets/js/min/dataStore.min.js | 2
build/feed/view.js | 2
inc/rest/RestRouteManager.php | 18
inc/rest/routes/LoginRoutes.php | 1
build/forms/view.js | 2
inc/blocks/FormBlock.php | 2
inc/importers/index.php | 3
74 files changed, 1,810 insertions(+), 732 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index c7a20d9..d2b0ad9 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -142,7 +142,6 @@
$this->managers['directory'] = new DirectoryManager();
}
-
if (jvbSiteHasDashboard()) {
$this->routes['error'] = new ErrorRoutes();
$this->routes['admin'] = new AdminRoutes();
diff --git a/assets/css/admin/index.php b/assets/css/admin/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/assets/css/admin/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/assets/css/forms.min.css b/assets/css/forms.min.css
index a0dac7b..388b49e 100644
--- a/assets/css/forms.min.css
+++ b/assets/css/forms.min.css
@@ -1 +1 @@
-input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}
\ No newline at end of file
+input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}.pendingChanges{position:fixed;bottom:var(--btn);right:var(--btn_);margin-right:1rem;padding:1rem;border-radius:var(--radius);background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-6);width:50vw;animation:fadeInSlideUp .5s ease-out forwards}.pendingChanges button{min-height:0;width:calc(50% - .7rem);padding:.35rem}@keyframes fadeInSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
\ No newline at end of file
diff --git a/assets/css/nav.min.css b/assets/css/nav.min.css
index a11598a..f063fae 100644
--- a/assets/css/nav.min.css
+++ b/assets/css/nav.min.css
@@ -1 +1 @@
-nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;font-family:var(--heading);padding:0;margin:0}nav li{display:flex;align-items:center;height:max(var(--btn),max-content);width:100%;max-inline-size:none;padding:0}nav a,nav button{display:flex;text-decoration:none;align-items:center;justify-content:center;height:var(--btn);width:100%;white-space:nowrap;text-transform:uppercase;transition:var(--trans-color)}nav a{height:var(--btn);padding:var(--padding)}nav button{justify-content:center;aspect-ratio:1;padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon{transform:rotate(0);transition:transform var(--trans-base)}.has-submenu.open>button .icon{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max(100%,max-content);background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:all var(--trans-t) var(--trans-fn);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:2px solid var(--action-0);outline-offset:2px}nav.always{--dir:column;--wrap:nowrap;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{--justify:flex-end;width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px)}nav.always>ul{--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;position:relative;right:-300vw;width:100vw;max-height:100%;padding:1rem 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{flex-wrap:wrap;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;max-width:calc(100% - var(--btn));text-align:center}nav.always .has-submenu{display:flex}nav.always .has-submenu>a{flex:1}nav.always .has-submenu>button{flex:0 0 var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;height:max-content;top:0;width:100%;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>button{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>button .icon-list,nav.always>button .icon-x{display:none}nav.always.open>button .icon-x,nav.always>button .icon-list{display:block;width:32px;height:32px}@media (min-width:768px){nav.always>ul{padding-top:var(--btn)}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-7)}#breadcrumbs ol{height:max-content}#breadcrumbs li{width:max-content}#breadcrumbs a{height:var(--chip)}#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}#breadcrumbs li:last-of-type::after{display:none}#breadcrumbs :is(a,span){padding:0 .125rem;color:var(--contrast);text-transform:none}#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed.bottom{position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed.bottom li{flex:1;justify-content:center}nav.fixed.bottom a{gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed.bottom a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));max-width:none;padding:0 .5rem;background-color:rgba(var(--base-rgb),var(--op-4));color:var(--base-200);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-6)}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}.on-this-page ul{width:100%}.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}nav.letters li{height:var(--chip);max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}nav.letters,nav.letters ul{height:var(--chipchip)}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:flex-start;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}.index ul{width:max-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--trans-base)}.index li.active,.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;align-items:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}.index.open ul{--dir:column;--justify:flex-end;height:100%;width:100%}.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}.index.open a{justify-content:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed{height:max-content;--wrap:wrap;--gap:0 .25rem}nav.condensed ul{min-height:var(--chip_);height:max-content;--justify:center;--wrap:wrap}.condensed li{width:max-content;min-height:var(--chip)}.condensed li+li::before{content:'·';padding:0 .25em}.condensed a{height:max-content;min-height:var(--chip);font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}.condensed a:focus{border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials,.always ul.socials a,.always ul.socials li{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{position:fixed;bottom:var(--btn);left:var(--btnbtn);right:var(--btnbtn);padding-bottom:2px;z-index:var(--z-6);touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button{aspect-ratio:unset}nav.tabs button.active{cursor:default}nav.tabs button.active:hover{background-color:var(--base-100);color:var(--contrast)}nav.tabs button h2{--wrap:nowrap;margin:0;font-size:var(--txt-x-small)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content.active{padding:1rem 0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}:where(body>header,.wp-site-blocks>header){--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}nav.term-navigation:has([hidden]){display:none}.dashboard-nav{--justify:flex-start;width:100%}nav.filters{--dir:row;--justify:flex-start;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}
\ No newline at end of file
+nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;font-family:var(--heading);padding:0;margin:0}nav li{display:flex;align-items:center;height:max(var(--btn),max-content);width:100%;max-inline-size:none;padding:0}nav a,nav button{display:flex;text-decoration:none;align-items:center;justify-content:center;height:var(--btn);width:100%;white-space:nowrap;text-transform:uppercase;transition:var(--trans-color)}nav a{height:var(--btn);padding:var(--padding)}nav button{justify-content:center;aspect-ratio:1;padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon{transform:rotate(0);transition:transform var(--trans-base)}.has-submenu.open>button .icon{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max(100%,max-content);background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:all var(--trans-t) var(--trans-fn);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:2px solid var(--action-0);outline-offset:2px}nav.always{--dir:column;--wrap:nowrap;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{--justify:flex-end;width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px)}nav.always>ul{--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;position:relative;right:-300vw;width:100vw;max-height:100%;padding:1rem 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{flex-wrap:wrap;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;max-width:calc(100% - var(--btn));text-align:center}nav.always .has-submenu{display:flex}nav.always .has-submenu>a{flex:1}nav.always .has-submenu>button{flex:0 0 var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;height:max-content;top:0;width:100%;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>button{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>button .icon-list,nav.always>button .icon-x{display:none}nav.always.open>button .icon-x,nav.always>button .icon-list{display:block;width:32px;height:32px}@media (min-width:768px){nav.always>ul{padding-top:var(--btn)}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-7)}#breadcrumbs ol{height:max-content}#breadcrumbs li{width:max-content}#breadcrumbs a{height:var(--chip)}#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}#breadcrumbs li:last-of-type::after{display:none}#breadcrumbs :is(a,span){padding:0 .125rem;color:var(--contrast);text-transform:none}#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed.bottom{position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed.bottom li{flex:1;justify-content:center}nav.fixed.bottom a{gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed.bottom a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));max-width:none;padding:0 .5rem;background-color:rgba(var(--base-rgb),var(--op-4));color:var(--base-200);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-6)}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}body:has(.additional-actionsbutton) nav.on-this-page{width:calc(100vw - var(--btn_) - 1rem)}.on-this-page ul{width:100%;gap:0}.on-this-page li{justify-content:center}.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}.on-this-page a{height:var(--chip);padding:0}nav.letters li{height:var(--chip);max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}nav.letters,nav.letters ul{height:var(--chipchip)}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:flex-start;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}.index ul{width:max-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--trans-base)}.index li.active,.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;align-items:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}.index.open ul{--dir:column;--justify:flex-end;height:100%;width:100%}.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}.index.open a{justify-content:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed{height:max-content;--wrap:wrap;--gap:0 .25rem}nav.condensed ul{min-height:var(--chip_);height:max-content;--justify:center;--wrap:wrap}.condensed li{width:max-content;min-height:var(--chip)}.condensed li+li::before{content:'·';padding:0 .25em}.condensed a{height:max-content;min-height:var(--chip);font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}.condensed a:focus{border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials,.always ul.socials a,.always ul.socials li{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{position:fixed;bottom:var(--btn);left:var(--btnbtn);right:var(--btnbtn);padding-bottom:2px;z-index:var(--z-6);touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button{aspect-ratio:unset}nav.tabs button.active{cursor:default}nav.tabs button.active:hover{background-color:var(--base-100);color:var(--contrast)}nav.tabs button h2{--wrap:nowrap;margin:0;font-size:var(--txt-x-small)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content.active{padding:1rem 0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}:where(body>header,.wp-site-blocks>header){--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}nav.term-navigation:has([hidden]){display:none}.dashboard-nav{--justify:flex-start;width:100%}nav.filters{--dir:row;--justify:flex-start;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}
\ No newline at end of file
diff --git a/assets/js/admin/index.php b/assets/js/admin/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/assets/js/admin/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index e3adbea..6eee959 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -362,7 +362,7 @@
this.forms.clearForm(formId);
}
- this.ui.modals[name].form.reset();
+ this.resetForm(this.ui.modals[name].form);
if (name === 'date') {
this.handleCustomDateSelection()
@@ -626,7 +626,9 @@
} else if (modal.classList.contains('create')) {
title = `Creating your new ${this.singular}`;
}
- this.savePosts(title,false);
+ this.cancelBackup();
+ this.handleBackup().then(()=>{});
+ this.savePosts(title,false).then(()=>{});
}
handleChange(e) {
// Early bailout - target must be in an item or be a filter
@@ -706,11 +708,8 @@
handleBulkTaxonomy(result) {
if (!result.termIds.length || !this.selected.size) return;
- const changes = {};
- const taxonomyField = `tax_${result.taxonomy}`;
-
this.selected.forEach(itemID => {
- const item = this.store.get(parseInt(itemID));
+ const item = this.store.get(itemID);
if (!item) return;
// Merge existing terms with new ones
@@ -718,18 +717,10 @@
const existingIds = existingTerms.map(t => t.id);
const newIds = [...new Set([...existingIds, ...result.termIds])];
- changes[itemID] = {
- [taxonomyField]: newIds.join(','),
- content: this.content
- };
+ this.updateItem(itemID, result.taxonomy, newIds);
});
- if (Object.keys(changes).length > 0) {
- this.savePosts(
- `Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,
- false
- ).then(()=>{});
- }
+ this.savePosts(`Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,).then(()=> {});
this.selectionHandler.clearSelection();
}
@@ -759,13 +750,20 @@
`changes-${this.content}`,
async () => {
if (this.changes.size > 0) {
- await this.changesStore.saveMany(this.changes);
- this.changes.clear();
+ await this.handleBackup();
}
},
2000
);
}
+
+ cancelBackup() {
+ window.debouncer.cancel(`changes-${this.content}`);
+ }
+ async handleBackup() {
+ await this.changesStore.saveMany(this.changes);
+ this.changes.clear();
+ }
handleFilterChange(target) {
let filter = target.dataset.filter;
@@ -875,7 +873,6 @@
}
}
openCreateModal(){
- this.ui.modals.create.form.reset();
this.forms.registerForm(this.ui.modals.create.form,{
cache: false,
});
@@ -899,9 +896,18 @@
}
break;
case 'trash':
- this.updateItem(itemID, 'post_status', 'trash');
- window.fade(button.closest('.item'), false);
- this.savePosts(`Sending ${this.singular} to trash...`).then(()=>{});
+ if (this.status === 'trash') {
+ if (confirm('Delete this item? This cannot be undone')) {
+ this.updateItem(itemID, 'post_status', 'delete');
+ window.fade(button.closest('.item'), false);
+ this.savePosts(`Permanently deleting ${this.singular}...`).then(()=>{});
+ this.store.delete(itemID);
+ }
+ } else {
+ this.updateItem(itemID, 'post_status', 'trash');
+ window.fade(button.closest('.item'), false);
+ this.savePosts(`Sending ${this.singular} to trash...`).then(()=>{});
+ }
break;
case 'bulk-edit':
if (this.selected.size > 0) {
@@ -921,7 +927,7 @@
}
}
handleBulkDelete() {
- let isTrash = this.store.filters.status === 'trash';
+ let isTrash = this.status === 'trash';
if (this.selected.size > 0 && confirm(`${isTrash ? 'Permanently delete' : 'Send'} ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural}${isTrash ? '' : 'to trash'}?`)) {
this.selected.forEach(id => {
this.store.delete(id);
@@ -1082,8 +1088,6 @@
this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`;
this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`;
- this.ui.modals.edit.form.reset();
-
this.forms.registerForm(this.ui.modals.edit.form, {cache: false});
this.isPopulating = true;
@@ -1118,9 +1122,9 @@
this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
- // this.isPopulating = true;
- // this.populate.populate(this.ui.modals.edit.form, item);
- // this.isPopulating = false;
+ this.isPopulating = true;
+ this.populate.populate(this.ui.modals.edit.form, item);
+ this.isPopulating = false;
}
/*****************************************************************
@@ -1128,9 +1132,12 @@
*****************************************************************/
async savePosts(title = '', delay = false) {
- const memoryChanges = Array.from(this.changes.values());
- const storedChanges = await this.changesStore.getAll();
- const changes = window.deepMerge(storedChanges, memoryChanges);
+ if (this.changes.size > 0) {
+ this.cancelBackup();
+ await this.handleBackup();
+ }
+ const changes = await this.changesStore.getAll();
+
if (changes.length === 0) return;
if (title === '') {
@@ -1221,11 +1228,13 @@
updateUI() {
if (this.ui.bulk.action) {
let options = false;
- let hasEdit =this.ui.bulk.action.querySelector('[value="edit"]');
- if (this.status === 'trash' && hasEdit) {
+ let hasEdit = this.ui.bulk.action.querySelector('[value="edit"]');
+ let currentStatus = this.status;
+
+ if (currentStatus === 'trash' && hasEdit) {
window.removeChildren(this.ui.bulk.action);
options = window.jvbTemplates.create('trashOptions');
- } else if (this.status !== 'trash' && !hasEdit) {
+ } else if (currentStatus !== 'trash' && !hasEdit) {
window.removeChildren(this.ui.bulk.action);
options = window.jvbTemplates.create('notTrashOptions');
}
@@ -1387,9 +1396,13 @@
setFilter(name, value) {
if (!this.allowedFilters.includes(name)) return;
this.cache.set(name, value);
+
+ if (name === 'status') this.status = value;
+ if (name === 'orderby') this.orderby = value;
+ if (name === 'order') this.order = value;
+
let el = this.findFilterEl(name, value);
this.setElValue(el, value);
- //TODO: If we set the element to checked, does that automatically call the change listener, which then also sets the store filter and cache?
this.store.setFilter(name, value);
}
@@ -1461,6 +1474,32 @@
/***************************************************************
CLEANUP
***************************************************************/
+ resetForm(form) {
+ // Clear text inputs, textareas
+ form.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach(input => {
+ input.value = '';
+ });
+
+ // Uncheck checkboxes and radios
+ form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
+ input.checked = false;
+ });
+
+ // Reset selects to first option
+ form.querySelectorAll('select').forEach(select => {
+ select.selectedIndex = 0;
+ });
+
+ // Clear any selected items displays
+ form.querySelectorAll('.selected-items').forEach(container => {
+ window.removeChildren(container);
+ });
+
+ // Clear upload previews
+ form.querySelectorAll('.item-grid.preview').forEach(grid => {
+ window.removeChildren(grid);
+ });
+ }
destroy() {
window.debouncer.cancel(`changes-${this.content}`);
if (this.changes.size > 0) {
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 3610c26..58ec874 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -45,7 +45,7 @@
* @param {object|array} configs An object defining the store, or an array of objects defining the stores
* @param {number} version the database version
*/
- register(name, configs = [], version = 1.1) {
+ register(name, configs = [], version = 1.2) {
if (!Array.isArray(configs)) configs = [configs];
if (configs.length === 0) return;
@@ -471,13 +471,16 @@
let result;
tx.oncomplete = () => resolve(result);
- tx.onerror = () => reject(tx.error);
+ tx.onerror = () => {
+ const error = tx.error || new Error('Transaction failed with unknown error');
+ reject(error);
+ };
// Call callback immediately to queue operations
try {
result = callback(objectStore, tx);
} catch (error) {
- reject(error);
+ reject(error || new Error('Callback failed with unknown error'));
}
});
}
@@ -589,11 +592,13 @@
return data;
} catch (error) {
- if (error.name !== 'AbortError') {
+ const isAbortError = error?.name === 'AbortError';
+
+ if (!isAbortError) {
console.error(`Fetch error for store "${name}":`, error);
this.notify(name, 'fetch-error', { error });
+ throw error;
}
- throw error;
} finally {
store.isFetching = false;
@@ -628,8 +633,8 @@
*/
async processFetchedData(name, data, cacheKey, response) {
const store = this.stores.get(name);
- const items = data.items || [];
- const changes = []; // Track all changes
+ const items = (data.items || []).filter(item => item && typeof item === 'object');
+ const changes = [];
// Batch process with single transaction
if (store.db && items.length > 0) {
@@ -829,7 +834,15 @@
}
processForStorage(obj, validate = true, path = 'root') {
- if (obj === null || obj === undefined) return { valid: true, data: obj };
+ if (obj === null) {
+ return { valid: true, data: null };
+ }
+ if (obj === undefined) {
+ if (validate) {
+ return { valid: false, error: `Undefined value at ${path}` };
+ }
+ return { valid: true, data: undefined };
+ }
const type = typeof obj;
@@ -890,7 +903,10 @@
for (const [key, value] of Object.entries(obj)) {
const result = this.processForStorage(value, validate, `${path}.${key}`);
if (!result.valid) return result;
- if (result.data !== undefined) processed[key] = result.data;
+ // Include null values, skip undefined
+ if (result.data !== undefined || value === null) {
+ processed[key] = result.data;
+ }
}
return { valid: true, data: processed };
}
@@ -1005,6 +1021,7 @@
if (!store) return [];
return Array.from(store.data.values()).filter(item => {
+ if (!item || typeof item !== 'object') return false;
return Object.entries(criteria).every(([key, value]) => {
const accepted = Array.isArray(value) ? value : [value];
return accepted.includes(item[key]);
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 894e58b..9a3f632 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -25,6 +25,7 @@
}
init() {
this.templates = window.jvbTemplates;
+ this.defineSummaryTemplate();
this.initElements();
this.initListeners();
this.initStore();
@@ -137,7 +138,7 @@
this.store = store.forms;
this.store.subscribe((event, data)=> {
- if (event === 'data-loaded') {
+ if (event === 'data-ready') {
let stored = this.store.getFiltered();
let pending = stored.filter(form=> form.src === window.location.pathname);
@@ -146,7 +147,7 @@
}
} else if (event === 'operation-status' && data.status === 'completed') {
if (data.config) {
- this.store.remove(data.config.id);
+ this.store.delete(data.config.id);
}
}
});
@@ -164,15 +165,16 @@
notification.className = 'pendingChanges';
notification.innerHTML = `
<p>We noticed unsaved changes from last time. Would you like to restore them?</p>
- <button class="restore" data-form-id="${formId}">Restore</button>
- <button class="discard" data-form-id="${formId}">Discard</button>`;
+ <button class="restore" type="button" data-form-id="${formId}">Restore</button>
+ <button class="discard" type="button" data-form-id="${formId}">Discard</button>`;
element.insertBefore(notification, form.ui.status.status);
notification.querySelector('.restore').addEventListener('click', async () => {
this.isRestoring = true;
- new this.populate(element, changes);
+ let theChanges = {['fields']: changes};
+ this.populate.populate(element, theChanges);
this.a11y.announce('Previous changes restored');
this.isRestoring = false;
@@ -180,8 +182,8 @@
});
notification.querySelector('.discard').addEventListener('click', async () => {
- await this.store.remove(formId);
- this.a11y.announce('Previous changes discared');
+ await this.store.delete(formId);
+ this.a11y.announce('Previous changes discarded');
notification.remove();
});
@@ -240,14 +242,26 @@
}
performValidation(input) {
const field = input.closest('.field');
- const value = this.getFieldValue(input);
+ const value = this.getFieldCheckedValue(input);
if (!value && !input.required) {
return { isValid: true, message: '' };
}
- if (input.required && !value) {
- return { isValid: false, message: 'This field is required' };
+ if (input.required) {
+ if (input.type === 'checkbox') {
+ if (!input.checked) {
+ return { isValid: false, message: 'This field is required' };
+ }
+ } else if (input.type === 'radio') {
+ const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`);
+ const anyChecked = Array.from(radioGroup).some(r => r.checked);
+ if (!anyChecked) {
+ return { isValid: false, message: 'Please select an option' };
+ }
+ } else if (!value) {
+ return { isValid: false, message: 'This field is required' };
+ }
}
if(input.checkValidity && !input.checkValidity()){
@@ -264,11 +278,11 @@
if (Object.hasOwn(field.dataset, 'validate') || input.type) {
const validator = this.validators[field.dataset.validate||input.type];
- if (validator.pattern && !validator.pattern.test(value)) {
+ if (validator && validator.pattern && !validator.pattern.test(value)) {
return {isValid: false, message: validator.message};
}
- if (validator.test) {
+ if (validator && validator.test) {
const result = validator.test(value, field);
if (result !== true) {
return {isValid: false, message: result};
@@ -338,15 +352,21 @@
handleInput(e){
let form = this.getForm(e.target);
- if (!form || !form.options.cache) return;
+ if (!form) return;
let field = this.getField(e.target);
if (!field) return;
- this.showFormStatus(form, 'pending');
+ const input = e.target; // Capture reference
+ const fieldName = field.dataset.field;
+
+ // Show pending status regardless of cache
+ this.showFormStatus(form.id, 'pending');
+
+ // Debounce validation
window.debouncer.schedule(
- `form:${form.id}:validate:${field.dataset.field}`,
- () => this.validateField.bind(this),
+ `form:${form.id}:validate:${fieldName}`,
+ () => this.validateField(input),
500
);
}
@@ -357,6 +377,9 @@
if (this.subscribers.size > 0) {
e.preventDefault();
+ console.log('Cancelling scheduled backup and manually backing up');
+ this.cancelBackup();
+ await this.backup();
const storedData = await this.store.get(form.id);
if (form.options.cache) {
@@ -375,10 +398,7 @@
if (form.options.showSummary) {
const storedData = await this.store.get(form.id);
- this.showSummary(form.id, {
- config: form,
- data: storedData?.changes || {}
- });
+ this.showSummary({config: form, changes: storedData?.changes});
}
}
@@ -405,21 +425,50 @@
}
}
- scheduleBackup() {
+ scheduleBackup() {
window.debouncer.schedule(
`form_changes`,
async () => {
if (this.changes.size > 0) {
- await this.store.saveMany(this.changes);
- for(let formId of this.changes.keys()) {
- this.showFormStatus(formId, 'autosaved');
- }
- this.changes.clear();
+ await this.backup();
}
},
2000
);
}
+ cancelBackup() {
+ window.debouncer.cancel('form_changes');
+ }
+ async backup() {
+ // Merge with existing stored data
+ const toSave = new Map();
+
+ for (let [formId, newData] of this.changes.entries()) {
+ const stored = await this.store.get(formId);
+
+ if (stored) {
+ // Merge changes
+ toSave.set(formId, {
+ ...stored,
+ ...newData,
+ changes: {
+ ...stored.changes,
+ ...newData.changes
+ },
+ timestamp: Date.now()
+ });
+ } else {
+ toSave.set(formId, newData);
+ }
+ }
+
+ await this.store.saveMany(toSave);
+
+ for (let formId of this.changes.keys()) {
+ this.showFormStatus(formId, 'autosaved');
+ }
+ this.changes.clear();
+ }
saveCache(formId) {
if (!this.changes.has(formId)) return;
@@ -451,20 +500,18 @@
id: formId,
status: '',
options: {
- autoUpload: false,
+ autoUpload: options.autoUpload??false,
+ imageMeta: options.imageMeta??true,
delay: options.delay??1500,
endpoint: options.save??form.dataset.save??'',
- formStatus: options.showStatus??true,
- showSummary: false,
+ showStatus: options.showStatus??true,
+ showSummary: options.showSummary??false,
cache: options.cache??true,
+ ignore: options.ignore??[]
},
ui: window.uiFromSelectors(this.selectors.forms, form)
};
- if (config.showSummary && !this.summaryTemplate) {
- this.defineSummaryTemplate();
- }
-
this.initializeFields(form, config);
this.forms.set(formId, config);
@@ -581,12 +628,12 @@
p: 'p',
},
setup({ el, refs, manyRefs, data }) {
- const skipFields = ['sendAll', ...form.ignore];
+ const skipFields = ['sendAll', ...data.config.options.ignore??[]];
for (let [key, value] of Object.entries(data.changes)) {
- if (skipFields.includes(key) || this.isEmptyValue(value)) continue;
+ if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
- let input = Array.from(this.inputs.values())
+ let input = Array.from(form.inputs.values())
.find(temp => temp.field?.dataset.field === key);
if (!input) continue;
@@ -594,15 +641,21 @@
let title = entry.querySelector('h3');
let p = entry.querySelector('p');
- title.textContent = input.label.textContent;
+ // Get field label - prioritize legend for fieldsets, then label
+ const legend = input.field?.querySelector('legend');
+ title.textContent = legend
+ ? legend.textContent.replace('*', '').trim()
+ : input.ui.label?.textContent.replace('*', '').trim();
- if (typeof value === 'string') {
- p.textContent = value;
- } else if (Array.isArray(value)) {
- //Repeater or Tag Item
- } else if (typeof value === 'object') {
- //Location item
- p.textContent = `${value.address}`;
+
+ const formattedValue = form.formatValueForSummary(value, input);
+
+ if (formattedValue instanceof HTMLElement) {
+ // If it's an HTML element (repeater, tag-list, etc.), replace <p>
+ p.replaceWith(formattedValue);
+ } else {
+ // If it's a string, set text content
+ p.textContent = formattedValue;
}
el.append(entry);
@@ -612,6 +665,7 @@
uploads.forEach(upload => {
let label = upload.querySelector('h2')?.textContent??'Upload:';
let imgs = upload.querySelectorAll('.item-grid.preview img');
+ let field = refs.result.cloneNode(true);
if (imgs) {
let entry = refs.result.cloneNode(true);
let title = field.querySelector('h3');
@@ -634,6 +688,8 @@
}
);
}
+
+
initializeFields(container, config = null) {
const fieldHandlers = {
'[data-editor]': () => this.checkForQuill(container,config),
@@ -642,7 +698,7 @@
'.field.tag-list': () => this.checkForTagLists(container),
'[data-depends-on]': () => this.checkForConditionalFields(container),
'[data-limit]': () => this.checkForCharacterLimits(container),
- '[data-uploader]': () => this.checkForImageUploads(container, config),
+ '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
'nav.tabs': () => this.checkForTabs(container, config),
'[data-type="selector"]': () => this.checkForSelectors(container)
};
@@ -979,7 +1035,7 @@
const controlField = this.dependencies.get(controlFieldName);
if (!controlField) return;
- const controlValue = this.getFieldValue(controlField.element);
+ const controlValue = this.getFieldCheckedValue(controlField.element);
const shouldShow = this.evaluateCondition(
controlValue,
dependentField.requiredValue,
@@ -1061,7 +1117,7 @@
}
}
checkForImageUploads(form, config) {
- window.jvbUploads.scanFields(form, config.autoUpload);
+ window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
}
checkForTabs(form, config) {
@@ -1269,11 +1325,6 @@
if (window.jvbA11y) {
window.jvbA11y.announce(data.message || 'Form submitted successfully');
}
-
- // Trigger custom event
- form.dispatchEvent(new CustomEvent('jvb-form-success', {
- detail: data
- }));
}
handleFormError(form, data) {
@@ -1348,6 +1399,8 @@
if (!form || !form.options.showStatus || !form.ui?.status?.status) return;
if (form.status === status) return;
+
+ form.status = status;
form.ui.status.status.hidden = false;
form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status));
@@ -1386,7 +1439,9 @@
SUMMARY
**********************************************************************/
showSummary(data) {
- this.templates.create('formSummary', data);
+ let summary = this.templates.create('formSummary', data);
+ data.config.element.after(summary);
+ window.fade(data.config.element, false);
}
/**********************************************************************
UTILITY
@@ -1433,11 +1488,53 @@
case 'true-false':
return element.value === '1'||element.value === 'on'||element.value ==='true';
-
+ case 'checkbox':
+ // Handle multi-checkbox (name ends with [])
+ if (element.name.endsWith('[]')) {
+ return this.getCheckboxGroupValue(element, conf);
+ }
+ return element.checked ? element.value : '';
default:
return element.value;
}
}
+
+ /**
+ * Get all checked values for a checkbox group
+ */
+ getCheckboxGroupValue(element, conf) {
+ if (!conf.checkboxGroup) {
+ conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`);
+ this.saveItem(conf);
+ }
+
+ return Array.from(conf.checkboxGroup)
+ .filter(cb => cb.checked)
+ .map(cb => cb.value);
+ }
+ /**
+ * Get the actual user-facing value (for validation and submission)
+ */
+ getFieldCheckedValue(element) {
+ // Handle checkboxes and radios based on checked state
+ if (element.type === 'checkbox') {
+ const type = this.getFieldType(element);
+ if (type === 'true-false') {
+ return element.checked;
+ }
+ return element.checked ? element.value : '';
+ }
+
+ if (element.type === 'radio') {
+ const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`);
+ const checked = Array.from(radioGroup).find(r => r.checked);
+ return checked ? checked.value : '';
+ }
+
+ // For everything else, use existing logic
+ return this.getFieldValue(element);
+ }
+
isEmptyValue(value) {
if (value === null || value === undefined || value === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
@@ -1493,6 +1590,235 @@
return conf.value.value;
}
+ /**
+ * Format field value for display in summary
+ * @param {*} value - The field value
+ * @param {Object} input - The input config
+ * @returns {HTMLElement|string} - Formatted display element or string
+ */
+ formatValueForSummary(value, input) {
+ const fieldType = this.getFieldType(input.element);
+
+ // Handle empty values
+ if (this.isEmptyValue(value)) {
+ return '';
+ }
+
+ // Handle different field types
+ switch (fieldType) {
+ case 'repeater':
+ return this.formatRepeaterForSummary(value, input);
+
+ case 'tag-list':
+ return this.formatTagListForSummary(value, input);
+
+ case 'location':
+ return this.formatLocationForSummary(value);
+
+ case 'true-false':
+ return value ? 'Yes' : 'No';
+
+ case 'checkbox':
+ // Handle multi-checkbox arrays
+ if (Array.isArray(value)) {
+ return this.formatCheckboxGroupForSummary(value, input);
+ }
+ // Single checkbox - get display label
+ return this.getDisplayLabel(input, value);
+
+ case 'selector':
+ case 'upload':
+ // These might need special handling depending on your needs
+ return this.formatHiddenFieldForSummary(value, input, fieldType);
+
+ default:
+ // For radio/checkbox, get the display label
+ if (typeof value === 'string') {
+ return this.getDisplayLabel(input, value);
+ }
+ // For textarea or any multi-line text, convert line breaks
+ if (typeof value === 'string' && value.includes('\n')) {
+ return this.convertLineBreaks(value);
+ }
+ return value;
+ }
+ }
+
+ /**
+ * Format checkbox group values with labels
+ */
+ formatCheckboxGroupForSummary(values, input) {
+ const labels = values.map(value => this.getDisplayLabel(input, value));
+ return labels.join(', ');
+ }
+
+ /**
+ * Convert \n line breaks to HTML
+ */
+ convertLineBreaks(text) {
+ const container = document.createElement('span');
+ container.innerHTML = text.split('\n').join('<br>');
+ return container;
+ }
+
+ /**
+ * Format repeater data as a list
+ */
+ formatRepeaterForSummary(rows, input) {
+ const container = document.createElement('div');
+ container.className = 'summary-repeater';
+
+ rows.forEach((row, index) => {
+ const rowDiv = document.createElement('div');
+ rowDiv.className = 'summary-repeater-row';
+
+ const rowTitle = document.createElement('strong');
+ rowTitle.textContent = `Entry ${index + 1}:`;
+ rowDiv.appendChild(rowTitle);
+
+ const fieldsList = document.createElement('ul');
+ fieldsList.className = 'summary-repeater-fields';
+
+ for (const [fieldName, fieldValue] of Object.entries(row)) {
+ if (this.isEmptyValue(fieldValue)) continue;
+
+ const li = document.createElement('li');
+
+ // Try to find the label for this subfield
+ const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`);
+ const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName;
+
+ li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`;
+ fieldsList.appendChild(li);
+ }
+
+ rowDiv.appendChild(fieldsList);
+ container.appendChild(rowDiv);
+ });
+
+ return container;
+ }
+
+ /**
+ * Format tag-list data
+ */
+ formatTagListForSummary(tags, input) {
+ const container = document.createElement('div');
+ container.className = 'summary-taglist';
+
+ const tagsList = document.createElement('ul');
+ tagsList.className = 'summary-tags';
+
+ tags.forEach(tag => {
+ const li = document.createElement('li');
+ li.className = 'summary-tag';
+
+ // Get the primary display value (first non-empty field)
+ const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || '';
+
+ // If there are multiple fields, show them all
+ const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v));
+ if (fields.length > 1) {
+ li.textContent = fields.map(([k, v]) => v).join(', ');
+ } else {
+ li.textContent = displayValue;
+ }
+
+ tagsList.appendChild(li);
+ });
+
+ container.appendChild(tagsList);
+ return container;
+ }
+
+ /**
+ * Format location data
+ */
+ formatLocationForSummary(location) {
+ const parts = [];
+
+ if (location.street) parts.push(location.street);
+ if (location.city) parts.push(location.city);
+ if (location.province) parts.push(location.province);
+ if (location.postal_code) parts.push(location.postal_code);
+ if (location.country) parts.push(location.country);
+
+ return parts.length > 0 ? parts.join(', ') : location.address || '';
+ }
+
+ /**
+ * Format hidden field types (upload, selector)
+ */
+ formatHiddenFieldForSummary(value, input, fieldType) {
+ if (fieldType === 'upload') {
+ // Get upload preview images if available
+ const uploadField = input.field?.querySelector('[data-upload-field]');
+ if (uploadField) {
+ const previews = uploadField.querySelectorAll('.item-grid.preview img');
+ if (previews.length > 0) {
+ const container = document.createElement('div');
+ container.className = 'summary-uploads';
+ previews.forEach(img => {
+ const clone = img.cloneNode(true);
+ clone.style.maxWidth = '100px';
+ clone.style.maxHeight = '100px';
+ container.appendChild(clone);
+ });
+ return container;
+ }
+ }
+ return `${value.split(',').length} file(s) uploaded`;
+ }
+
+ if (fieldType === 'selector') {
+ // Could enhance this to show selected item names if available
+ return value;
+ }
+
+ return value;
+ }
+
+ /**
+ * Get the display label for an input value (especially for radio/checkbox)
+ * @param {Object} input - The input config from this.inputs
+ * @param {*} value - The field value
+ * @returns {string} - The display label or original value
+ */
+ getDisplayLabel(input, value) {
+ if (!input.element) return value;
+
+ const inputType = input.element.type;
+
+ // Handle radio buttons
+ if (inputType === 'radio') {
+ const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`);
+ const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value);
+ if (selectedRadio) {
+ const label = selectedRadio.closest('label') ||
+ input.field.querySelector(`label[for="${selectedRadio.id}"]`);
+ if (label) {
+ return label.textContent.replace('*', '').trim();
+ }
+ }
+ }
+
+ // Handle checkboxes (including groups)
+ if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') {
+ // Find checkbox with this value in the field
+ const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`);
+ if (checkbox) {
+ const label = checkbox.closest('label') ||
+ input.field.querySelector(`label[for="${checkbox.id}"]`);
+ if (label) {
+ // Get just the span content to avoid getting nested elements
+ const span = label.querySelector('span');
+ return span ? span.textContent.trim() : label.textContent.replace('*', '').trim();
+ }
+ }
+ }
+
+ return value;
+ }
getItem(element, formId = null) {
const hasID = Object.hasOwn(element.dataset, 'ref');
let id = (hasID) ? element.dataset.ref : window.generateID('input');
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 373a61c..16d3460 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -165,7 +165,34 @@
grid.append(this.templates.create('uploadItem', data));
});
}
+
+ this.populateUploadMeta(field, name, value);
}
+ populateUploadMeta(element, name, id) {
+ // Find the image_data field group
+ const imageDataField = element.querySelector('[data-field="image_data"]');
+ if (!imageDataField) return;
+ let data = this.data.images[id]??false;
+ if (!data) return;
+
+ // Set upload ID or attachment ID
+ imageDataField.dataset.attachmentId = data.id;
+ imageDataField.setAttribute('data-ignore', '');
+
+ // Populate the metadata fields
+ const meta = [
+ 'image-title',
+ 'image-alt-text',
+ 'image-caption'
+ ];
+
+ for (const m of meta) {
+ const input = imageDataField.querySelector(`[data-field="${m}"] input, [data-field="${m}"] textarea`);
+ if (input && data[m]!=='') {
+ input.value = data[m];
+ }
+ }
+ }
populateTimelineGallery(field,name, value) {
if (!value || !Array.isArray(value) || value.length === 0) return;
@@ -306,6 +333,10 @@
video: 'video',
file: '.select-item span',
img: 'img',
+ details: 'details[data-field]',
+ imgAlt: '[name="image-alt-text"]',
+ imgTitle: '[name="image-title"]',
+ imgDesc: '[name="image-caption"]',
},
manyRefs: {
fields: '.field',
@@ -326,10 +357,29 @@
refs.img.alt = imgData['image-alt-text']??'';
}
+ if (refs.details) {
+ let imgData = p.data.images[data.post_thumbnail];
+
+ refs.details.setAttribute('data-ignore', '');
+ refs.details.dataset.attachmentId = data.post_thumbnail;
+ if (Object.hasOwn(imgData, 'image-alt-text') && refs.alt) {
+ refs.alt.value = imgData['image-alt-text'];
+ }
+ if ((Object.hasOwn(imgData, 'image-title') || Object.hasOwn(data, 'file')) && refs.title) {
+ refs.title.value = imgData['image-title']||data.file.name;
+ }
+ if (Object.hasOwn(imgData, 'image-caption') && refs.description) {
+ refs.description.value = imgData['image-caption'];
+ }
+ }
+
if (manyRefs.fields) {
for (let field of manyRefs.fields) {
if (field.dataset.fieldType === 'group') continue;
-
+ if (field.dataset.field === 'post_thumbnail') {
+ field.remove();
+ continue;
+ }
let name = field.dataset.field;
let value = data[name]??'';
if (!p.isEmptyValue(value)) {
diff --git a/assets/js/concise/TaxonomyCreator.js b/assets/js/concise/TaxonomyCreator.js
index 11281e3..4a5d595 100644
--- a/assets/js/concise/TaxonomyCreator.js
+++ b/assets/js/concise/TaxonomyCreator.js
@@ -55,7 +55,6 @@
if (this.ui.form) {
this.ui.form.addEventListener('change', (e) => {
- console.log('Creator form change, prevents default');
e.preventDefault();
e.stopPropagation();
});
@@ -120,12 +119,32 @@
* Handle successful term creation
*/
async handleSuccessfulCreation(term, data) {
- // Close create form
- this.ui.details.open = false;
+ // Add term to store immediately - don't wait for fetch
+ const fullTerm = {
+ id: term.id,
+ name: term.name,
+ path: term.path || term.name,
+ slug: term.slug || term.name.toLowerCase().replace(/\s+/g, '-'),
+ parent: data.parent || 0,
+ taxonomy: data.taxonomy,
+ count: 0,
+ hasChildren: false
+ };
- // Clear cache to ensure fresh data
+ // Add to store data immediately so addSelected can find it
+ this.selector.store.data.set(term.id, fullTerm);
+
+ // Close create form
+ if (this.ui.details) {
+ this.ui.details.open = false;
+ }
+
+ // Clear cache and refetch in background for accuracy
this.selector.store.clearCache();
- await this.selector.store.fetch();
+ // Don't await - let it happen async
+ this.selector.store.fetch().catch(err => {
+ console.warn('Background fetch after term creation failed:', err);
+ });
}
/**
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index bdbe06b..2f58c05 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -44,7 +44,7 @@
indexes: [
{name: 'taxonomy', keyPath: 'taxonomy'},
{name: 'parent', keyPath: 'parent'},
- {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'slug', keyPath: 'slug'},
{name: 'count', keyPath: 'count'},
],
endpoint: 'terms',
@@ -96,14 +96,14 @@
if (refs.checkbox) {
refs.checkbox.dataset.id = data.id;
- refs.checkbox.id = `${field.element.id}-${data.id}`;
- refs.checkbox.name = `${field.element.id}-${field.taxonomy}-select`;
+ refs.checkbox.id = `${field.id}-${data.id}`;
+ refs.checkbox.name = `${field.id}-${field.taxonomy}-select`;
refs.checkbox.value = data.id;
refs.checkbox.disabled = !isSelected && limitReached;
refs.checkbox.checked = isSelected;
}
if (refs.label) {
- refs.label.htmlFor = `${field.element.id}-${data.id}`;
+ refs.label.htmlFor = `${field.id}-${data.id}`;
refs.label.title = data.path??data.name;
refs.label.dataset.path = data.path;
}
@@ -841,8 +841,10 @@
const field = this.fields.get(fieldId);
if (!field) return;
let selected = Array.from(this.selectedTerms.get(fieldId));
- field.ui.value.value = selected.join(',')??'';
- field.ui.value.dispatchEvent(new Event('change', { bubbles: true }));
+ if (field.ui.value) {
+ field.ui.value.value = selected.join(',')??'';
+ field.ui.value.dispatchEvent(new Event('change', { bubbles: true }));
+ }
}
checkLimits(fieldId) {
@@ -875,9 +877,9 @@
updateFieldsForTaxonomy(taxonomy) {
let fields = Array.from(this.fields.values())
- .filter(field => !field.checked && field.taxonomy === taxonomy);
+ .filter(field => field.taxonomy === taxonomy);
const hasItems = Array.from(this.store.data.values())
- .some(term=>term.taxonomy === taxonomy);
+ .some(term => term && term.taxonomy === taxonomy);
fields.forEach(field => {
if (!field.toggle) return;
@@ -938,11 +940,9 @@
showAutocompleteTerms() {
const field = this.currentField();
- const terms = this.currentTerms();
- if (!field) return;
-
+ if (!field || !field.hasAutocomplete || !field.ui.dropdown?.list) return;
const dropdown = field.ui.dropdown.list;
- if (!dropdown) return;
+ const terms = this.currentTerms();
window.removeChildren(dropdown);
if (terms.length === 0) {
@@ -1090,7 +1090,6 @@
handleDataLoaded() {
const taxonomy = this.store.filters.taxonomy;
- // Always update fields for loaded taxonomies (handles both single and batch)
if (taxonomy) {
const taxonomies = taxonomy.split(',').map(t => t.trim());
taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax));
@@ -1118,6 +1117,9 @@
filters
});
+ if (!this.activeField && isAutoComplete) {
+ return;
+ }
if (isAutoComplete) {
this.showAutocompleteTerms();
@@ -1143,10 +1145,8 @@
}
async batchFetchTaxonomies() {
if (this.batchFetch.size === 0) return;
-
const taxonomies = Array.from(this.batchFetch);
this.batchFetch.clear();
-
try {
await this.store.setFilters({
taxonomy: taxonomies.join(','),
@@ -1198,40 +1198,63 @@
if (!field) return;
window.debouncer.cancel(`${field.id}-search-results`);
+
let data = {
taxonomy: field.taxonomy,
parent: this.store.filters.parent??0
}
- //If it's autocomplete or the selector's search input, we just need the name
+
if (!this.container.open || this.ui.search.input.value !== '') {
data.name = (this.container.open) ? this.ui.search.input.value : field.ui.search.value;
} else {
- //Otherwise, we've created it from the details element
data.parent = this.creator.ui.parent.value??data.parent;
data.name = this.creator.ui.name.value??false;
}
+
if (data.parent !== undefined && data.name) {
this.setMessage(true, `Creating "${data.name}"...`);
this.setCreateButton(false);
+
if (this.container.open) {
window.removeChildren(this.ui.terms.list);
} else {
field.ui.search.disabled = true;
- window.removeChildren(field.ui.dropdown.list);
if (field.ui.dropdown.wrapper) {
field.ui.dropdown.wrapper.hidden = false;
}
}
+
let term = await this.creator.handleTermCreation(data);
+
if (term) {
+ // Stop any typeLoop animation and show success message WITHOUT typeLoop
+ this.setMessage(true, `"${term.name}" created!`, false);
+
this.addSelected(term.id, field.id);
+ this.updateFieldValue(field.id);
+ // For autocomplete, show the newly created term in dropdown
+ if (!this.container.open && field.ui.dropdown.list) {
+ window.removeChildren(field.ui.dropdown.list);
+ const termElement = this.createAutocompleteTerm(term);
+ if (termElement) {
+ termElement.classList.add('newly-created');
+ field.ui.dropdown.list.append(termElement);
+ }
+ }
+ this.scheduleHideDropdown(field.id, 300);
+ this.setMessage(false);
+ } else {
+ // Creation failed - hide immediately
+ this.setMessage(false);
+ if (!this.container.open && field.ui.dropdown.wrapper) {
+ field.ui.dropdown.wrapper.hidden = true;
+ }
}
+
if (!this.container.open) {
field.ui.search.disabled = false;
field.ui.search.value = '';
}
- this.scheduleHideDropdown(field.id);
- this.setMessage(false);
}
}
setMessage(show = true, message = '', type = true) {
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index f2e9188..e5f8643 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -19,6 +19,8 @@
this.selectionHandlers = new Map();
this.sortables = new Map();
+ this.changes = new Map();
+
this.previewUrls = new Set();
this.initElements();
this.initListeners();
@@ -37,9 +39,12 @@
video: 'video',
file: 'label > span',
details: 'details',
+ alt: '[name="image-alt-text"]',
+ title: '[name="image-title"]',
+ description: '[name="image-caption"]',
},
manyRefs: {
- inputs: 'input',
+ inputs: 'input, select, textarea',
},
setup({el, refs, manyRefs, data}) {
const isNewUpload = Object.hasOwn(data, 'file');
@@ -103,7 +108,29 @@
break;
}
if (refs.details) {
- refs.details.append(T.create('uploadMeta'));
+ if (Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) {
+ refs.details.remove();
+ } else {
+ if(Object.hasOwn(data, 'id')) {
+ refs.details.dataset.attachmentId = data.id;
+ } else if (Object.hasOwn(data, 'uploadId')) {
+ refs.details.dataset.uploadId = data.uploadId;
+ }
+ refs.details.setAttribute('data-ignore', '');
+
+
+ if (mimeType !== 'image' && refs.alt) {
+ refs.alt.closest('.field')?.remove();
+ } else if (Object.hasOwn(data, 'image-alt-text') && refs.alt) {
+ refs.alt.value = data['image-alt-text'];
+ }
+ if ((Object.hasOwn(data, 'title') || Object.hasOwn(data, 'file')) && refs.title) {
+ refs.title.value = data.title||data.file.name;
+ }
+ if (Object.hasOwn(data, 'image-caption') && refs.description) {
+ refs.description.value = data['image-caption'];
+ }
+ }
}
@@ -111,31 +138,12 @@
if (manyRefs.inputs) {
for (let input of manyRefs.inputs) {
- window.prefixInput(input, `${data.uploadId}-`);
+ window.prefixInput(input, `${data.id??data.uploadId}-`);
}
}
}
});
- T.define('uploadMeta', {
- refs: {
- alt: '[name="alt_text"]',
- title: '[name="image-title"]',
- description: '[name="image-caption"]',
- },
- setup({el, refs, manyRefs, data}) {
- if (Object.hasOwn(data, 'alt') && refs.alt) {
- refs.alt.value = data.alt;
- }
- if (Object.hasOwn(data, 'title') && refs.title) {
- refs.title.value = data.title;
- }
- if (Object.hasOwn(data, 'description') && refs.description) {
- refs.description.value = data.description;
- }
- }
- });
-
T.define('imageGroup', {
refs: {
selectAll: '[data-select-all]',
@@ -209,7 +217,7 @@
grid: '.item-grid'
},
async setup({el, refs, manyRefs, data}) {
- let fieldId = images.registerField(el, false, `recovery_${data.index}`);
+ let fieldId = images.registerField(el, false, false, `recovery_${data.index}`);
if (data.isCurrent) {
el.open = true;
@@ -417,6 +425,7 @@
};
const upload = { ...defaults, ...data };
+
Object.preventExtensions(upload);
await this.stores.uploads.save(upload);
return upload;
@@ -488,8 +497,15 @@
}
}
handleChange(e) {
+
let fieldId = this.getFieldIdFromElement(e.target);
- if (!fieldId) return;
+ if (!fieldId) {
+ let isMeta = e.target.closest('[data-upload-id], [data-attachment-id]');
+ if (isMeta) {
+ this.queueUploadMeta(e);
+ }
+ return;
+ }
if (e.target.matches(this.selectors.fields.input)) {
const files = Array.from(e.target.files);
@@ -505,12 +521,11 @@
}
let field = this.fields.get(fieldId);
- if (!field || !field.config.autoUpload) return;
if (field.config.destination === 'post_group') {
this.handleGroupMetaChange(e.target);
} else {
- this.queueUploadMeta(e).then(()=>{});
+ this.queueUploadMeta(e);
}
}
handleGroupMetaChange(input) {
@@ -799,38 +814,69 @@
return { uploadMap, files };
}
- async queueUploadMeta(e) {
- const uploadId = e.target.closest(this.selectors.items.item)?.dataset.uploadId;
- const upload = this.stores.uploads.get(uploadId);
- if (!uploadId || !upload) return;
+ queueUploadMeta(e) {
+ let attachmentId = e.target.closest('[data-attachment-id]')?.dataset.attachmentId;
+ let isUpload = false;
+ if (!attachmentId) {
+ attachmentId = e.target.closest('[data-upload-id]')?.dataset.uploadId;
+ isUpload = true;
+ if (!attachmentId) return;
- const field = this.fields.get(upload.field);
- if (!field) return;
- let data = {};
- data[e.target.name] = e.target.value;
+ }
- upload.fields = { ...upload.fields, ...data };
- await this.setUpload(upload.id, upload);
+ if (!this.changes.has(attachmentId)) {
+ let object = {};
+ if (isUpload) {
+ object['uploadId'] = attachmentId;
+ } else {
+ object['attachmentId'] = attachmentId;
+ }
+ this.changes.set(attachmentId, object);
+ }
- let queueData = {};
- queueData[upload.attachmentId ?? upload.id] = upload.fields;
- return await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true);
+ let field = e.target.closest('[data-field]');
+ let name = field.dataset.field;
+
+ this.changes.get(attachmentId)[name] = e.target.value;
+
+ this.scheduleSave();
+ }
+ scheduleSave() {
+ window.debouncer.schedule(
+ `upload-meta`,
+ async () => {
+ if (this.changes.size > 0) {
+ let items = {};
+ for (let [id, meta] of this.changes.entries()) {
+ console.log(id, meta);
+ items[id] = meta;
+ }
+ let data = {
+ user: window.auth.getUser(),
+ items: items
+ };
+ await this.sendToQueue('uploads/meta', data, 'Uploading Meta', 'Uploading Meta', true);
+ this.changes.clear();
+ }
+ },
+ 2000
+ );
}
/*********************************************************************
FIELD LOGIC
*********************************************************************/
- scanFields(container, autoUpload = true) {
+ scanFields(container, autoUpload = true, imageMeta = true) {
const fields = container.querySelectorAll(this.selectors.fields.field);
- fields.forEach(uploader => this.registerField(uploader, autoUpload));
+ fields.forEach(uploader => this.registerField(uploader, autoUpload, imageMeta));
}
- registerField(element, autoUpload = true, id = null) {
+ registerField(element, autoUpload = true, imageMeta = true, id = null) {
const data = {
element: element,
id: (id) ? id : this.determineFieldId(element),
- config: this.extractFieldConfig(element, autoUpload),
+ config: this.extractFieldConfig(element, autoUpload, imageMeta),
uploads: new Set(),
operationId: null,
groups: [],
@@ -850,9 +896,10 @@
return data.id;
}
- extractFieldConfig(fieldElement, autoUpload) {
+ extractFieldConfig(fieldElement, autoUpload, imageMeta) {
return {
autoUpload: autoUpload,
+ showMeta: imageMeta,
destination: fieldElement.dataset.destination || 'meta', //TODO: why do we need this?
content: this.extractFieldContent(fieldElement),
mode: fieldElement.dataset.mode || 'direct',
@@ -1060,7 +1107,7 @@
id: uploadId,
field: fieldId,
status: 'local_processing',
- blob: null,
+ // blob: null,
fields: {
originalName: file.name,
originalSize: file.size,
diff --git a/assets/js/concise/UtilityFunctions.js b/assets/js/concise/UtilityFunctions.js
index 2381056..df3c75d 100644
--- a/assets/js/concise/UtilityFunctions.js
+++ b/assets/js/concise/UtilityFunctions.js
@@ -414,21 +414,25 @@
}
window.prefixInput = function(input, prefix, replace = false) {
+ if (!input) {
+ console.warn('prefixInput called with null/undefined input');
+ return;
+ }
let newId = replace ? prefix : `${prefix}${input.name}`;
- if (input.labels.length > 0) {
+ if (input.labels && input.labels.length > 0) {
input.labels?.forEach(label => {
label.htmlFor = newId;
});
- } else {
- if (input.nextElementSibling?.tagName === 'LABEL') {
- input.nextElementSibling.htmlFor = newId;
- }else if (input.previousElementSibling?.tagName === 'LABEL') {
- input.previousElementSibling.htmlFor = newId;
- } else {
- let label = input.parentElement.querySelector(`label[for="${input.id}"]`);
- if (label) {
- label.htmlFor = newId;
- }
+ } else if (input.previousElementSibling?.tagName === 'label') {
+ let label = input.previousElementSibling;
+ if (label) label.htmlFor = newId;
+ } else if (input.nextElementSibling?.tagName === 'label') {
+ let label = input.nextElementSibling;
+ if (label) label.htmlFor = newId;
+ }else {
+ let label = input.closest('[data-field]')?.querySelector(`label[for="${input.id}"]`);
+ if (label) {
+ label.htmlFor = newId;
}
}
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index 4d76819..23ecd8d 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.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{console.log("Creator form change, prevents default"),e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);return t.success?t.term?.pending?(this.selector.setMessage(!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){this.ui.details.open=!1,this.selector.store.clearCache(),await this.selector.store.fetch()}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);return t.success?t.term?.pending?(this.selector.setMessage(!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){const i={id:e.id,name:e.name,path:e.path||e.name,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-"),parent:t.parent||0,taxonomy:t.taxonomy,count:0,hasChildren:!1};this.selector.store.data.set(e.id,i),this.ui.details&&(this.ui.details.open=!1),this.selector.store.clearCache(),this.selector.store.fetch().catch((e=>{console.warn("Background fetch after term creation failed:",e)}))}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.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 76dbecb..2ad3e61 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id,window.prefixInput(i.checkbox,`select-${s.id}`,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${l.id}-`)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${a.id}-`)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.ui.modals[e].form.reset(),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"content"===t.endpoint&&Object.keys(t.data?.posts??{}).length>0){this.store.clearCache();let e=Object.keys(t.data.posts),i=this.changesStore.getMany(e);this.changesStore.deleteMany(e);for(let s of e){let e=i.filter((e=>e.id===s))[0]??!1,a=t.data.posts[s],l={};for(let[t,i]of Object.entries(a))e&&!Object.hasOwn(e,t)||(e[t]===i&&delete e[t],l[t]=i);Object.keys(l).length>0&&(l.id=s,l.content=this.content,this.changes.set(s,l))}Object.values(this.changes).length>0&&this.scheduleBackup()}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.savePosts(i,!1)}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){if(!e.termIds.length||!this.selected.size)return;const t={},i=`tax_${e.taxonomy}`;this.selected.forEach((s=>{const a=this.store.get(parseInt(s));if(!a)return;const l=(a.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),n=[...new Set([...l,...e.termIds])];t[s]={[i]:n.join(","),content:this.content}})),Object.keys(t).length>0&&this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`,!1).then((()=>{})),this.selectionHandler.clearSelection()}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&(await this.changesStore.saveMany(this.changes),this.changes.clear())}),2e3)}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.ui.modals.create.form.reset(),this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{}));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.store.filters.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.ui.modals.edit.form.reset(),this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1})}async savePosts(e="",t=!1){const i=Array.from(this.changes.values()),s=await this.changesStore.getAll(),a=window.deepMerge(s,i);if(0===a.length)return;""===e&&(e=`Saving ${a.length} ${1===a.length?this.singular:this.plural}`);let l={},n=[];a.forEach((e=>{let t=e.id;const{id:i,...s}=e;l[t]=s,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&n.push(t)})),n.length>0&&this.removeItems(n);let o={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:l},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(o)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]');"trash"===this.status&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===this.status||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id,window.prefixInput(i.checkbox,`select-${s.id}`,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${l.id}-`)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${a.id}-`)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"content"===t.endpoint&&Object.keys(t.data?.posts??{}).length>0){this.store.clearCache();let e=Object.keys(t.data.posts),i=this.changesStore.getMany(e);this.changesStore.deleteMany(e);for(let s of e){let e=i.filter((e=>e.id===s))[0]??!1,a=t.data.posts[s],l={};for(let[t,i]of Object.entries(a))e&&!Object.hasOwn(e,t)||(e[t]===i&&delete e[t],l[t]=i);Object.keys(l).length>0&&(l.id=s,l.content=this.content,this.changes.set(s,l))}Object.values(this.changes).length>0&&this.scheduleBackup()}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.cancelBackup(),this.handleBackup().then((()=>{})),this.savePosts(i,!1).then((()=>{}))}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const i=this.store.get(t);if(!i)return;const s=(i.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...s,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){await this.changesStore.saveMany(this.changes),this.changes.clear()}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const i=await this.changesStore.getAll();if(0===i.length)return;""===e&&(e=`Saving ${i.length} ${1===i.length?this.singular:this.plural}`);let s={},a=[];i.forEach((e=>{let t=e.id;const{id:i,...l}=e;s[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:s},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),i=this.status;"trash"===i&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===i||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(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 63037d0..d5d94d1 100644
--- a/assets/js/min/dataStore.min.js
+++ b/assets/js/min/dataStore.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.1){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>a(o.error);try{h=r(c,o)}catch(e){a(e)}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),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};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){throw"AbortError"!==t.name&&(console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t})),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=t.items||[],o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const d={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,d),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(d)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null==e)return{valid:!0,data:e};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0!==e.data&&(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items)return this.applyOrdering(r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]),t);const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];for(const[e,s]of Object.entries(t.filters))if(!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;if(t.filters.orderby||t.filters.order){const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.fields?.post_title||e.title||e.name||"").toLowerCase(),a=(t.fields?.post_title||t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||0),a=new Date(t.modified||0);break;default:i=new Date(e.date||0),a=new Date(t.date||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0}))}return e}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t}),this.notify(e,"data-loaded",{cached:!0,items:this.getFiltered(e)});const a=await this.shouldFetchWithFilters(e,t,i);r.config.endpoint&&a?await this.fetch(e):r.config.endpoint&&this.notify(e,"data-loaded")}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.2){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),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};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw 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,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items)return this.applyOrdering(r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]),t);const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];for(const[e,s]of Object.entries(t.filters))if(!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;if(t.filters.orderby||t.filters.order){const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.fields?.post_title||e.title||e.name||"").toLowerCase(),a=(t.fields?.post_title||t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||0),a=new Date(t.modified||0);break;default:i=new Date(e.date||0),a=new Date(t.date||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0}))}return e}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t}),this.notify(e,"data-loaded",{cached:!0,items:this.getFiltered(e)});const a=await this.shouldFetchWithFilters(e,t,i);r.config.endpoint&&a?await this.fetch(e):r.config.endpoint&&this.notify(e,"data-loaded")}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 1ad372e..1c13797 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-loaded"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.remove(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" data-form-id="${e}">Restore</button>\n <button class="discard" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0,new this.populate(i,t),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.remove(e),this.a11y.announce("Previous changes discared"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required&&!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t||!t.options.cache)return;let s=this.getField(e.target);s&&(this.showFormStatus(t,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${s.dataset.field}`,(()=>this.validateField.bind(this)),500))}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary(t.id,{config:t,data:e?.changes||{}})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{if(this.changes.size>0){await this.store.saveMany(this.changes);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}}),2e3)}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:!1,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",formStatus:t.showStatus??!0,showSummary:!1,cache:t.cache??!0},ui:window.uiFromSelectors(this.selectors.forms,e)};return i.showSummary&&!this.summaryTemplate&&this.defineSummaryTemplate(),this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...e.ignore];for(let[e,i]of Object.entries(a.changes)){if(r.includes(e)||this.isEmptyValue(i))continue;let a=Array.from(this.inputs.values()).find((t=>t.field?.dataset.field===e));if(!a)continue;let n=s.result.cloneNode(!0),l=n.querySelector("h3"),o=n.querySelector("p");l.textContent=a.label.textContent,"string"==typeof i?o.textContent=i:Array.isArray(i)||"object"==typeof i&&(o.textContent=`${i.address}`),t.append(n)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img");if(a){let e=s.result.cloneNode(!0),r=field.querySelector("h3"),n=field.querySelector("p");n?.remove(),r&&(r.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}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)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.autoUpload)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}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 i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}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 i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),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}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){this.templates.create("formSummary",e)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;default:return e.value}}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,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("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),this.cancelBackup(),await this.backup();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}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)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}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 i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}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")}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 i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),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}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,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("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/index.php b/assets/js/min/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/assets/js/min/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index 42f7c96..ced1583 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let l=e.querySelector(`[data-field="${t}"]`);l&&this.populateField(l,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let l=this.formHelper.getFieldType(e);if(!l||this.isEmptyValue(t)||this.isEmptyValue(i))return;const a={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(a,l)?a[l](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const l=e.querySelector(".repeater-items");let a=e.querySelector("template")?.className??!1;l&&a&&(window.removeChildren(l),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(a,e);let r=i.querySelectorAll(".field");this.populate(r,e),l.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const l=e.querySelector(".tag-items");let a=e.querySelector("template")?.className??!1;l&&a&&(window.removeChildren(l),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(a,e);let r=i.querySelectorAll(".field");this.populate(r,e),l.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let l=e.querySelector(`[data-location-field="${t}"]`);l&&(l.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let l=this.splitIDs(i);if(0===l.length)return;const a=e.querySelector(`input[type="hidden"][name="${t}"]`);a&&(a.value=l.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(a)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const l=this.splitIDs(i);if(0===l.length)return;const a=e.querySelector('input[type="hidden"]');a&&(a.value=l.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=l.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),l.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))})))}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let l=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,l){window.removeChildren(l),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&l.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let l=e.querySelector(`select[name="${t}"]`);if(l&&l.multiple)for(let e of l.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let l=e.querySelector(`select[name="${t}"]`);if(l)return void(l.value=i);let a=e.querySelector(`[name="${t}"][value="${i}"]`);a&&(a.checked=!0)}populateBoolean(e,t,i){const l=e.querySelector(`[name="${t}"], input[type="checkbox"]`);l&&(l.checked=Boolean(i))}populateDate(e,t,i){const l=e.querySelector(`[name="${t}"], input`);if(l){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(l.type){case"date":l.value=e.toISOString().split("T")[0];break;case"time":l.value=e.toTimeString().slice(0,5);break;case"datetime-local":l.value=e.toISOString().slice(0,16);break;default:l.value=i}}catch(e){l.value=i}}}populateNumber(e,t,i){const l=e.querySelector(`[name="${t}"], input[type="number"]`);l&&(l.value=Number(i)||0)}populateTextarea(e,t,i){let l=e.querySelector("textarea");l.dataset.editor?(l.value=String(i||""),l.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let l=e.querySelector(`[name="${t}"], input, textarea`);l&&"file"!==l.type&&(l.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img"},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:l,data:a}){e.dataset.itemId=a.id,i.select&&window.prefixInput(i.select,`${a.id}-`),i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[a.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),l.fields)for(let e of l.fields){if("group"===e.dataset.fieldType)continue;let i=e.dataset.field,l=a[i]??"";t.isEmptyValue(l)||t.populateField(e,i,l);const r=e.querySelector('input:not([type="file"]), textarea');r&&window.prefixInput(r,`[${a.id}]`)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const r=["image-title","image-alt-text","image-caption"];for(const e of r){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){e.dataset.itemId=l.id,i.select&&window.prefixInput(i.select,`${l.id}-`),i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[l.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const r=e.querySelector('input:not([type="file"]), textarea');r&&window.prefixInput(r,`[${l.id}]`)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=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 4d9f4bd..92d967a 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=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.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.element.id}-${r.id}`,s.checkbox.name=`${a.element.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.element.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0}))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>!t.checked&&t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField(),t=this.currentTerms();if(!e)return;const s=e.ui.dropdown.list;s&&(window.removeChildren(s),0===t.length?this.setMessage(!0,`No ${e.plural} found.`,!1):(window.chunkIt(t,(e=>this.createAutocompleteTerm(e)),(e=>s.append(e))).then((()=>{})),this.setMessage(!1)),this.setCreateButton(!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1))}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t),this.setMessage(!0,"An error occurred loading data",!1)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField?this.showResults(!0):this.setMessage(!1)}showResults(e=!1){this.setMessage(!1);const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length)}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e=!0){const t=this.currentField();if(!t||!t.canCreate||!this.creator)return;const s=this.container.open?this.ui:t.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button,r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=e&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(!0,`Creating "${s.name}"...`),this.setCreateButton(!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,window.removeChildren(t.ui.dropdown.list),t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);e&&this.addSelected(e.id,t.id),this.container.open||(t.ui.search.disabled=!1,t.ui.search.value=""),this.scheduleHideDropdown(t.id),this.setMessage(!1)}}setMessage(e=!0,t="",s=!0){const i=this.currentField();if(!i)return;const r=this.container.open||i.isFilter?this.ui:i.isFilter?null:i.ui;if(!r?.message?.message)return;t=""===t?`No ${i.plural??"items"} found.`:t;const a=r.message.message,n=r.message.text;a.hidden=!e,e?t&&n&&(s&&window.typeLoop&&n?(this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id]),this.messageText[i.id]=window.typeLoop(n,t)):n.textContent=t):this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id])}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(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(!1)),this.setCreateButton(!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t),this.setMessage(!0,"An error occurred loading data",!1)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField?this.showResults(!0):this.setMessage(!1)}showResults(e=!1){this.setMessage(!1);const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e=!0){const t=this.currentField();if(!t||!t.canCreate||!this.creator)return;const s=this.container.open?this.ui:t.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button,r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=e&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(!0,`Creating "${s.name}"...`),this.setCreateButton(!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(!1)}else this.setMessage(!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e=!0,t="",s=!0){const i=this.currentField();if(!i)return;const r=this.container.open||i.isFilter?this.ui:i.isFilter?null:i.ui;if(!r?.message?.message)return;t=""===t?`No ${i.plural??"items"} found.`:t;const a=r.message.message,n=r.message.text;a.hidden=!e,e?t&&n&&(s&&window.typeLoop&&n?(this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id]),this.messageText[i.id]=window.typeLoop(n,t)):n.textContent=t):this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id])}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(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index 7f8dabc..cba5853 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details"},manyRefs:{inputs:"input"},setup({el:s,refs:r,manyRefs:i,data:o}){let a,l,n,d=!1;switch(Object.hasOwn(o,"file")?(s.dataset.uploadId=o.uploadId,a=t.getSubtypeFromMime(o.file.type)||"image",l="document"!==a&&t.createPreviewUrl(o.file),d=l,n=o.file.name||""):(s.dataset.id=o.id,a=t.getSubtypeFromURL(o.medium??o.src),l=o.medium??o.src,n=o["image-alt-text"]??""),s.dataset.subtype=a,r.featured&&(r.featured.value=o.uploadId),a){case"image":r.img&&(r.img.src=l,r.img.alt=n,d&&(r.img.dataset.previewUrl=d)),r.video&&r.video.remove(),r.file&&r.file.remove();break;case"video":r.video&&(r.video.src=l,r.video.alt=n,d&&(r.video.dataset.previewUrl=d)),r.img&&r.img.remove(),r.file&&r.file.remove();break;case"document":if(r.preview){let e=o.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},s=window.getIcon(t[e]??"file");r.preview.innerText=o.file.name??o.title,r.preview.prepend(s)}r.img&&r.img.remove(),r.video&&r.video.remove()}if(r.details&&r.details.append(e.create("uploadMeta")),s.draggable="single"!==s.dataset.mode,i.inputs)for(let e of i.inputs)window.prefixInput(e,`${o.uploadId}-`)}}),e.define("uploadMeta",{refs:{alt:'[name="alt_text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},setup({el:e,refs:t,manyRefs:s,data:r}){Object.hasOwn(r,"alt")&&t.alt&&(t.alt.value=r.alt),Object.hasOwn(r,"title")&&t.title&&(t.title.value=r.title),Object.hasOwn(r,"description")&&t.description&&(t.description.value=r.description)}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:r,data:i}){t.dataset.groupId=i.groupId,s.selectAll&&window.prefixInput(s.selectAll,`select-all-${i.groupId}`,!0);let o=e.create("groupMetadata",{groupId:i.groupId});o?s.fields.append(o):s.details.remove(),s.grid&&(s.grid.dataset.groupId=i.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:r}){t.inputs&&t.inputs.forEach((e=>{window.prefixInput(e,`${r.groupId}-`)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:r,data:i}){if(s.details){let e=i.bySource.size>1?` across ${i.bySource.size} pages`:"",t=i.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${i.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let o=1;for(const[t,r]of i.bySource){let i={index:o,isCurrent:t===window.location.href,src:t,uploads:r};s.wrap.append(e.create("restoreField",i)),o++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:r,data:i}){let o=t.registerField(e,!1,`recovery_${i.index}`);i.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=i.src,s.a.title="Navigate to page and restore",s.a.textContent=i.src);let a=[...new Set(i.uploads.map((e=>e.group??"preview")))];for(let e of a){let r="preview"===e||t.stores.groups.get(e);if(!r)continue;let a=await t.createGroupElement(e,o),l=a.querySelector(".item-grid"),n=i.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(r.fields??{})){let s=a.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of n){let s=await t.createUpload(e.id,t.formatFile(e),o);l.append(s)}s.grid.append(a)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(!t)return;if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);return void(s.length>0&&this.processFiles(t,s).then((()=>{})))}if(e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]'))return;let s=this.fields.get(t);s&&s.config.autoUpload&&("post_group"===s.config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e).then((()=>{})))}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name,r=e.value,i=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${i}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[i]=r,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const r=this.getFieldIdFromElement(t);r&&(this.processFiles(r,s).then((()=>{this.updateHandlerItems(r)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const r=this.fields.get(t);if(!r)return;let i=this.stores.uploads.filterByIndex({field:t});if(0===i.length)return;const[o,a]=["uploads"===e,"uploads/groups"===e];let l,n,d,u,p;s.append("fieldId",r.id),s.append("content",r.config.content),o&&(s.append("mode",r.config.mode),s.append("field_name",r.config.name),s.append("fieldId",r.id),s.append("field_type",r.config.type),s.append("subtype",r.config.subtype),s.append("item_id",r.config.itemID),s.append("destination",r.config.destination)),a?({posts:l,uploadMap:n,files:d}=this.collectGroups(t)):o&&({uploadMap:n,files:d}=this.collectUploads(t)),a&&s.append("posts",JSON.stringify(l)),d.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(n)),o?(u=`Uploading ${i.length} file${i.length>1?"s":""} to server...`,p=`Uploading ${i.length} file${i.length>1?"s":""}...`):a&&(u=`Creating ${l.length} ${r.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(i,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=r.element.closest("details");e&&(e.open=!1)}return c?(r.operationId=c,await this.setBulkUpload(i,"operationId",c),await this.setBulkUpload(i,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(r.id,r)):await this.setBulkUpload(i,"status","failed"),this.notify("sent-to-queue",t),c}async sendToQueue(e,t,s="",r="",i=!1){""===r&&(r=s);const o={endpoint:e,method:"POST",data:t,title:s,popup:r,canMerge:i,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(o)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e}),r=[],i=[],o=[];for(const e of s){const t=this.groups.get(e.id)?.element,s={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},a=this.getGroupUploadsInOrder(e);for(const t of a){const r=this.formatFile(t);if(r){o.push(r);const a={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),n=l?.element?.querySelector(`input[name="${e.id}_featured"]`);n?.checked&&(s.fields.featured=t.id),s.images.push(a),i.push(t.id)}}r.push(s)}const a=t.filter((e=>!e.group));for(const e of a){const t={images:[],fields:{}},s=this.formatFile(e);if(s){o.push(s);const r={upload_id:e.id,index:i.length};t.images.push(r),i.push(e.id)}r.push(t)}return{posts:r,uploadMap:i,files:o}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const r=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>r.includes(e)))||e.value&&(s[r]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],r=[];for(const e of t){const t=this.formatFile(e);t&&(r.push(t),s.push(e.id))}return{uploadMap:s,files:r}}async queueUploadMeta(e){const t=e.target.closest(this.selectors.items.item)?.dataset.uploadId,s=this.stores.uploads.get(t);if(!t||!s)return;if(!this.fields.get(s.field))return;let r={};r[e.target.name]=e.target.value,s.fields={...s.fields,...r},await this.setUpload(s.id,s);let i={};return i[s.attachmentId??s.id]=s.fields,await this.sendToQueue("uploads/meta",i,"Uploading Meta","",!0)}scanFields(e,t=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t)))}registerField(e,t=!0,s=null){const r={element:e,id:s||this.determineFieldId(e),config:this.extractFieldConfig(e,t),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t){return{autoUpload:t,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,r){const i=this.fields.get(e);i&&window.showProgress(i.ui.progress,t,s,r)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,r=this.workerState.tasks.get(t);r&&(clearTimeout(r.timeoutId),r.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const r=[],i=[...e],o=this.workerState.settings.maxConcurrent,a=async()=>{for(;i.length>0;){const e=i.shift();r.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(o,e.length)},(()=>a()))),r}async processImage(e,t=2200,s=2200,r=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),r)}catch(r){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:r,restart:i}=this.workerState;if(i.count>=i.max)throw new Error("Worker max restarts exceeded");const o=await createImageBitmap(e);let{width:a,height:l}=o;if(a>t||l>s){const e=Math.min(t/a,s/l);a=Math.round(a*e),l=Math.round(l*e)}const n=this.getWorker(),d=crypto.randomUUID();return new Promise(((t,s)=>{const i=setTimeout((()=>{this.workerState.tasks.delete(d),r.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),r.timeout);this.workerState.tasks.set(d,{resolve:t,reject:s,timeoutId:i}),n.postMessage({id:d,imageBitmap:o,width:a,height:l,type:e.type,quality:.9},[o])}))}resizeImage(e,t,s){return new Promise((r=>{const i=new Image;i.onload=()=>{URL.revokeObjectURL(i.src);let{width:o,height:a}=i;if(o>t||a>s){const e=Math.min(t/o,s/a);o=Math.round(o*e),a=Math.round(a*e)}const l=document.createElement("canvas");l.width=o,l.height=a,l.getContext("2d").drawImage(i,0,0,o,a),l.toBlob(r,e.type,.9)},i.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const r=t.length;let i=0;this.updateFieldProgress(e,0,r,"Processing files...");const o=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),r=await this.setUpload(s,{id:s,field:e,status:"local_processing",blob:null,fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),i=await this.createUpload(s,t,e);return this.uploads.set(s,{element:i,ui:window.uiFromSelectors(this.selectors.items,i)}),await this.addToGroup(s,null),{uploadId:s,upload:r,file:t}}))),a=o.filter((e=>e.file.type.startsWith("image/"))),l=o.filter((e=>!e.file.type.startsWith("image/"))),n=await this.processImages(a.map((e=>e.file)));for(let t=0;t<a.length;t++){const{uploadId:s,upload:o}=a[t];o.blob=n[t],o.fields.size=n[t].size,o.status="queued",await this.setUpload(s,o),i++,this.updateFieldProgress(e,i,r,"Processing files...")}for(const{uploadId:t,upload:s,file:o}of l)s.blob=o,s.status="queued",await this.setUpload(t,s),i++,this.updateFieldProgress(e,i,r,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]});if(0===e.length)return;const t=new Map;e.forEach((e=>{const s=e.src||"unknown";t.has(s)||t.set(s,[]),t.get(s).push(e)}));let s={bySource:t,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",s));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),r=[...new Set(s.map((e=>e.group)))].filter(Boolean),i=s[0].field;if(!document.querySelector(`[data-uploader="${i}"]`))return void console.log("No field found for "+i);let o=this.fields.get(i);o.groupUI.container&&(o.groupUI.container.hidden=!1);let a=[];for(let e of r){let t=this.stores.groups.get(e);await this.createGroup(i,e);let r=this.groups.get(e),o=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=r.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of o){let s=await this.createUpload(t.id,this.formatFile(t),i);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),a.push(t.id)}}let l=s.filter((e=>!a.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),i);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{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"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let r=this.fields.get(s);if(!r)return null;let i={uploadId:e,file:t,field:r};return this.templates.create("uploadItem",i)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const r=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(r)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const r=this.createGroupElement(t,e);if(!r)return null;const i=s.groupUI.empty;i?.nextSibling?s.groupUI.grid.insertBefore(r,i.nextSibling):s.groupUI.grid.append(r);const o=r.querySelector(".item-grid");o&&(o.dataset.groupId=t,this.createSortable(e,o,t));let a=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...a,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},r=this.templates.create("imageGroup",s);return this.groups.set(e,{element:r,ui:window.uiFromSelectors(this.selectors.group,r)}),this.getSelectionHandler(t)?.addWrapper(r),r}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let r=this.stores.groups.filterByIndex({field:e});if(0===r.length)return;let i=r.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(i)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),r=this.uploads.get(e);if(!s||!r)return;const i=this.fields.get(s.field);if(!i)return;if(null!==r.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}r.ui.checkbox&&(r.ui.checkbox.checked=!1);const o=this.selectionHandlers.get(s.field);if(o&&o.isSelected(e)&&o.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),r.ui.featured&&(r.ui.featured.hidden=!t),t){r.ui.featured&&(r.ui.featured.name=`${t}_featured`);let i=this.stores.groups.get(t);i&&(i.uploads.push(e),s.group=t,await this.stores.groups.save(i))}else s.group=null;let a=t?this.groups.get(t)?.ui.grid:i.ui.grid;a&&(a.append(r.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let r=this.stores.uploads.filterByIndex({group:s});Promise.all(r.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),r=this.stores.groups.get(e);if(!r)return;let i=!0;t&&r.uploads.length>0&&(i=window.confirm("Keep uploads in this group?")),await Promise.all(r.uploads.map((e=>i?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(r.field)){const t=this.getGroupKey(r.field,e),i=this.selectionHandlers.get(t);i?.destroy&&i.destroy(),this.selectionHandlers.get(r.field)?.removeWrapper(s.element);const o=this.sortables.get(t);o?.destroy&&o.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,r=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=r}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let r=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});r.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,r)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const r=this.getGroupKey(e,s);if(this.sortables.has(r))return this.sortables.get(r);const i=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",onStart:t=>{const s=t.item,r=s?.dataset.uploadId,i=this.selected.get(e);if(r&&(!i||!i.has(r))){const t=this.selectionHandlers.get(e);t&&t.select(r)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(r,i),i}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),s.classList.remove("drag-over");const r=this.selected.get(e);if(!r||0===r.size)return;const i=await this.createGroup(e);i&&(await Promise.all(Array.from(r).map((e=>this.addToGroup(e,i)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,r=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===r.length)return;const i=s.dataset.groupId||null;for(const e of r)await this.addToGroup(e,i);await this.handleReorder(t,i),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let r=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=r,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=r.join(","))}this.a11y.announce("Items reordered")}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(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),r=e.dataset.field,i=this.stores.uploads.filterByIndex({field:t});for(const e of i){const t=this.formatFile(e);t&&s.push({file:t,fieldName:r,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let e of i.inputs)window.prefixInput(e,`${r.id??r.uploadId}-`)}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){t.dataset.groupId=r.groupId,s.selectAll&&window.prefixInput(s.selectAll,`select-all-${r.groupId}`,!0);let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{window.prefixInput(e,`${i.groupId}-`)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name,i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i)):await this.setBulkUpload(r,"status","failed"),this.notify("sent-to-queue",t),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e}),i=[],r=[],a=[];for(const e of s){const t=this.groups.get(e.id)?.element,s={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const i=this.formatFile(t);if(i){a.push(i);const o={upload_id:t.id,index:r.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(s.fields.featured=t.id),s.images.push(o),r.push(t.id)}}i.push(s)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},s=this.formatFile(e);if(s){a.push(s);const i={upload_id:e.id,index:r.length};t.images.push(i),r.push(e.id)}i.push(t)}return{posts:i,uploadMap:r,files:a}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift();i.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>e.file)));for(let t=0;t<o.length;t++){const{uploadId:s,upload:a}=o[t];a.blob=d[t],a.fields.size=d[t].size,a.status="queued",await this.setUpload(s,a),r++,this.updateFieldProgress(e,r,i,"Processing files...")}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]});if(0===e.length)return;const t=new Map;e.forEach((e=>{const s=e.src||"unknown";t.has(s)||t.set(s,[]),t.get(s).push(e)}));let s={bySource:t,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",s));let i=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(i),this.restoreSelection=new window.jvbHandleSelection(i,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{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"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}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(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(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 a31af92..81f4cbe 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=!1){let i=n?t:`${t}${e.name}`;if(e.labels.length>0)e.labels?.forEach((e=>{e.htmlFor=i}));else if("LABEL"===e.nextElementSibling?.tagName)e.nextElementSibling.htmlFor=i;else if("LABEL"===e.previousElementSibling?.tagName)e.previousElementSibling.htmlFor=i;else{let t=e.parentElement.querySelector(`label[for="${e.id}"]`);t&&(t.htmlFor=i)}e.id=i},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
+(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");let i=n?t:`${t}${e.name}`;if(e.labels&&e.labels.length>0)e.labels?.forEach((e=>{e.htmlFor=i}));else if("label"===e.previousElementSibling?.tagName){let t=e.previousElementSibling;t&&(t.htmlFor=i)}else if("label"===e.nextElementSibling?.tagName){let t=e.nextElementSibling;t&&(t.htmlFor=i)}else{let t=e.closest("[data-field]")?.querySelector(`label[for="${e.id}"]`);t&&(t.htmlFor=i)}e.id=i},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
diff --git a/base/index.php b/base/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/base/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/build/feed/style-index-rtl.css b/build/feed/style-index-rtl.css
index 7a0ed7f..c7ef499 100644
--- a/build/feed/style-index-rtl.css
+++ b/build/feed/style-index-rtl.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.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 [type=radio]{right:var(--offScreen);position:absolute}.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(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.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)}.item-grid{max-width:none;padding:0 var(--chip)}.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 details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>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 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)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.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 [type=radio]{right:var(--offScreen);position:absolute}.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(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.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)}.item-grid{max-width:none;padding:0 var(--chip)}.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 details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>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 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)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-right:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/style-index.css b/build/feed/style-index.css
index 273f7f5..d520da0 100644
--- a/build/feed/style-index.css
+++ b/build/feed/style-index.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.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 [type=radio]{left:var(--offScreen);position:absolute}.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(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.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)}.item-grid{max-width:none;padding:0 var(--chip)}.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 details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>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 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)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.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 [type=radio]{left:var(--offScreen);position:absolute}.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(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.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)}.item-grid{max-width:none;padding:0 var(--chip)}.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 details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>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 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)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-left:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index 1e9f0a8..aad0cb3 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => 'eb66f05b2fcc7e099499');
+<?php return array('dependencies' => array(), 'version' => '22ba781dbd65270a003b');
diff --git a/build/feed/view.js b/build/feed/view.js
index 0371212..d261b31 100644
--- a/build/feed/view.js
+++ b/build/feed/view.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters"}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.content=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.content=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.content=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.content=!1),this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t)}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){const e=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=e.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(){let e=this.store.getFiltered();this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.fields.number} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.content=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.content=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.content=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.content=!1),this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch())}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){const e=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=e.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(){let e=this.store.getFiltered();this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.fields.number-1} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
diff --git a/build/forms/view.asset.php b/build/forms/view.asset.php
index 9fe42fd..6ec4e52 100644
--- a/build/forms/view.asset.php
+++ b/build/forms/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '2afaae229d42019919a2');
+<?php return array('dependencies' => array(), 'version' => '4a803d31294ea48b6b19');
diff --git a/build/forms/view.js b/build/forms/view.js
index beaacce..8037160 100644
--- a/build/forms/view.js
+++ b/build/forms/view.js
@@ -1 +1 @@
-(()=>{class o{constructor(){this.controller=window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((o=>{this.controller.registerForm(o,{autosave:!0,autoUpload:!1,showSummary:!0})})),this.controller.subscribe(((o,r)=>{"form-submit"===o&&this.handleFormSubmission(r).then((()=>{}))}))}async handleFormSubmission(o){const{config:r,data:e}=o,t=r.element,n=new FormData;for(const[o,r]of Object.entries(e))"_wpnonce"!==o&&"_wp_http_referer"!==o&&(Array.isArray(r)?r.forEach((r=>n.append(`${o}[]`,r))):"object"==typeof r&&null!==r?n.append(o,JSON.stringify(r)):n.append(o,r));if(window.jvbUploads)try{(await window.jvbUploads.getFilesForForm(t)).forEach((({file:o,fieldName:r})=>{n.append(`${r}[]`,o)}))}catch(o){console.error("Error getting files:",o)}this.controller.showFormStatus(r.id,"uploading");try{const o=await fetch(`${jvbSettings.api}forms`,{method:"POST",credentials:"same-origin",body:n}),e=await o.json();if(!o.ok)return this.controller.showFormStatus(r.id,"error"),void this.controller.handleFormError(t,e);if(this.controller.showFormStatus(r.id,"submitted"),this.controller.showSummary(r.id,".jvb-form-block"),window.jvbUploads){const o=t.querySelectorAll("[data-upload-field]");for(const r of o){const o=window.jvbUploads.determineFieldId(r);await window.jvbUploads.clearFieldFromStores(o)}}}catch(o){console.error("Form submission error:",o),this.controller.showFormStatus(r.id,"error"),this.controller.handleFormError(t,{message:"Network error. Please check your connection and try again.",code:"network_error"})}finally{await this.controller.store.delete(r.id)}}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((r=>{"auth-loaded"===r&&new o}))}))})();
\ No newline at end of file
+(()=>{class o{constructor(){this.controller=window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((o=>{this.controller.registerForm(o,{cache:!0,autoUpload:!1,imageMeta:!1})})),this.controller.subscribe(((o,e)=>{"form-submit"===o&&this.handleFormSubmission(e).then((()=>{}))}))}async handleFormSubmission(o){const{config:e,data:r}=o,t=e.element,n=new FormData;for(const[o,e]of Object.entries(r))Array.isArray(e)?e.forEach((e=>n.append(`${o}[]`,e))):"object"==typeof e&&null!==e?n.append(o,JSON.stringify(e)):n.append(o,e);if(e.element.querySelectorAll('[name="form_id"],[name="form_type"],[name="timestamp"],[name="cf-turnstile-response"]').forEach((o=>{n.append(o.name,o.value)})),window.jvbUploads)try{(await window.jvbUploads.getFilesForForm(t)).forEach((({file:o,fieldName:e})=>{n.append(`${e}[]`,o)}))}catch(o){console.error("Error getting files:",o)}this.controller.showFormStatus(e.id,"uploading");try{const o=await fetch(`${jvbSettings.api}forms`,{method:"POST",credentials:"same-origin",body:n}),a=await o.json();if(!o.ok)return this.controller.showFormStatus(e.id,"error"),void this.controller.handleFormError(t,a);if(this.controller.showFormStatus(e.id,"submitted"),this.controller.showSummary({changes:r,config:e}),window.jvbA11y.announce("Form successfully submitted!"),window.jvbUploads){const o=t.querySelectorAll("[data-upload-field]");for(const e of o){const o=window.jvbUploads.determineFieldId(e);await window.jvbUploads.clearFieldFromStores(o)}}}catch(o){console.error("Form submission error:",o),this.controller.showFormStatus(e.id,"error"),this.controller.handleFormError(t,{message:"Network error. Please check your connection and try again.",code:"network_error"})}finally{await this.controller.store.delete(e.id)}}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((e=>{"auth-loaded"===e&&new o}))}))})();
\ No newline at end of file
diff --git a/build/timeline/style-index-rtl.css b/build/timeline/style-index-rtl.css
index d10c16d..144005a 100644
--- a/build/timeline/style-index-rtl.css
+++ b/build/timeline/style-index-rtl.css
@@ -1 +1 @@
-main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{margin:0 auto;max-width:var(--wide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--txt-x-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 img{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(--txt-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(--txt-x-large)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point img{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(--txt-x-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)}}
+main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{margin:0 auto;max-width:var(--wide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--txt-x-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 img{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(--txt-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(--txt-x-large)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point img{width:50%}.timeline-point.timeline-point .info{padding:25vh 1rem 1rem;width:50%}.timeline-point.timeline-point .info h2 .icon{--w:4rem;right:-6.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(--txt-x-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
index ea196c4..29dddad 100644
--- a/build/timeline/style-index.css
+++ b/build/timeline/style-index.css
@@ -1 +1 @@
-main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{margin:0 auto;max-width:var(--wide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--txt-x-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 img{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(--txt-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(--txt-x-large)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point img{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(--txt-x-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)}}
+main{--gap:0}main section:last-of-type{margin-bottom:0}#at-a-glance{margin:0 auto;max-width:var(--wide);--gap:0}#at-a-glance img{border:2px solid var(--action-0);height:auto;width:100%}#at-a-glance h3{font-size:var(--txt-x-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 img{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(--txt-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(--txt-x-large)}.timeline-point.timeline-point{--gap:4rem}.timeline-point.timeline-point img{width:50%}.timeline-point.timeline-point .info{padding:25vh 1rem 1rem;width:50%}.timeline-point.timeline-point .info h2 .icon{--w:4rem;left:-6.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(--txt-x-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/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 71f30c9..45a0959 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -21,9 +21,11 @@
{
// Initialize cache with connections
$this->cache = CacheManager::for('feed_block', WEEK_IN_SECONDS);
- $this->cache->clear();
// Set up cache connections for all feed content types
$this->setupCacheConnections();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
add_action('init', [$this, 'registerBlock']);
}
@@ -188,6 +190,7 @@
echo TaxonomySelector::outputSelectorModal();
?>
</section>
+ <footer><button data-action="refresh" data-ignore><?=jvbIcon('arrows-clockwise')?><span>Hard Refresh</span></span></button></footer>
<?php
return ob_get_clean();
}
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index 512a9c9..fd5fe29 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -283,7 +283,7 @@
}
}
- echo '<form id="' . esc_attr($form_id) . '" data-form-id="'.esc_attr($type).'" data-save="form" data-noautosave>';
+ echo '<form id="' . esc_attr($form_id) . '" data-form-id="'.esc_attr($type).'" data-save="form">';
// wp_nonce_field('jvb_form_' . $type);
}
diff --git a/inc/blocks/SummaryBlock.php b/inc/blocks/SummaryBlock.php
index 6fc6da2..84d99b3 100644
--- a/inc/blocks/SummaryBlock.php
+++ b/inc/blocks/SummaryBlock.php
@@ -25,6 +25,9 @@
{
$this->cache = CacheManager::for('summary_block', WEEK_IN_SECONDS);
add_action('init', [ $this, 'registerBlock' ]);
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
}
public function registerBlock()
@@ -54,7 +57,6 @@
{
$this->config = $this->getConfig();
$key = $this->generateKey();
- $this->cache->clear();
$cache = $this->cache->get($key);
if ($cache) {
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
index 7fc2674..90cf469 100644
--- a/inc/blocks/TimelineBlock.php
+++ b/inc/blocks/TimelineBlock.php
@@ -32,7 +32,9 @@
public function __construct()
{
$this->cache = CacheManager::for('timelines', WEEK_IN_SECONDS)->connectTo('post', 'timeline');
- $this->cache->clear();
+ if (JVB_TESTING){
+ $this->cache->clear();
+ }
add_action('init', [ $this, 'registerBlock' ]);
add_action('wp_footer', 'jvbRenderGallery');
}
@@ -79,17 +81,27 @@
$this->renderTimeline();
}
+ protected function get_field(string $fieldName, array $fields) {
+ if (array_key_exists($fieldName, $fields)) {
+ return $fields[$fieldName];
+ }
+ return '';
+ }
protected function renderHeader():void
{
- $title = get_the_title();
+ $meta = new MetaManager($this->parentID, 'post');
+ $sharedFields = JVB()->routes('content')->getTimelineSharedFields($this->content);
+ $fields = $meta->getAll($sharedFields);
+ $extra = $meta->getAll();
?>
<header id="top">
- <h1><small>Before and After Laser Tattoo Removal:</small><?=$title?></h1>
+ <h1><small>Before and After Laser Tattoo Removal:</small><?=$this->get_field('post_title', $extra)?></h1>
<ul class="timeline terms">
<?php
- $tax = ['goal', 'number', 'body-part', 'style', 'skin-type', 'age'];
-
- foreach ($tax as $slug) {
+ foreach ($fields as $slug => $value) {
+ if ($value === '' || $slug === 'person') {
+ continue;
+ }
$config = JVB_TAXONOMY[$slug];
$taxSlug = jvbCheckBase($slug);
$terms = get_the_terms($this->parentID, $taxSlug);
@@ -97,8 +109,10 @@
$many = count($terms) > 1;
?>
<li class="<?=$slug?>">
- <?=jvbIcon($config['icon']??jvbDefaultIcon())?>
-
+ <span title="<?= $config['singular']?>" class="term tldr" data-short="" data-long="<?= $config['singular']?>">
+ <?=jvbIcon($config['icon']??jvbDefaultIcon())?>
+ <span></span>
+ </span>
<?php
if ($many) { echo '<ul>'; }
$open = ($many) ? '<li>' : '';
@@ -130,13 +144,35 @@
<?= jvbFormatImage(get_post_thumbnail_id($this->children[count($this->children)-1]), 'tiny', 'large', false) ?>
</div>
</section>
+ <section id="info">
+ <?php
+ $content = $this->get_field('post_content', $extra);
+ if ($content !== '') {
+ echo $this->formatContent($content);
+ }
+ ?>
+ </section>
<?php
}
+
+ protected function formatContent(string $content, bool $wrap = false, string $class = 'content'):string
+ {
+ $out = '';
+ if ($wrap) {
+ $out .= '<div class="'.$class.'">';
+ }
+ $out .= wptexturize(wp_kses_post( wpautop($content)));
+ if ($wrap) {
+ $out .= '</div>';
+ }
+ return $out;
+ }
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);
@@ -147,16 +183,28 @@
?>
<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']);
+ $img = $this->get_field('post_thumbnail', $fields);
+ if (is_int($img)) {
+ echo jvbFormatImage($img);
}
?>
<div class="info">
<header>
<h2><?=jvbIcon(jvbLogoIcon())?><?= $title?></h2>
- <?= array_key_exists('post_date', $fields) && $fields['post_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>' : '' ?>
+ <?php
+ $date = $this->get_field('post_date', $fields);
+ if ($date !== '') {
+ echo '<time datetime="'.$date.'">'.date('F Y', strtotime($date)).'</time>';
+ }
+
+
+ echo $this->outputTimelineTax($ID);
+
+ $content = $this->get_field('post_content', $fields);
+ if ($i > 0 && $content !== '') {
+ echo $this->formatContent($this->get_field('post_content', $fields));
+ }
+ ?>
</header>
</div>
</section>
@@ -166,14 +214,14 @@
protected function outputTimelineTax(int $ID):string
{
- $timeline = get_the_terms($ID, BASE.'timeline');
+ $timeline = wp_get_object_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 .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').$term->name.'</a><small>from the last treatment</small></li>';
}
$out .='</ul>';
return $out;
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index eccabc2..9409b61 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -118,14 +118,15 @@
</button>
</nav>
+
+ <p class="message" hidden aria-live="polite">
+ { <span>loading items</span> }
+ </p>
<!-- Terms list -->
<ul class="items-container col start" role="listbox" aria-label="Available terms">
<!-- Terms will be populated here -->
</ul>
- <p class="message" hidden aria-live="polite">
- { <span>loading items</span> }
- </p>
<button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button>
<!-- Infinite scroll sentinel -->
@@ -239,12 +240,12 @@
</button>
<?php if ($hasAutocomplete !== '') { ?>
<input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
+ <p class="message" hidden aria-live="polite">
+ { <span>loading items</span> }
+ </p>
<div class="auto-wrapper" hidden>
<ul class="search-results">
</ul>
- <p class="message" hidden aria-live="polite">
- { <span>loading items</span> }
- </p>
<button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button>
</div>
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 3fa3207..67ade56 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -558,7 +558,7 @@
$dataID = ($ID) ? ['image-id' => $ID] : false;
$addID = ($ID) ? '-'.$ID : '';
- $defaultFields = [
+ $config = [
'image-title'.$addID => [
'type' => 'text',
'label' => 'Image Title',
@@ -579,15 +579,6 @@
'data' => $dataID
]
];
- $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, $config, false, true);
}
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index 5b0a616..8dac9d4 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -473,6 +473,7 @@
</div>
<div class="fstatus row" hidden>
<div class="spinner"></div>
+ <i class="icon"></i>
<p class="message">'.$message.'</p>
</div>';
}
diff --git a/inc/importers/index.php b/inc/importers/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/inc/importers/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index c838f64..6e823b2 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -129,7 +129,9 @@
]
);
-// $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
}
protected function initialize(): void
diff --git a/inc/managers/CRUDManager.php b/inc/managers/CRUDManager.php
index b868510..2b62d88 100644
--- a/inc/managers/CRUDManager.php
+++ b/inc/managers/CRUDManager.php
@@ -41,7 +41,9 @@
$this->content = $content;
$this->cache = CacheManager::for($content);
- $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
// Create and configure skeleton
$this->skeleton = new CRUDSkeleton();
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 20fbfd8..bc12ee2 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -67,18 +67,21 @@
$singular = 'Dashboard';
register_post_type(BASE.'dash', array(
'labels' => [
- 'name' => $plural,
- 'singular_name' => $singular,
- 'menu_name' => $plural,
- 'add_new' => "Add New {$singular}",
- 'add_new_item' => "Add New {$singular}",
- 'edit_item' => "Edit {$singular}",
- 'new_item' => "New {$singular}",
- 'view_item' => "View {$singular}",
- 'search_items' => "Search {$plural}",
- 'not_found' => "No {$plural} found",
- 'not_found_in_trash' => "No {$plural} found in Trash"
- ],
+ 'name' => $plural,
+ 'singular_name' => $singular,
+ 'menu_name' => $plural,
+ 'name_admin_bar' => $singular,
+ 'add_new' => "Add New",
+ 'add_new_item' => "Add New {$singular}",
+ 'new_item' => "New {$singular}",
+ 'edit_item' => "Edit {$singular}",
+ 'view_item' => "View {$singular}",
+ 'all_items' => "All {$plural}",
+ 'search_items' => "Search {$plural}",
+ 'parent_item_colon' => "Parent {$plural}:",
+ 'not_found' => "No {$plural} found.",
+ 'not_found_in_trash' => "No {$plural} found in Trash.",
+ ],
'menu_icon' => jvbCSSIcon('gauge'),
'public' => true,
'publicly_queryable' => true,
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index 6f81f6f..d724af6 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -5,6 +5,7 @@
exit;
}
+use JVBase\registry\PostTypeRegistrar;
use JVBase\utility\Features;
use WP_Block;
use WP_Query;
@@ -14,60 +15,73 @@
protected array $directories;
protected array $directoryPageIDs;
protected array $directoryList;
+ protected int $perPage;
protected static string $type = BASE.'for_type';
protected static string $slug = BASE.'for_type_slug';
protected CacheManager $cache;
- public function __construct()
+ public function __construct($perPage = 100)
{
- $this->directories = $this->getDirectories();
+ $this->directories = $this->getDirectories();
if (empty($this->directories)) {
return;
}
+ $this->perPage = $perPage;
$this->cache = CacheManager::for('directory', WEEK_IN_SECONDS);
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
+
foreach(['content','taxonomy','user'] as $key) {
if (array_key_exists($key, $this->directories)) {
$this->cache->connectTo($key);
}
}
- add_action('init', [$this, 'registerDirectories']);
- jvb_register_do_once('directories_registered', [$this, 'activate']);
+ add_action('init', [$this, 'registerDirectories']);
+ jvb_register_do_once('directories_registered', [$this, 'activate']);
add_action('render_block', [$this, 'renderBlock'], 99999, 3);
}
- public function registerDirectories()
+ public function registerDirectories():void
{
- $plural = 'Directories';
- $singular = 'Directory';
- register_post_type(BASE.'directory', array(
- 'labels' => [
- 'name' => $plural,
- 'singular_name' => $singular,
- 'menu_name' => $plural,
- 'add_new' => "Add New {$singular}",
- 'add_new_item' => "Add New {$singular}",
- 'edit_item' => "Edit {$singular}",
- 'new_item' => "New {$singular}",
- 'view_item' => "View {$singular}",
- 'search_items' => "Search {$plural}",
- 'not_found' => "No {$plural} found",
- 'not_found_in_trash' => "No {$plural} found in Trash"
- ],
- 'menu_icon' => jvbCSSIcon('sort-ascending'),
- 'public' => true,
- 'publicly_queryable' => true,
- 'show_in_menu' => true,
- 'show_in_admin_bar' => false,
- 'has_archive' => true,
- 'rewrite' => array(
- 'slug' => 'directory',
- 'with_front' => false
- ),
- 'capability_type' => 'post',
- 'supports' => array('title', 'editor', 'content', 'excerpt', 'custom-fields')
- ));
+ $singular = (array_key_exists('directory_label', JVB_SITE)) ? JVB_SITE['directory_label'][0] : 'Directory';
+ $plural = (array_key_exists('directory_label', JVB_SITE)) ? JVB_SITE['directory_label'][1] : 'Directories';
+ $config = [
+ 'labels' => [
+ 'name' => $plural,
+ 'singular_name' => $singular,
+ 'menu_name' => $plural,
+ 'name_admin_bar' => $singular,
+ 'add_new' => "Add New",
+ 'add_new_item' => "Add New {$singular}",
+ 'new_item' => "New {$singular}",
+ 'edit_item' => "Edit {$singular}",
+ 'view_item' => "View {$singular}",
+ 'all_items' => "All {$plural}",
+ 'search_items' => "Search {$plural}",
+ 'parent_item_colon' => "Parent {$plural}:",
+ 'not_found' => "No {$plural} found.",
+ 'not_found_in_trash' => "No {$plural} found in Trash.",
+ ],
+ 'public' => true,
+ 'menu_icon' => jvbCSSIcon('list-dashes'),
+ 'publicly_queryable' => true,
+ 'show_in_menu' => false,
+ 'show_in_admin_bar' => false,
+ 'has_archive' => true,
+ 'hierarchical' => true,
+ 'rewrite' => [
+ 'slug' => sanitize_title(strtolower($plural)),
+ 'with_front' => false,
+ ],
+ 'capability_type' => 'post',
+ 'show_in_rest' => true,
+ 'supports'=>['title', 'author', 'thumbnail', 'editor', 'revisions', 'custom-fields', 'excerpt', 'content']
+ ];
+
+ register_post_type(BASE.'directory', $config);
}
public function getDirectories():array
@@ -118,12 +132,14 @@
public function activate()
{
+ $this->registerDirectories();
+
$created = [];
$directories = [];
foreach($this->directories as $directory => $type) {
$config = $this->getConfigFromType($directory);
- $title = $config['directory']??$config['plural'];
+ $title = $this->directoryTitle($config);
$excerpt = implode(' ', $config['description']??[]);
$ID = wp_insert_post([
'post_type' => BASE.'directory',
@@ -141,7 +157,7 @@
'slug' => $slug,
'title' => $title,
'ID' => $ID,
- 'url' => get_home_url(null, '/directory/'.$slug),
+ 'url' => get_the_permalink($ID),
'page' => $title,
'description' =>$config[$directory]['description']??[],
'type' => $type,
@@ -174,7 +190,7 @@
'slug' => $slug,
'title' => $title,
'ID' => $ID,
- 'url' => get_home_url(null, '/directory/'.$slug),
+ 'url' => get_the_permalink($ID),
'page' => $title,
'description' =>$config[$directory]['description']??[],
'type' => $type,
@@ -184,26 +200,26 @@
}
}
- if (Features::forSite()->has('has_map')) {
- $ID = wp_insert_post([
- 'post_type' => BASE.'directory',
- 'post_title' => 'Map',
- 'post_status'=> 'publish',
- 'slug' => 'map',
- ]);
- if (!is_wp_error($ID)) {
- add_post_meta($ID, self::$type, 'map');
- $created['map'] = (int)$ID;
- $directories['map'] = [
- 'slug' => 'map',
- 'title' => 'Map',
- 'ID' => $ID,
- 'url' => get_home_url(null, '/directory/map'),
- 'page' => 'Map',
- 'type' => 'term',
- ];
- }
- }
+// if (Features::forSite()->has('has_map')) {
+// $ID = wp_insert_post([
+// 'post_type' => BASE.'directory',
+// 'post_title' => 'Map',
+// 'post_status'=> 'publish',
+// 'slug' => 'map',
+// ]);
+// if (!is_wp_error($ID)) {
+// add_post_meta($ID, self::$type, 'map');
+// $created['map'] = (int)$ID;
+// $directories['map'] = [
+// 'slug' => 'map',
+// 'title' => 'Map',
+// 'ID' => $ID,
+// 'url' => get_the_permalink($ID),
+// 'page' => 'Map',
+// 'type' => 'term',
+// ];
+// }
+// }
if (!empty($created)) {
update_option(BASE.'directory_ids', $created);
@@ -319,16 +335,15 @@
return $this->cache->remember(
'archive',
function() {
- $cache = '<h1>Directory of Directories</h1>
+ $cache = '<h1>'.$this->referAs().' of '.$this->referAs(true).'</h1>
<p>You like lists? We\'ve got \'em!</p>
- <section class="directories item-grid">';
+ <ul class="directories">';
foreach ($this->directoryList as $slug => $directory) {
$config = $this->getConfigFromType($slug);
$aOpen = '<a href="'.$directory['url'].'" title="See our list of '.$directory['title'].'">';
$aClose = '</a>';
- $cache .= '<div class="directory col start">
- '.$aOpen.jvbIcon($config['icon']).$aClose.
- '<h2>'.$aOpen.$directory['title'].$aClose.'</h2>';
+ $cache .= '<li class="directory col start">
+ '. $aOpen.jvbIcon(array_key_exists('icon', $config) ? $config['icon']:'list-dashes').$directory['title'].$aClose;
if (!empty($directory['description'])) {
$cache .= '<div class="description">';
foreach ($directory['description'] as $description) {
@@ -336,9 +351,9 @@
}
$cache .= '</div>';
}
- $cache .= '</div>';
+ $cache .= '</li>';
}
- $cache .= '</section>';
+ $cache .= '</ul>';
return $cache;
}
);
@@ -366,44 +381,56 @@
);
if ($current !== '' && array_key_exists($current, $this->directories())) {
$open = ($open) ? ' open' : '';
- $cache = '<details'.$open.'><summary class="row btw">Other Directories:</summary>'.
+ $cache = '<details'.$open.'><summary class="row btw">Other '.$this->referAs(true).':</summary>'.
str_replace('id="'.$current.'"', 'id="'.$current.'" class="current"', $cache)
.'</details>';
}
return $cache;
}
- private function renderDirectory():string
- {
+private function renderDirectory(): string
+{
+ $slug = get_post_meta(get_the_ID(), self::$slug, true);
+ if ($slug === '') {
+ return '';
+ }
- $slug = get_post_meta(get_the_ID(), self::$slug, true);
- if ($slug === '') {
- return '';
- }
- $this->directories();
- return $this->cache->remember(
- $slug,
- function() use ($slug) {
- $config = $this->getConfigFromType($slug);
- $out = '<h1>'.$config['directory'].'</h1>';
- $out .= '<div class="description">';
+ $type = $this->directories[$slug];
+ $config = $this->getConfigFromType($slug);
- foreach ($config[$slug]['description']??[] as $p) {
- $out .= '<p>'.$p.'</p>';
- }
- $out .= '</div>';
- $out .= $this->renderIndex($slug);
+ $paged = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
- $type = $this->directories[$slug];
- $list = [];
- switch ($type) {
- case 'content':
- $get = new WP_Query([
- 'post_type' => jvbCheckBase($slug),
- 'posts_per_page' => -1,
- 'orderby' => 'title',
- 'order' => 'ASC'
- ]);
+ $cacheKey = $slug . '_page_' . $paged;
+
+ return $this->cache->remember(
+ $cacheKey,
+ function() use ($slug, $type, $config, $paged) {
+ $out = '<h1>' . $this->directoryTitle($config) . '</h1>';
+ $out .= '<div class="description">';
+ foreach ($config[$slug]['description'] ?? [] as $p) {
+ $out .= '<p>' . $p . '</p>';
+ }
+ $out .= '</div>';
+ $out .= $this->renderIndex($slug);
+
+ $list = [];
+ $query = null;
+
+ switch ($type) {
+ case 'content':
+ $args = [
+ 'post_type' => jvbCheckBase($slug),
+ 'posts_per_page' => $this->perPage,
+ 'paged' => $paged,
+ 'orderby' => 'title',
+ 'order' => 'ASC'
+ ];
+
+ if (Features::forContent($slug)->has('is_timeline')) {
+ $args['post_parent'] = 0;
+ }
+
+ $get = new WP_Query($args);
$hasExtra = Features::forContent($slug)->has('directory_extra');
if ($get->have_posts()) {
@@ -439,11 +466,21 @@
wp_reset_postdata();
break;
case 'taxonomy':
+ // For taxonomy, we need to manually paginate
+ $offset = ($paged - 1) * $this->perPage;
$get = get_terms([
- 'taxonomy' => jvbCheckBase($slug),
- 'hide_empty' => true,
- 'orderby' => 'name',
- 'order' => 'ASC',
+ 'taxonomy' => jvbCheckBase($slug),
+ 'hide_empty' => true,
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ 'number' => $this->perPage,
+ 'offset' => $offset,
+ ]);
+
+ // Get total for pagination
+ $total_terms = wp_count_terms([
+ 'taxonomy' => jvbCheckBase($slug),
+ 'hide_empty' => true,
]);
if ($get && !is_wp_error($get)) {
@@ -474,8 +511,14 @@
if (empty($list)) {
$out .= '<h2>Nothing here.</h2><p>We don\'t have anything here yet.</p>';
} else {
- $out .= $this->renderLettersIndex($list);
+ $out .= $this->renderLettersIndex($list, $type, $slug);
$out .= $this->renderLettersList($list, $slug);
+
+ if ($type === 'content' && $query) {
+ $out .= $this->renderPagination($query);
+ } elseif ($type === 'taxonomy' && is_numeric($total_terms)) {
+ $out .= $this->renderTaxonomyPagination($total_terms, $paged);
+ }
}
$out .= '</section>';
@@ -549,23 +592,41 @@
return $out;
}
- public function renderLettersIndex(array $list):string
- {
- $out = '<nav class="letters on-this-page"><ul>';
- foreach ($this->letters() as $l) {
- $aOpen = $aClose = $class = '';
- if (array_key_exists($l, $list)) {
- $aOpen = '<a href="#starts-with-'.$l.'">';
- $aClose = '</a>';
- $class = ' class="has"';
- }
- $out .= '<li'.$class.'>'.$aOpen.strtoupper($l).$aClose.'</li>';
- }
+ public function renderLettersIndex(array $list, string $type = '', string $slug = ''): string
+ {
+ $letters_on_page = array_keys($list);
+ $letterPageMap = [];
- $out .= '</ul></nav>';
+ if ($type !== '' && $slug !== '') {
+ $letterPageMap = $this->getLetterPageMap($type, $slug);
+ }
- return $out;
- }
+ $out = '<nav class="letters on-this-page"><ul>';
+
+ foreach ($this->letters() as $l) {
+ $aOpen = $aClose = $class = '';
+
+ if (array_key_exists($l, $list)) {
+ // Letter is on current page - link to anchor
+ $aOpen = '<a href="#starts-with-' . $l . '">';
+ $aClose = '</a>';
+ $class = ' class="has current-page"';
+ } elseif (isset($letterPageMap[$l])) {
+ // Letter exists but on different page - link to that page with GET param
+ $page = $letterPageMap[$l]['page'];
+ $url = add_query_arg('page', $page, get_permalink()) . '#starts-with-' . $l;
+ $aOpen = '<a href="' . $url . '" title="Go to page ' . $page . '">';
+ $aClose = '</a>';
+ $class = ' class="has other-page"';
+ }
+
+ $out .= '<li' . $class . '>' . $aOpen . strtoupper($l) . $aClose . '</li>';
+ }
+
+ $out .= '</ul></nav>';
+
+ return $out;
+ }
public function renderLettersList(array $list, string $type):string
{
@@ -585,10 +646,16 @@
}
$extra .= '</span>';
}
- $out .= '<li class="row btw">
+
+ $item_html = apply_filters('jvb_directory_render_item', '', $item, $type, $extra);
+
+ if (empty($item_html)) {
+ $item_html = '<li class="row btw">
<a href="'.$item['url'].'" title="More about '.$item['name'].'">
- '.$item['name'].'</a>'.$extra.
- '</li>';
+ '.$item['name'].'</a>'.$extra.'
+ </li>';
+ }
+ $out .= $item_html;
}
$out .= '</ul></li>';
@@ -623,4 +690,202 @@
return $content;
}
+
+ protected function directoryTitle(array $config):string
+ {
+ return array_key_exists('directory', $config) ? $config['directory'] : $config['plural'];
+ }
+
+ public function referAs($plural = false):string
+ {
+ if (!empty(JVB_SITE) && array_key_exists('directory_label', JVB_SITE)) {
+ return ($plural) ? JVB_SITE['directory_label'][1] : JVB_SITE['directory_label'][0];
+ }
+ return ($plural) ? 'Directories' : 'Directory';
+ }
+
+ /*****************************************************
+ * PAGINATION HELPERS
+ ****************************************************/
+ protected function renderPagination(WP_Query $query): string
+ {
+ if ($query->max_num_pages <= 1) {
+ return '';
+ }
+
+ $current = max(1, isset($_GET['page']) ? (int)$_GET['page'] : 1);
+
+ $pagination = paginate_links([
+ 'base' => add_query_arg('page', '%#%'),
+ 'format' => '',
+ 'current' => $current,
+ 'total' => $query->max_num_pages,
+ 'type' => 'array',
+ 'prev_text' => jvbIcon('arrow-square-left'),
+ 'next_text' => jvbIcon('arrow-square-right'),
+ ]);
+
+ if (!$pagination) {
+ return '';
+ }
+
+ $out = '<nav class="directory-pagination" aria-label="Directory pagination"><ul class="pagination">';
+ foreach ($pagination as $page) {
+ $out .= '<li>' . $page . '</li>';
+ }
+ $out .= '</ul></nav>';
+
+ return $out;
+ }
+
+ protected function renderTaxonomyPagination(int $total, int $paged): string
+ {
+ $max_pages = ceil($total / $this->perPage);
+
+ if ($max_pages <= 1) {
+ return '';
+ }
+
+ $current = max(1, isset($_GET['page']) ? (int)$_GET['page'] : 1);
+
+ $pagination = paginate_links([
+ 'base' => add_query_arg('page', '%#%'),
+ 'format' => '',
+ 'current' => $current,
+ 'total' => $max_pages,
+ 'type' => 'array',
+ 'prev_text' => jvbIcon('arrow-square-left'),
+ 'next_text' => jvbIcon('arrow-square-right'),
+ ]);
+
+ if (!$pagination) {
+ return '';
+ }
+
+ $out = '<nav class="directory-pagination" aria-label="Directory pagination"><ul class="pagination">';
+ foreach ($pagination as $page) {
+ $out .= '<li>' . $page . '</li>';
+ }
+ $out .= '</ul></nav>';
+
+ return $out;
+ }
+
+ protected function getLetterRanges(array $letters): array
+ {
+ if (empty($letters)) {
+ return [];
+ }
+
+ sort($letters);
+ $ranges = [];
+ $start = $letters[0];
+ $prev = $letters[0];
+
+ foreach (array_slice($letters, 1) as $letter) {
+ // Check if letters are consecutive
+ if (ord($letter) !== ord($prev) + 1) {
+ $ranges[] = ['start' => $start, 'end' => $prev];
+ $start = $letter;
+ }
+ $prev = $letter;
+ }
+
+ // Add final range
+ $ranges[] = ['start' => $start, 'end' => $prev];
+
+ return $ranges;
+ }
+ protected function getLetterPageMap(string $type, string $slug): array
+ {
+ return $this->cache->remember(
+ $slug . '_letter_page_map',
+ function() use ($type, $slug) {
+ $titles = [];
+
+ switch ($type) {
+ case 'content':
+ global $wpdb;
+ $post_type = jvbCheckBase($slug);
+
+ $where = $wpdb->prepare("post_type = %s AND post_status = 'publish'", $post_type);
+ if (Features::forContent($slug)->has('is_timeline')) {
+ $where .= " AND post_parent = 0";
+ }
+
+ $titles = $wpdb->get_col(
+ "SELECT post_title
+ FROM {$wpdb->posts}
+ WHERE {$where}
+ ORDER BY post_title"
+ );
+ break;
+
+ case 'taxonomy':
+ global $wpdb;
+ $taxonomy = jvbCheckBase($slug);
+
+ $titles = $wpdb->get_col($wpdb->prepare(
+ "SELECT t.name
+ FROM {$wpdb->terms} t
+ INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
+ WHERE tt.taxonomy = %s AND tt.count > 0
+ ORDER BY t.name ASC",
+ $taxonomy
+ ));
+ break;
+
+ case 'user':
+ $users = get_users([
+ 'role' => jvbCheckBase($slug),
+ 'orderby' => 'display_name',
+ 'order' => 'ASC',
+ 'fields' => 'display_name',
+ ]);
+ $titles = array_column($users, 'display_name');
+ break;
+ }
+
+ return $this->calculateLetterPages($titles);
+ }
+ );
+ }
+
+ protected function calculateLetterPages(array $titles): array
+ {
+ $letterCounts = [];
+
+ // Count items per letter
+ foreach ($titles as $title) {
+ $letter = strtolower(mb_substr($title, 0, 1));
+ if (!isset($letterCounts[$letter])) {
+ $letterCounts[$letter] = 0;
+ }
+ $letterCounts[$letter]++;
+ }
+
+ // Calculate which page each letter starts on
+ $letterPages = [];
+ $runningTotal = 0;
+
+ foreach ($this->letters() as $letter) {
+ if (!isset($letterCounts[$letter])) {
+ continue;
+ }
+
+ $count = $letterCounts[$letter];
+ $startPosition = $runningTotal + 1;
+ $startPage = (int)ceil($startPosition / $this->perPage);
+
+ $letterPages[$letter] = [
+ 'page' => $startPage,
+ 'count' => $count,
+ 'start_position' => $startPosition,
+ ];
+
+ $runningTotal += $count;
+ }
+
+ return $letterPages;
+ }
}
diff --git a/inc/managers/EmailManager.php b/inc/managers/EmailManager.php
index 106a387..2fdd0c6 100644
--- a/inc/managers/EmailManager.php
+++ b/inc/managers/EmailManager.php
@@ -82,13 +82,13 @@
* @param string $header Optional header text for the template
* @return bool Whether the email was sent successfully
*/
- public function sendEmail(string $to, string $subject, string $message, string $header = '', array $headers = [], array $attachments = []):bool
+ public function sendEmail(string $to, string $subject, string $message, string $header = '', string $preheader = '', array $headers = [], array $attachments = []):bool
{
// Make sure the content type is set to HTML
add_filter('wp_mail_content_type', [$this, 'setHtmlContentType']);
// Format the message with our template
- $formatted_message = $this->getEmailTemplate($message, $header);
+ $formatted_message = $this->getEmailTemplate($message, $header, $preheader);
// Send the email
$result = wp_mail($to, $subject, $formatted_message, $headers, $attachments);
@@ -105,7 +105,7 @@
*
* @return string
*/
- private function getEmailTemplate(string $content, string $headerText = ''):string
+ private function getEmailTemplate(string $content, string $headerText = '', string $preheader = ''):string
{
$custom_logo_id = get_theme_mod( 'custom_logo' );
$logo_thumbnail = wp_get_attachment_image_src( $custom_logo_id, 'custom-logo-thumbnail' );
@@ -118,6 +118,14 @@
$headerText = $this->title;
}
+ $preheaderHtml = '';
+ if (!empty($preheader)) {
+ $preheaderHtml = '
+ <div style="display:none;font-size:1px;color:#fefefe;line-height:1px;font-family:Helvetica,Arial,sans-serif;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
+ ' . esc_html($preheader) . '
+ </div>';
+ }
+
return '<!DOCTYPE html>
<html>
<head>
@@ -671,15 +679,15 @@
}
$content .= sprintf(
'<div style="padding:10px 0;border-bottom:1px solid %s;background-color:%s;">
- <span style="display:inline-block;vertical-align:top;font-weight:600;color:%s;width:%s;">%s</span>
- <div style="display:inline-block;vertical-align:top;width:%s;">%s</div>
- </div>',
+ <span style="display:inline-block;vertical-align:top;font-weight:600;color:%s;width:%s;">%s</span>
+ <div style="display:inline-block;vertical-align:top;width:%s;">%s</div>
+ </div>',
$this->colours['dark-200'],
($index%2 === 0) ? $this->colours['light-100'] : $this->colours['light-50'],
$this->colours['dark-200'],
- '19%',
+ '30%', // Changed from 19% to 30%
$item['label'],
- '80%',
+ '68%', // Changed from 80% to 68% (30% + 68% = 98%, leaving 2% for spacing)
$item['value']
);
}
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index ff9ff03..0a23c5c 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/inc/managers/SEO/BreadcrumbManager.php
@@ -24,7 +24,9 @@
private function __construct()
{
$this->cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS)->connectTo('all');
-// $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
}
public static function getInstance(): self
@@ -193,7 +195,7 @@
if (Features::forSite()->has('is_directory') && $name === 'directory') {
$crumbs[] = [
- 'name' => 'Directory',
+ 'name' => JVB()->directories()->referAs(true),
'url' => get_post_type_archive_link($type)
];
} elseif ((is_post_type_archive() || !Features::forContent($name)->has('show_directory')) && array_key_exists($name, JVB_CONTENT)) {
diff --git a/inc/managers/SEO/index.php b/inc/managers/SEO/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/inc/managers/SEO/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index fd6672a..8a28ebb 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
add_action('init', 'jvbRegisterScripts', 5);
function jvbRegisterScripts() {
- $version = '1.1.11';
+ $version = '1.1.23';
$strategy = [
'strategy' => 'defer',
'in_footer' => true
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index c53f4a7..cf3936d 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -83,22 +83,12 @@
$results = [];
$errors = [];
+ $updateTimelineOrder = [];
+
foreach ($posts as $id => $postData) {
try {
$content = $postData['content'] ?? '';
- // Timeline posts
- if (Features::forContent($content)->has('is_timeline') && isset($postData['timeline'])) {
- $parentId = (int)$id;
- if ($parentId === 0) {
- $progress->failItem($id, 'Invalid parent post ID for timeline');
- continue;
- }
- $results[$id] = $this->processTimelinePost($parentId, $postData);
- $progress->advance(1);
- continue;
- }
-
// New post creation
if (str_starts_with((string)$id, 'new')) {
$newId = wp_insert_post([
@@ -115,6 +105,11 @@
$this->savePostFields($newId, $postData);
$results[$id] = ['success' => true, 'new_id' => $newId];
+
+ if (Features::forContent($content)->has('is_timeline')) {
+ $this->updateTimelineLatestDate($newId);
+ }
+
$progress->advance(1);
continue;
}
@@ -124,8 +119,43 @@
$progress->failItem($id, 'No permission to modify this post');
continue;
}
+ // Check if this is a timeline post
+ $isTimeline = Features::forContent($content)->has('is_timeline');
+ if ($isTimeline) {
+ $post = get_post((int)$id);
+ $parentId = $post->post_parent;
+ $isParent = ($parentId === 0);
+
+ // Track timeline reordering only if date changed
+ if (array_key_exists('post_date', $postData)) {
+ $timelineRoot = $isParent ? (int)$id : $parentId;
+ if (!in_array($timelineRoot, $updateTimelineOrder)) {
+ $updateTimelineOrder[] = $timelineRoot;
+ }
+ }
+
+ // Update shared fields if this is the parent
+ if ($isParent) {
+ $this->initTimelineFields($content);
+ $sharedFieldsUpdated = array_filter($postData, function($key) {
+ return in_array($key, $this->timelineSharedFields);
+ }, ARRAY_FILTER_USE_KEY);
+
+ if (!empty($sharedFieldsUpdated)) {
+ $this->updateSharedFields((int)$id, $sharedFieldsUpdated);
+ }
+ }
+ }
$this->processPostUpdate((int)$id, $postData);
+
+ if (Features::forContent($content)->has('is_timeline')
+ && array_key_exists('post_date', $postData)) {
+ $post = get_post((int)$id);
+ $parentId = $post->post_parent === 0 ? (int)$id : $post->post_parent;
+ $this->updateTimelineLatestDate($parentId);
+ }
+
$results[$id] = ['success' => true];
$progress->advance(1);
@@ -140,6 +170,11 @@
$errors[$id] = $e->getMessage();
}
}
+ if (!empty($updateTimelineOrder)) {
+ foreach ($updateTimelineOrder as $parentID) {
+ $this->reorderTimelineByDate($parentID);
+ }
+ }
// Send notification
if (jvbSiteHasNotifications()) {
@@ -177,6 +212,7 @@
break;
case 'draft':
wp_update_post(['ID' => $postId, 'post_status' => 'draft']);
+ unset($postData['post_status']);
break;
case 'trash':
wp_trash_post($postId);
@@ -187,9 +223,201 @@
}
}
+ // Save all fields via MetaManager (handles post fields too)
$this->savePostFields($postId, $postData);
}
+ private function updateSharedFields(int $parentId, array $sharedFields): void
+ {
+ // Get all posts in timeline
+ $children = get_posts([
+ 'post_type' => get_post_type($parentId),
+ 'post_parent' => $parentId,
+ 'post_status' => ['publish', 'draft'],
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ ]);
+
+ $allPostIds = array_merge([$parentId], $children);
+
+ // Apply shared fields to all posts
+ foreach ($allPostIds as $timelinePostId) {
+ $meta = new MetaManager($timelinePostId, 'post');
+ $meta->setAll($sharedFields);
+ }
+ }
+
+ private function reorderTimelineByDate(int $parentId): void
+ {
+ $parent = get_post($parentId);
+ if (!$parent) return;
+
+ // Get all posts in this timeline (parent + children)
+ $children = get_posts([
+ 'post_type' => get_post_type($parentId),
+ 'post_parent' => $parentId,
+ 'post_status' => ['publish', 'draft'],
+ 'posts_per_page' => -1,
+ 'orderby' => 'date',
+ 'order' => 'ASC'
+ ]);
+
+ // Combine and sort by post_date
+ $allPosts = array_merge([$parent], $children);
+ usort($allPosts, function($a, $b) {
+ return strtotime($a->post_date) <=> strtotime($b->post_date);
+ });
+
+ $newParent = $allPosts[0];
+
+ // If parent changed, restructure
+ if ($newParent->ID !== $parentId) {
+ wp_update_post([
+ 'ID' => $newParent->ID,
+ 'post_parent' => 0,
+ 'menu_order' => 0
+ ]);
+
+ wp_update_post([
+ 'ID' => $parentId,
+ 'post_parent' => $newParent->ID
+ ]);
+
+ foreach ($allPosts as $index => $post) {
+ if ($index === 0) continue;
+
+ wp_update_post([
+ 'ID' => $post->ID,
+ 'post_parent' => $newParent->ID,
+ 'menu_order' => $index
+ ]);
+
+ $this->getOrCreateTerm($post->ID, (string)$index, 'number');
+ }
+ } else {
+ // Just update menu_order
+ foreach ($allPosts as $index => $post) {
+ if ($index === 0) continue;
+
+ wp_update_post([
+ 'ID' => $post->ID,
+ 'menu_order' => $index
+ ]);
+
+ $this->getOrCreateTerm($post->ID, (string)$index, 'number');
+ }
+ }
+
+ // Calculate and set timeline taxonomy (time since previous post)
+ $previousPost = null;
+ foreach ($allPosts as $index => $post) {
+ if ($index === 0) {
+ // Parent post - no timeline term (it's the baseline)
+ wp_set_object_terms($post->ID, [], BASE . 'timeline', false);
+ $previousPost = $post;
+ continue;
+ }
+
+ $timelineTerm = $this->calculateTimelineTerm($previousPost, $post);
+ if ($timelineTerm) {
+ $this->getorCreateTerm($post->ID, $timelineTerm, 'timeline');
+ }
+
+ $previousPost = $post;
+ }
+
+ $this->updateTimelineLatestDate($newParent->ID);
+ }
+
+ private function updateTimelineLatestDate(int $parentId): void
+ {
+ $parent = get_post($parentId);
+ if (!$parent) return;
+
+ // Get all posts in timeline
+ $children = get_posts([
+ 'post_type' => get_post_type($parentId),
+ 'post_parent' => $parentId,
+ 'post_status' => ['publish', 'draft'],
+ 'posts_per_page' => -1,
+ 'orderby' => 'date',
+ 'order' => 'DESC', // Get newest first
+ 'fields' => 'ids'
+ ]);
+
+ $allPostIds = array_merge([$parentId], $children);
+
+ // Get all timestamps
+ $timestamps = array_map(function($id) {
+ $post = get_post($id);
+ return $post ? strtotime($post->post_date) : 0;
+ }, $allPostIds);
+
+ $latestTimestamp = max($timestamps);
+
+ // Store as UNIX timestamp
+ update_post_meta($parentId, BASE . 'latest_date', $latestTimestamp);
+ }
+
+ private function calculateTimelineTerm(\WP_Post $previousPost, \WP_Post $currentPost): ?string
+ {
+ $previousDate = strtotime($previousPost->post_date);
+ $currentDate = strtotime($currentPost->post_date);
+
+ if (!$previousDate || !$currentDate || $currentDate <= $previousDate) {
+ return null;
+ }
+
+ // Calculate difference in days
+ $daysDiff = floor(($currentDate - $previousDate) / (60 * 60 * 24));
+
+ // Convert to weeks
+ $weeks = floor($daysDiff / 7);
+
+ // If less than 16 weeks, use weeks
+ if ($weeks < 16) {
+ if ($weeks === 0) {
+ return null; // Same week, no term
+ }
+ return $weeks === 1 ? '1 Week' : $weeks . ' Weeks';
+ }
+
+ // 16+ weeks, calculate months
+ // Using actual month calculation rather than weeks/4
+ $previousDateTime = new \DateTime($previousPost->post_date);
+ $currentDateTime = new \DateTime($currentPost->post_date);
+ $interval = $previousDateTime->diff($currentDateTime);
+
+ $months = ($interval->y * 12) + $interval->m;
+
+ if ($months === 0) {
+ // Edge case: technically less than a full month but 16+ weeks
+ return $weeks . ' Weeks';
+ }
+
+ return $months === 1 ? '1 Month' : $months . ' Months';
+ }
+
+ private function getOrCreateTerm(int $postID, string $termName, string $taxonomy): void
+ {
+ $taxonomy = jvbCheckBase($taxonomy);
+ $term = get_term_by('name', $termName, $taxonomy);
+
+ if (!$term) {
+ $result = wp_insert_term($termName, $taxonomy);
+ if (is_wp_error($result)) {
+ return;
+ }
+ $termID = $result['term_id'];
+ } else {
+ $termID = $term->term_id;
+ }
+
+ if ($termID) {
+ wp_set_object_terms($postID, [$termID], $taxonomy, false);
+ }
+ }
+
private function savePostFields(int $postId, array $postData): bool
{
$content = $postData['content'] ?? '';
@@ -325,9 +553,11 @@
$results = [];
foreach ($files as $img) {
+ $title = $this->generatePostTitle($data['content']);
$postId = wp_insert_post([
'post_type' => $this->postType,
- 'post_title' => $this->generatePostTitle($data['content']),
+ 'post_title' => $title,
+ 'post_slug' => sanitize_title($title),
'post_status' => 'draft',
'post_author' => $operation->userId,
]);
@@ -342,202 +572,6 @@
}
// ─────────────────────────────────────────────────────────────
- // Timeline Processing
- // ─────────────────────────────────────────────────────────────
-
- private function processTimelinePost(int $parentId, array $postData): array
- {
- if (!$this->verifyOwnership($parentId)) {
- return ['success' => false, 'message' => 'No permission'];
- }
-
- $content = $postData['content'];
- $this->initTimelineFields($content);
-
- $parentPost = get_post($parentId);
- $parentIsPublished = ($parentPost->post_status === 'publish');
-
- // Extract shared data (excluding post_thumbnail)
- $sharedData = array_filter($postData, function ($key) {
- return in_array($key, $this->timelineSharedFields)
- && !in_array($key, ['content', 'user'])
- && $key !== 'post_thumbnail';
- }, ARRAY_FILTER_USE_KEY);
-
- if (!isset($sharedData['post_title']) && isset($postData['timeline'][0]['post_title'])) {
- $sharedData['post_title'] = $postData['timeline'][0]['post_title'];
- }
-
- if (!isset($postData['timeline']) || !is_array($postData['timeline'])) {
- return ['success' => false, 'message' => 'No timeline data'];
- }
-
- // Validate parent is in timeline
- $index = array_search((string)$parentId, array_column($postData['timeline'], 'id'));
- if ($index === false) {
- return ['success' => false, 'message' => 'Missing parent id'];
- }
-
- // Handle parent reordering if needed
- if ($index !== 0) {
- $parentId = $this->reorderTimelineParent($parentId, $postData['timeline'], $index);
- $parentPost = get_post($parentId);
- $parentIsPublished = ($parentPost->post_status === 'publish');
- }
-
- // Shared taxonomies (excluding title and thumbnail)
- $sharedTaxonomies = array_filter($sharedData, function ($key) {
- return !in_array($key, ['post_title', 'post_thumbnail']);
- }, ARRAY_FILTER_USE_KEY);
-
- $existingChildren = get_children([
- 'post_parent' => $parentId,
- 'orderby' => 'menu_order',
- 'post_status' => ['publish', 'draft'],
- 'fields' => 'ids',
- ]);
-
- $errors = [];
- $success = [];
-
- foreach ($postData['timeline'] as $order => $timeline) {
- $result = $this->processTimelineEntry(
- $timeline,
- $order,
- $parentId,
- $parentIsPublished,
- $sharedTaxonomies,
- $existingChildren,
- $content
- );
-
- if ($result['success']) {
- $success[] = $result;
- if (isset($result['child_id']) && in_array($result['child_id'], $existingChildren)) {
- unset($existingChildren[array_search($result['child_id'], $existingChildren)]);
- }
- } else {
- $errors[] = $result;
- }
- }
-
- // Trash orphaned children
- foreach ($existingChildren as $orphanId) {
- wp_trash_post($orphanId);
- }
-
- return [
- 'success' => empty($errors),
- 'updated' => count($success),
- 'errors' => $errors,
- ];
- }
-
- private function processTimelineEntry(
- array $timeline,
- int $order,
- int $parentId,
- bool $parentIsPublished,
- array $sharedTaxonomies,
- array &$existingChildren,
- string $content
- ): array {
- $isParent = ((int)($timeline['id'] ?? 0) === $parentId);
-
- // Get unique fields for this entry
- $allowedFields = array_filter($timeline, function ($key) {
- return in_array($key, $this->timelineUniqueFields) && !in_array($key, ['content', 'user']);
- }, ARRAY_FILTER_USE_KEY);
-
- // Determine title
- $providedTitle = $timeline['post_title'] ?? '';
- $autoPattern = '/^.+Treatment #?\d+$/';
-
- if ($isParent) {
- $allowedFields['post_title'] = $providedTitle ?: ($sharedTaxonomies['post_title'] ?? get_post($parentId)->post_title);
- } else {
- if (empty($providedTitle) || preg_match($autoPattern, $providedTitle)) {
- $allowedFields['post_title'] = 'Treatment ' . $order;
- } else {
- $allowedFields['post_title'] = $providedTitle;
- }
- }
-
- $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
-
- // Create child if needed
- $childId = $timeline['id'] ?? null;
- if (!$childId || !is_numeric($childId)) {
- $childId = wp_insert_post([
- 'post_author' => $this->userId,
- 'post_type' => jvbCheckBase($content),
- 'post_title' => $allowedFields['post_title'],
- 'post_parent' => $parentId,
- 'menu_order' => $order,
- 'post_status' => $parentIsPublished ? 'publish' : 'draft',
- ]);
-
- if (!$childId || is_wp_error($childId)) {
- return ['success' => false, 'message' => 'Could not create child post'];
- }
- }
-
- // Update post
- $postUpdates = ['ID' => $childId];
- if (!$isParent) {
- $postUpdates['menu_order'] = $order;
- if ($parentIsPublished) {
- $currentPost = get_post($childId);
- if ($currentPost && $currentPost->post_status !== 'publish') {
- $postUpdates['post_status'] = 'publish';
- }
- }
- }
-
- if (isset($allowedFields['post_title'])) {
- $postUpdates['post_title'] = $allowedFields['post_title'];
- unset($allowedFields['post_title']);
- }
-
- wp_update_post($postUpdates);
-
- // Save meta fields
- if (!empty($allowedFields)) {
- $meta = new MetaManager($childId, 'post');
- $meta->setAll($allowedFields);
- }
-
- return ['success' => true, 'child_id' => $childId];
- }
-
- private function reorderTimelineParent(int $currentParentId, array $timeline, int $currentIndex): int
- {
- $newParentId = $timeline[0]['id'] ?? null;
-
- if (!is_numeric($newParentId) || (int)$newParentId <= 0) {
- return $currentParentId;
- }
-
- $newParentId = (int)$newParentId;
-
- // Make new parent a top-level post
- wp_update_post(['ID' => $newParentId, 'post_parent' => 0]);
-
- // Make old parent a child
- wp_update_post(['ID' => $currentParentId, 'post_parent' => $newParentId]);
-
- // Move existing children to new parent
- $existingChildren = get_children(['post_parent' => $currentParentId, 'fields' => 'ids']);
- foreach ($existingChildren as $childId) {
- if ($childId !== $newParentId) {
- wp_update_post(['ID' => $childId, 'post_parent' => $newParentId]);
- }
- }
-
- return $newParentId;
- }
-
- // ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index 10accf8..c71e680 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -398,6 +398,7 @@
'post_author' => $user,
'post_status' => 'draft',
'post_title' => $title,
+ 'post_slug' => sanitize_title($title),
'post_excerpt' => $excerpt
];
diff --git a/inc/managers/queue/executors/index.php b/inc/managers/queue/executors/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/inc/managers/queue/executors/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/inc/managers/queue/index.php b/inc/managers/queue/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/inc/managers/queue/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index bfb93eb..ea8f614 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -202,6 +202,7 @@
$validationAttrs = $this->buildValidationAttributes($field);
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+ $pattern = array_key_exists('pattern', $field) ? $field['pattern'] : '';
$customData = '';
if (array_key_exists('data', $field) && !empty($field['data'])) {
foreach ($field['data'] as $key => $v) {
@@ -225,6 +226,7 @@
value="<?= esc_attr($data['value']) ?>"
<?= $inputAttrs ?>
<?= $customData?>
+ <?= $pattern?>
>
<span class="validation-icon success" hidden aria-hidden="true">
<?= jvbIcon('check-circle') ?>
@@ -789,7 +791,9 @@
{
foreach ($field['fields'] as $field_name => $config) {
// Set the group context for proper field naming
- $config['group'] = $groupName;
+ if (!array_key_exists('wrap', $field) || $field['wrap'] !== 'details') {
+ $config['group'] = $groupName;
+ }
// Get the value for this specific field
$field_value = $values[$field_name] ?? '';
@@ -843,7 +847,7 @@
// Build accept attribute for input
$acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptExtensions);
+ $acceptAttr = implode(',', $acceptedTypes);
// Determine field attributes
$subtype = $config['subtype'] ?? 'image';
@@ -1138,15 +1142,12 @@
<summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
<?php
- $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
- // Only add image_data if not already provided
- if (!array_key_exists('image_data', $fields)) {
- $fields['image_data'] = [
- 'type' => 'group',
- 'wrap' => 'details',
- 'label' => 'Image Info',
- 'hint' => 'These will be automatically generated if left blank.',
+ $fields = [
+ 'image_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Fields',
'fields' => [
'image-title'.$addID => [
'type' => 'text',
@@ -1168,9 +1169,9 @@
'data' => $dataID
]
]
- ];
- }
-
+ ]
+ ];
+ $fields = array_key_exists('fields', $config) ? array_merge($fields, $config['fields']) : $fields;
$meta = new MetaManager($id);
foreach ($fields as $field => $config) {
$meta->render('form', $field, $config);
@@ -1594,11 +1595,11 @@
// Default types based on subtype
$defaults = [
- 'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
- 'video' => ['video/mp4', 'video/webm', 'video/ogg'],
- 'document' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
- 'any' => ['image/*', 'video/*', 'application/pdf']
+ 'image' => ['image/*'],
+ 'video' => ['video/*'],
+ 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
];
+ $defaults['any'] = array_merge($defaults['image'], $defaults['video'], $defaults['document']);
return $defaults[$config['subtype']] ?? $defaults['image'];
}
diff --git a/inc/meta/MetaSanitizer.php b/inc/meta/MetaSanitizer.php
index f4f573f..6f722f7 100644
--- a/inc/meta/MetaSanitizer.php
+++ b/inc/meta/MetaSanitizer.php
@@ -22,12 +22,10 @@
public function sanitize(mixed $value, array $field_config):mixed
{
$callback = $this->getCallback($field_config);
- error_log('Callback: '.print_r($callback, true));
if (is_array($callback)) {
return call_user_func([$this, $callback[1]], $value, $field_config);
}
if (method_exists($this, $callback)) {
- error_log('Method exists');
return $this->$callback($value, $field_config);
} else {
return call_user_func($callback, $value);
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index a54967e..852a9e9 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/inc/registry/CheckCustomTables.php
@@ -498,9 +498,9 @@
`peak_memory_usage` int DEFAULT NULL,
`peak_cpu_usage` float DEFAULT NULL,
-
- `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
+ `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
UNIQUE KEY (date, type),
KEY `date_idx` (date),
KEY `type_idx` (type)
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index cd753c4..13a75f8 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -80,7 +80,6 @@
if ($this->config['is_timeline']??false) {
}
-
register_post_type($this->post_type, $args);
if (!empty($this->fields)) {
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index f017e9c..37db58e 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -344,7 +344,12 @@
$args['orderby'] = 'meta_value_num';
break;
default:
- $args['orderby'] = 'date';
+ if ($this->isTimeline($args, $data)) {
+ $args['meta_key'] = BASE . 'latest_date';
+ $args['orderby'] = 'meta_value_num';
+ }else {
+ $args['orderby'] = 'date';
+ }
}
}
$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
@@ -353,6 +358,17 @@
return $args;
}
+ protected function isTimeline($args, $data):bool
+ {
+ $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
+ foreach ($post_types as $type) {
+ if (Features::forContent($type)->has('is_timeline')) {
+ return true;
+ }
+ }
+ return false;
+ }
+
protected function applyDateFilters(array $args, array $data):array
{
if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 8ff8c78..3fc50bb 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -36,7 +36,10 @@
{
$this->cache_name = 'user_content_' . get_current_user_id();
parent::__construct();
- $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
+
$this->action = 'dash-';
$this->operation_type = 'content_update';
add_action('init', [$this, 'registerContentExecutors'], 5);
@@ -613,6 +616,8 @@
return ['success' => false, 'message' => 'No permission'];
}
+ error_log('[Processing Timeline Post...');
+
$ignore = ['content', 'user'];
$this->fields = jvbGetFields($post_data['content']);
$this->initTimelineFields($post_data['content']);
@@ -701,7 +706,8 @@
]);
$prevDate = null;
-
+ $latest_date = null;
+ $earliest_date = null;
foreach ($post_data['timeline'] as $order => $timeline) {
// Get unique fields for this specific timeline entry
$allowedFields = array_filter($timeline, function ($key) use ($ignore) {
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 05a97a7..9988cf2 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -32,12 +32,14 @@
$this->cache_name = 'feed';
$this->cache_ttl = 86400;
parent::__construct();
- $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
+
}
public function init():void
{
- $this->cache->clear();
$this->checker = Checker::getInstance();
if (Features::hasIntegration('umami')) {
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index 1201a8e..c5c0d36 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -142,7 +142,6 @@
{
// Get form configuration
$form_config = FormBlock::getForm($form_type);
-
if (!$form_config) {
return new WP_Error('invalid_form', 'Form configuration not found.');
}
@@ -178,7 +177,6 @@
try {
$email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data, $files);
} catch (\Exception $e) {
- ;
return new WP_Error('email_failed', 'Email error: ' . $e->getMessage());
}
@@ -226,7 +224,7 @@
$value = $normalized_form_data[$field_name] ?? '';
if (in_array($field_config['type'], ['checkbox', 'set'])) {
- $value = jvbCommaList((is_array($value)) ? $value : explode(',',$value));
+ $value = is_array($value) ? implode(',', $value) : $value;
}
// Check required fields
@@ -267,9 +265,6 @@
/**
* Send email notification
*/
- /**
- * Send email notification
- */
protected function sendEmailNotification(string $form_type, array $form_config, array $form_data, array $files = []): bool
{
$admin_email = apply_filters('jvb_form_email_to', $form_config['email_to'], $form_type, $form_data);
@@ -286,6 +281,17 @@
$submitter_name = $form_data['name'];
}
+ if (!array_key_exists('preheader', $form_config)) {
+ $preheader = $form_config['preheader'];
+ } else {
+ $preheader = sprintf(
+ 'New %s submission from %s',
+ $form_config['title'],
+ $submitter_name ?: ($submitter_name ?: 'website visitor')
+ );
+ }
+
+
// Email headers
$headers = [];
@@ -319,23 +325,36 @@
);
}
- // Build form data array for table
+ // Build form data array for table - using similar logic to JS
+ $skip_fields = array_merge(
+ ['sendAll', 'action', 'form_id', 'form_type', 'timestamp', '_wpnonce', '_wp_http_referer', 'cf-turnstile-response'],
+ $form_config['ignore'] ?? []
+ );
+
$form = [];
foreach ($form_config['fields'] as $field_name => $config) {
- // Skip file upload fields
- if ($config['type'] == 'upload') {
+ // Skip ignored fields
+ if (in_array($field_name, $skip_fields)) {
+ continue;
+ }
+
+ // Skip upload fields (handled separately)
+ if ($config['type'] === 'upload') {
continue;
}
$value = $form_data[$field_name] ?? '';
// Skip empty fields
- if (empty($value)) {
+ if ($this->isEmptyValue($value)) {
continue;
}
+ // Get label - check for summaryTitle first, then label (similar to JS checking legend then label)
+ $label = $config['summaryTitle'] ?? $config['label'] ?? ucfirst(str_replace('_', ' ', $field_name));
+
$form[] = [
- 'label' => $config['summaryTitle'] ?? $config['label'],
+ 'label' => $label,
'value' => $this->formatFieldValueForEmail($field_name, $value, $config)
];
}
@@ -359,17 +378,34 @@
$subject,
$body,
'',
+ $preheader,
$headers,
$attachments
);
}
/**
+ * Check if value is empty (similar to JS isEmptyValue)
+ */
+ protected function isEmptyValue($value): bool
+ {
+ if ($value === null || $value === '' || $value === false) {
+ return true;
+ }
+
+ if (is_array($value) && empty($value)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
* Format field value for email display
*/
protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string
{
- if (empty($value)) {
+ if ($this->isEmptyValue($value)) {
return '';
}
@@ -377,9 +413,17 @@
$type = $field_config['subType'] ?? $type;
switch ($type) {
+ case 'repeater':
+ return $this->formatRepeaterForEmail($value, $field_config);
+
+ case 'tag-list':
+ return $this->formatTagListForEmail($value);
+
+ case 'location':
+ return $this->formatLocationForEmail($value);
+
case 'phone':
case 'tel':
- // Format phone number as xxx-xxx-xxxx
$cleaned = preg_replace('/\D/', '', $value);
if (strlen($cleaned) === 10) {
return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6);
@@ -388,51 +432,101 @@
case 'checkbox':
case 'set':
- // Convert array of values to comma-separated labels
- if (!is_array($value)) {
- $value = explode(',', $value);
- }
- $labels = [];
- foreach ($value as $val) {
+ $values = explode(',', $value);
+ $labels = array_map(function($val) use ($field_config) {
$val = trim($val);
- if (isset($field_config['options'][$val])) {
- $labels[] = $field_config['options'][$val];
- } else {
- $labels[] = $val; // Fallback to value if no label found
- }
- }
+ return $field_config['options'][$val] ?? ucfirst(str_replace('_', ' ', $val));
+ }, $values);
return implode(', ', $labels);
case 'radio':
case 'select':
- // Return label instead of value
if (isset($field_config['options'][$value])) {
return $field_config['options'][$value];
}
- return $value;
-
- case 'textarea':
- case 'wysiwyg':
- // Keep line breaks for email display
- return $value; // nl2br is already applied in the email body
+ // Fallback: capitalize the value
+ return ucfirst(str_replace('_', ' ', $value));
case 'true_false':
$true_text = $field_config['true_text'] ?? 'Yes';
$false_text = $field_config['false_text'] ?? 'No';
return ($value === '1' || $value === 1 || $value === true) ? $true_text : $false_text;
- case 'email':
- return $value; // Will be clickable in email client
-
- case 'url':
- return $value; // Will be clickable in email client
-
default:
return is_array($value) ? implode(', ', $value) : $value;
}
}
/**
+ * Format repeater field for email
+ */
+ protected function formatRepeaterForEmail(array $rows, array $field_config): string
+ {
+ $output = '';
+
+ foreach ($rows as $index => $row) {
+ $output .= '<strong>Entry ' . ($index + 1) . ':</strong><br>';
+
+ foreach ($row as $sub_field => $sub_value) {
+ if ($this->isEmptyValue($sub_value)) {
+ continue;
+ }
+
+ // Get sub-field label if available
+ $sub_config = $field_config['sub_fields'][$sub_field] ?? [];
+ $label = $sub_config['label'] ?? ucfirst(str_replace('_', ' ', $sub_field));
+
+ $output .= ' ' . esc_html($label) . ': ' . esc_html($sub_value) . '<br>';
+ }
+
+ $output .= '<br>';
+ }
+
+ return $output;
+ }
+
+ /**
+ * Format tag-list field for email
+ */
+ protected function formatTagListForEmail(array $tags): string
+ {
+ $items = [];
+
+ foreach ($tags as $tag) {
+ // Get first non-empty value as display
+ $display = '';
+ foreach ($tag as $field => $value) {
+ if (!$this->isEmptyValue($value)) {
+ $display = $value;
+ break;
+ }
+ }
+
+ if ($display) {
+ $items[] = $display;
+ }
+ }
+
+ return implode(', ', $items);
+ }
+
+ /**
+ * Format location field for email
+ */
+ protected function formatLocationForEmail(array $location): string
+ {
+ $parts = [];
+
+ if (!empty($location['street'])) $parts[] = $location['street'];
+ if (!empty($location['city'])) $parts[] = $location['city'];
+ if (!empty($location['province'])) $parts[] = $location['province'];
+ if (!empty($location['postal_code'])) $parts[] = $location['postal_code'];
+ if (!empty($location['country'])) $parts[] = $location['country'];
+
+ return !empty($parts) ? implode(', ', $parts) : ($location['address'] ?? '');
+ }
+
+ /**
* Process uploaded files for email attachments
* Files are in PHP's tmp directory and will be automatically cleaned up after request
*/
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 9762e9f..8c5d9d0 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -110,7 +110,6 @@
public function handleLogin(WP_REST_Request $request): WP_REST_Response
{
$data = $request->get_params();
- error_log('Data: '.print_r($data, true));
// Verify Turnstile
if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
return $this->error('Security verification failed', 'turnstile_failed', 403);
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index a6edcb1..eb39e8b 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -441,9 +441,9 @@
'total' => $total_count,
'page' => $offset,
'per_page' => $limit,
- 'pages' => ceil($total_count / $limit),
- 'has_more' => ($offset * $limit + count($notifications)) < $total_count
- ]
+ 'pages' => ceil($total_count / $limit)
+ ],
+ 'has_more' => ($offset * $limit + count($notifications)) < $total_count
];
// Cache the result
@@ -462,9 +462,9 @@
'total' => 0,
'page' => $offset,
'per_page' => $limit,
- 'pages' => 0,
- 'has_more' => false
- ]
+ 'pages' => 0
+ ],
+ 'has_more' => false
]);
}
}
@@ -835,9 +835,9 @@
'total' => (int)$total_count,
'page' => $offset,
'per_page' => $limit,
- 'pages' => ceil($total_count / $limit),
- 'has_more' => ($offset + $limit) < $total_count
- ]
+ 'pages' => ceil($total_count / $limit)
+ ],
+ 'has_more' => ($offset + $limit) < $total_count
];
// Cache the results
@@ -857,9 +857,9 @@
'total' => 0,
'page' => $offset,
'per_page' => $limit,
- 'pages' => 0,
- 'has_more' => false
- ]
+ 'pages' => 0
+ ],
+ 'has_more' => false
];
}
}
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 824ec55..b26bc70 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -22,7 +22,9 @@
$this->cache_ttl = 300;
parent::__construct();
- $this->cache->clear();
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
}
/**
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index ec029c8..5dc3b87 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -22,7 +22,9 @@
{
$this->cache_name = 'terms';
parent::__construct();
-// $this->cache->invalidateGroup('terms');
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
$this->per_page = 20;
add_action('edited_term', [$this, 'deleteTermPath']);
@@ -200,7 +202,7 @@
$args = [
'taxonomy' => $taxonomy,
'include' => $data['termIDs'],
- 'hide_empty' => false,
+ 'hide_empty' => true,
];
$key = $this->cache->generateKey($args);
$cached = $this->cache->get($key);
@@ -241,9 +243,9 @@
'page' => 1,
'per_page' => $per_page,
'total_pages' => 0,
- 'total_terms' => 0,
- 'has_more' => false
- ]
+ 'total_terms' => 0
+ ],
+ 'has_more' => false
]);
}
@@ -260,7 +262,7 @@
// Get terms for current level with child count
$args = [
'taxonomy' => $taxonomy,
- 'hide_empty' => false,
+ 'hide_empty' => true,
'parent' => $parent,
'number' => $per_page,
'orderby'=> 'name',
@@ -284,8 +286,8 @@
'per_page' => $per_page,
'total_pages' => 0,
'total_terms' => 0,
- 'has_more' => false
- ]
+ ],
+ 'has_more' => false
]);
return $this->addCacheHeaders($response);
}
@@ -306,9 +308,9 @@
'page' => 1,
'per_page' => $per_page,
'total_pages' => 0,
- 'total_terms' => 0,
- 'has_more' => false
- ]
+ 'total_terms' => 0
+ ],
+ 'has_more' => false
]);
return $this->addCacheHeaders($response);
}
@@ -365,9 +367,9 @@
'page' => 1,
'per_page' => $per_page,
'total_pages' => 0,
- 'total_terms' => 0,
- 'has_more' => false
- ]
+ 'total_terms' => 0
+ ],
+ 'has_more' => false
]);
return $this->addCacheHeaders($response);
@@ -403,9 +405,9 @@
'page' => $page,
'per_page' => $per_page,
'total_pages' => $total_pages,
- 'total_terms' => (int)$total_terms,
- 'has_more' => $has_more
- ]
+ 'total_terms' => (int)$total_terms
+ ],
+ 'has_more' => $has_more
];
$this->cache->set($key, $response);
@@ -442,9 +444,9 @@
'items' => $all_terms,
'pagination'=> [
'page' => $page,
- 'per_page'=> $per_page,
- 'has_more' => true,
- ]
+ 'per_page'=> $per_page
+ ],
+ 'has_more' => true,
];
$response = new WP_REST_Response($response);
@@ -533,7 +535,7 @@
// When searching, we want to search across all terms regardless of hierarchy
$args = [
'taxonomy' => $taxonomy,
- 'hide_empty' => false,
+ 'hide_empty' => true,
'search' => $search,
'search_columns' => ['name', 'slug'],
'fields' => 'all',
@@ -556,9 +558,9 @@
'page' => 0,
'per_page' => 20,
'total_pages' => 0,
- 'total_terms' => 0,
- 'has_more' => false
- ]
+ 'total_terms' => 0
+ ],
+ 'has_more' => false
]);
}
@@ -582,9 +584,9 @@
'page' => (int)$page,
'per_page' => (int)$per_page,
'total_pages' => $total_pages,
- 'total_terms' => (int)$total_terms,
- 'has_more' => $has_more
- ]
+ 'total_terms' => (int)$total_terms
+ ],
+ 'has_more' => $has_more
];
$this->cache->set($key, $response);
@@ -776,9 +778,9 @@
'page' => (int)$page,
'per_page' => (int)$per_page,
'total_terms'=> $total,
- 'total_pages'=> $total_pages,
- 'has_more' => $page < $total_pages
- ]
+ 'total_pages'=> $total_pages
+ ],
+ 'has_more' => $page < $total_pages
];
// Cache results
@@ -817,7 +819,7 @@
// Build query args
$args = [
'taxonomy' => $taxonomy,
- 'hide_empty' => false,
+ 'hide_empty' => true,
'orderby' => $search ? 'name' : 'count',
'order' => $search ? 'ASC' : 'DESC',
'number' => $per_page,
@@ -840,9 +842,9 @@
'page' => 0,
'per_page' => 20,
'total_pages' => 0,
- 'total_terms' => 0,
- 'has_more' => 0
- ]
+ 'total_terms' => 0
+ ],
+ 'has_more' => 0
]);
}
@@ -866,9 +868,9 @@
'page' => (int)$page,
'per_page' => (int)$per_page,
'total_pages' => $total_pages,
- 'total_terms' => (int)$total,
- 'has_more' => $page < $total_pages
- ]
+ 'total_terms' => (int)$total
+ ],
+ 'has_more' => $page < $total_pages
];
// Cache results
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index eb3c211..1da0412 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -783,14 +783,8 @@
try {
$data = $request->get_params();
- // Validate user permissions
- if (!$this->userCheck($data['user'])) {
- return $this->sendResponse(
- false,
- ['error_code' => 'invalid_user'],
- 'Invalid user specified'
- );
- }
+ error_log('Received data for meta change: '.print_r($data, true));
+
$items = $data['items']??false;
if (!$items) {
@@ -803,12 +797,12 @@
}
$pending = [];
$attachments = array_filter($items, function ($item) {
- return is_numeric($item);
- }, ARRAY_FILTER_USE_KEY);
+ return array_key_exists('attachmentId', $item);
+ });
if (count($attachments) !== count($items)) {
$pending = array_filter($items, function ($item) {
- return !is_numeric($item);
- }, ARRAY_FILTER_USE_KEY);
+ return array_key_exists('uploadId',$item);
+ });
}
@@ -851,9 +845,12 @@
{
$updated_count = 0;
$errors = [];
-
- foreach ($data as $attachment_id => $info) {
+ $ids = [];
+ foreach ($data as $info) {
try {
+ $attachment_id = $info['attachmentId'];
+ $ids[] = $attachment_id;
+ unset($info['attachmentId']);
// Verify attachment exists and user has permission
if (!$this->verifyAttachmentAccess($attachment_id, $user)) {
$errors[] = "No permission to edit attachment {$attachment_id}";
@@ -873,7 +870,7 @@
[
'updated_count' => $updated_count,
'errors' => $errors,
- 'attachment_ids' => $data['attachment_ids']
+ 'attachment_ids' => $ids
],
$updated_count > 0 ?
"Updated metadata for {$updated_count} attachment(s)" :
@@ -993,18 +990,18 @@
protected function applyMeta(int $attachment_id, array $metadata): void
{
// Update alt text
- if (!empty($metadata['alt'])) {
- update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($metadata['alt']));
+ if (!empty($metadata['image-alt-text'])) {
+ update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($metadata['image-alt-text']));
}
$postUpdates = [];
// Update title
- if (!empty($metadata['title'])) {
- $postUpdates['post_title'] = $metadata['title'];
+ if (!empty($metadata['image-title'])) {
+ $postUpdates['post_title'] = $metadata['image-title'];
}
// Update caption
- if (!empty($metadata['caption'])) {
- $postUpdates['post_excerpt'] = sanitize_textarea_field($metadata['caption']);
+ if (!empty($metadata['image-caption'])) {
+ $postUpdates['post_excerpt'] = sanitize_textarea_field($metadata['image-caption']);
}
if (!empty($postUpdates)) {
diff --git a/inc/ui/CRUDSkeleton.php b/inc/ui/CRUDSkeleton.php
index 521de4f..515c0d5 100644
--- a/inc/ui/CRUDSkeleton.php
+++ b/inc/ui/CRUDSkeleton.php
@@ -346,9 +346,7 @@
}
return false;
}));
- 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) {
@@ -357,6 +355,7 @@
return false;
}));
+
$all = array_merge($this->timelineUniqueFields, $this->timelineSharedFields);
$this->nonTimelineFields = array_filter($this->fields, function ($field) use ($all) {
return !in_array($field, $all);
diff --git a/inc/ui/index.php b/inc/ui/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/inc/ui/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/inc/utility/Image.php b/inc/utility/Image.php
index d9a9515..4901435 100644
--- a/inc/utility/Image.php
+++ b/inc/utility/Image.php
@@ -17,6 +17,9 @@
public function __construct()
{
$this->cache = CacheManager::for('images')->connectTo('post', 'attachment');
+ if (JVB_TESTING) {
+ $this->cache->clear();
+ }
}
public function formatImage(int $ID, string $start = 'tiny', string $replace = 'large', bool $addLink = true, ?string $postSlug = null):string
@@ -25,7 +28,7 @@
['ID' => $ID, 'start' => $start, 'replace' => $replace],
function() use ($ID, $start, $replace) {
// Define size order for progressive enhancement
- $sizeOrder = ['tiny', 'medium', 'large', 'full'];
+ $sizeOrder = ['tiny', 'directory-preview', 'thumbnail', 'medium', 'large', 'full'];
$startIndex = array_search($start, $sizeOrder);
$replaceIndex = array_search($replace, $sizeOrder);
diff --git a/jvb.php b/jvb.php
index 7989497..67b2174 100644
--- a/jvb.php
+++ b/jvb.php
@@ -82,7 +82,8 @@
return [BASE.'directory', BASE.'dash', 'attachment', 'revision', 'nav_menu_item'];
}
add_filter('show_admin_bar', '__return_false');
-const JVB_TESTING = true;
+
+define('JVB_TESTING', str_contains(get_home_url(),'.test'));
const JVB_DIR = WP_PLUGIN_DIR . '/jvb';
define('JVB_URL', plugin_dir_url(__FILE__));
@@ -530,6 +531,7 @@
{
add_theme_support('post-thumbnails');
add_image_size( 'tiny', 50, 50, false );
+ add_image_size( 'directory-preview', 100, 100, false );
}
diff --git a/src/feed/feed/components/FeedGrid.js b/src/feed/feed/components/FeedGrid.js
deleted file mode 100644
index fea7f7a..0000000
--- a/src/feed/feed/components/FeedGrid.js
+++ /dev/null
@@ -1,540 +0,0 @@
-// FeedGrid.js - Manages rendering of feed items in a grid
-import { formatTimeAgo } from '../utils/formatters';
-class FeedGrid {
- constructor(container, options = {}) {
- this.container = container;
- this.options = {
- showAuthor: true,
- showDate: false,
- isGallery: false,
- imageLoadThreshold: 5, // Number of images to load immediately
- lazyLoadOffset: '100px', // Load images 100px before they enter viewport
- ...options
- };
-
- this.galleryItems = [];
- this.loadedItems = 0;
- this.imageObserver = null;
- this.galleryOpenHandler = null;
- this.intersectionObserver = null;
- this.resizeObserver = null;
-
- // Element template cache for performance
- this.templateCache = new Map();
-
- this.initializeObservers();
- }
-
- /**
- * Initialize intersection observer for lazy loading images
- */
- initializeObservers() {
- // 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.options.lazyLoadOffset,
- threshold: 0.1
- });
- }
-
- // Resize observer for responsive images
- if ('ResizeObserver' in window) {
- this.resizeObserver = new ResizeObserver(this.debounce(() => {
- this.updateImageSizes();
- }, 250));
-
- // Observe the container
- this.resizeObserver.observe(this.container);
- } else {
- // Fallback to window resize
- window.addEventListener('resize', this.debounce(() => {
- this.updateImageSizes();
- }, 250));
- }
-
- // Add event delegation
- this.container.addEventListener('click', this.handleContainerClick.bind(this));
- }
-
- /**
- * Handle container click events (delegation pattern)
- */
- handleContainerClick(e) {
- // Gallery image click
- if (this.options.isGallery) {
- 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.galleryOpenHandler) {
- this.galleryOpenHandler(index);
- e.preventDefault();
- }
- }
- }
- }
-
- // Favourite button click handled by document-level event listener
- }
-
- /**
- * Set gallery open handler
- */
- setGalleryOpenHandler(handler) {
- this.galleryOpenHandler = handler;
- }
-
- /**
- * Render feed items
- */
- renderItems(items, append = false) {
- // Reset if not appending
- if (!append) {
- this.container.innerHTML = '';
- this.loadedItems = 0;
- this.galleryItems = [];
- }
-
- // Add items to gallery if in gallery mode
- if (this.options.isGallery) {
- this.galleryItems = this.galleryItems.concat(items);
- }
-
- // Use DocumentFragment for better performance
- const fragment = document.createDocumentFragment();
-
- // Process items in batches for better performance
- const batchSize = 10;
- const processBatch = (startIndex) => {
- const endIndex = Math.min(startIndex + batchSize, items.length);
-
- // Process this batch
- for (let i = startIndex; i < endIndex; i++) {
- const item = items[i];
- const element = this.createItemElement(item);
- fragment.appendChild(element);
-
- // Lazy load images beyond threshold
- if (this.loadedItems >= this.options.imageLoadThreshold && this.imageObserver) {
- this.imageObserver.observe(element);
- } else {
- this.loadImage(element);
- }
-
- this.loadedItems++;
- }
-
- // 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.container.appendChild(fragment);
- this.makeItemsKeyboardNavigable();
- this.announceUpdate(items.length, append);
- }
- };
-
- // Start processing the first batch
- if (items.length > 0) {
- processBatch(0);
- } else {
- this.announceUpdate(0, append);
- }
- }
-
- /**
- * Load image for an item
- */
- loadImage(element) {
- const img = element.querySelector('img[data-src]');
- if (!img) return;
-
- const size = this.getImageSize();
- img.src = img.dataset[size] || img.dataset.src;
- delete img.dataset.src;
-
- element.setAttribute('data-loaded', 'true');
- }
-
- /**
- * Update image sizes based on screen width
- */
- updateImageSizes() {
- const size = this.getImageSize();
-
- // Update only visible images that aren't already loaded with the right size
- const items = this.container.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) {
-
- // Process item data - filters out artist from labels if not showing author
- if (!this.options.showAuthor) {
- item.labels = item.labels.filter(label => label.icon !== 'artist');
-
- if (this.options.showDate) {
- item.labels.push({
- 'icon': 'calendar',
- 'value': formatTimeAgo(item.date),
- 'url': false,
- });
- }
- }
-
- // Check if item is favourited
- const isFavourited = window.hasFavourited ?
- window.hasFavourited(item.type, item.id) :
- false;
-
- // Get cached template or create new element
- const cacheKey = `${item.type}_template`;
- let wrapper;
-
- if (this.templateCache.has(cacheKey)) {
- // Clone from template cache
- wrapper = this.templateCache.get(cacheKey).cloneNode(true);
- } else {
- // Create new element
- wrapper = document.createElement('details');
- wrapper.className = item.type === 'artist' ? 'feed-item artist' : 'feed-item';
-
- // Cache for future use
- this.templateCache.set(cacheKey, wrapper.cloneNode(true));
- }
-
- // Set unique attributes
- wrapper.id = `${item.type}-${item.id}`;
-
- // Add umami tracking attributes
- if (item.umami_view) {
- const viewAttributes = item.umami_view.split(" ");
- viewAttributes.forEach(attr => {
- const parts = attr.split('="');
- if (parts.length === 2) {
- const name = parts[0];
- const value = parts[1].replace('"', '');
- wrapper.setAttribute(name, value);
- }
- });
- }
-
- // Generate HTML content
- const imageHTML = (item.type === 'artist') ? this.generateArtistGrid(item) : this.generateImageHTML(item);
- const labelsHTML = this.generateLabels(item.labels);
- const taxonomiesHTML = this.generateTaxonomies(item.taxonomies);
-
- wrapper.innerHTML = `
- <summary>
- <span class="handle">DETAILS</span>
- <button class="favourite-button${isFavourited ? ' favourited' : ''}"
- data-id="${item.id}"
- data-type="${item.type}"
- data-artist="${item.user_id || ''}"
- title="${isFavourited ? 'Remove from favourites' : 'Add to favourites'}">
- ${window.feedSettings.icons[isFavourited ? 'heart-filled' : 'heart']}
- </button>
- ${imageHTML}
- </summary>
-
- <div class="item-info">
- ${item.title ? `<h3><a href="${item.url}" ${item.umami_click}>${item.title}</a></h3>` : ''}
- ${labelsHTML}
- ${taxonomiesHTML}
- </div>
- `;
-
- return wrapper;
- }
-
- generateImageHTML(item) {
- // For artist with tattoos, show a gallery
- if (item.type === 'artist' && item.tattoos && item.tattoos.length) {
- const mainImage = `<a href="${item.url}" class="feed-image" ${item.umami_click}>
- ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
- </a>`;
-
- const tattooHTML = item.tattoos.slice(0, 6).map(tattoo => `
- <a href="${tattoo.url}" class="tattoo-preview">
- ${tattoo.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
- </a>
- `).join('');
-
- return `
- <div class="artist-tattoos">
- ${mainImage}
- ${tattooHTML}
- </div>
- `;
- }
-
- // For regular items, show standard image
- const url = this.options.isGallery ? '#' : item.url;
- return `<a href="${url}" class="feed-image" ${item.umami_click}>
- ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
- </a>`;
- }
-
- /**
- * Generate artist grid HTML
- */
- generateArtistGrid(item) {
- const mainImage = `<a href="${item.url}" class="feed-image" ${item.umami_click}>
- ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
- </a>`;
- // Add tattoo previews if available
- if (item.tattoos && item.tattoos.length) {
- const tattooHtml = item.tattoos.slice(0, 6).map(tattoo => `
- <a href="${tattoo.url}" class="tattoo-preview">
- ${tattoo.image}
- </a>
- `).join('');
-
- return `
- <div class="artist-tattoos">
- ${mainImage}
- ${tattooHtml}
- </div>
- `;
- }
-
- return mainImage;
- }
-
- /**
- * Generate labels HTML
- */
- generateLabels(labels) {
- if (!labels || !labels.length) return '';
-
- // Sort labels to show artist first
- const sortedLabels = [...labels].sort((a, b) => {
- if (a.icon === 'artist') return -1;
- if (b.icon === 'artist') return 1;
- return 0;
- });
-
- return `<div class="item-labels">
- ${sortedLabels.map(label => `
- <div class="label">
- ${window.feedSettings.icons[label.icon]}
- <span class="screen-reader-text">${label.label || ''}</span>
- ${label.url !== false
- ? `<a href="${label.url}" title="Learn more about ${label.value}" ${label.umami_click || ''}>${label.value}</a>`
- : label.value
- }
- </div>
- `).join('')}
- </div>`;
- }
-
- /**
- * Generate taxonomies HTML
- */
- generateTaxonomies(taxonomies) {
- if (!taxonomies || !taxonomies.length) return '';
-
- return `<div class="taxonomy-lists">
- ${taxonomies.map(tax => `
- <div class="taxonomy-group">
- <span>${window.feedSettings.icons[tax.icon]} ${tax.title}</span>
- <ul>
- ${tax.terms.slice(0, 3).map(term => `
- <li>
- <a href="${term.url}" ${term.umami_click || ''}>${term.title}</a>
- </li>
- `).join('')}
- </ul>
- </div>
- `).join('')}
- </div>`;
- }
-
- /**
- * Show empty state
- */
- showEmptyState(showingFavourites = false) {
- const message = showingFavourites
- ? `<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>`;
-
- this.container.innerHTML = message;
- this.announceEmptyState(showingFavourites);
- }
-
- /**
- * Clear the grid
- */
- clear() {
- this.container.innerHTML = '';
- this.loadedItems = 0;
- }
-
- /**
- * Get image size based on screen width
- */
- getImageSize() {
- const width = window.innerWidth;
- if (width > 1024) return 'full';
- if (width > 500) return 'medium';
- return 'small';
- }
-
- /**
- * Announce update to screen readers
- */
- announceUpdate(itemCount, isAppending) {
- const liveRegion = document.querySelector('.feed-block .live-region');
- if (liveRegion) {
- const action = isAppending ? 'Added' : 'Loaded';
- liveRegion.textContent = `${action} ${itemCount} new items`;
- }
- }
-
- /**
- * Announce empty state to screen readers
- */
- announceEmptyState(showingFavourites) {
- const liveRegion = document.querySelector('.feed-block .live-region');
- if (liveRegion) {
- liveRegion.textContent = showingFavourites
- ? "No favourites found. Try adding some items to your collection."
- : "No items found matching your current filters.";
- }
- }
-
- /**
- * 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 {
- 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);
- }
-
- /**
- * Make feed items keyboard navigable
- */
- makeItemsKeyboardNavigable() {
- const items = this.container.querySelectorAll('.feed-item:not([data-keyboard-nav])');
-
- items.forEach(item => {
- // Mark as processed
- item.setAttribute('data-keyboard-nav', 'true');
-
- // Make focusable
- item.setAttribute('tabindex', '0');
-
- // Add keyboard handling
- item.addEventListener('keydown', e => {
- if (e.key === 'Enter' || e.key === ' ') {
- // Find and click main link
- const link = item.querySelector('.feed-image a, h3 a');
- if (link) {
- link.click();
- e.preventDefault();
- }
- }
- });
- });
- }
-
- /**
- * Update favourite status for items
- */
- updateFavouriteStatus(type, id, isFavourited) {
- // Find all matching buttons in the grid
- const buttons = this.container.querySelectorAll(
- `button.favourite[data-type="${type.replace('jvb_', '')}"][data-id="${id}"]`
- );
-
- // Update button state
- buttons.forEach(button => {
- button.classList.toggle('favourited', isFavourited);
- button.innerHTML = window.feedSettings.icons[isFavourited ? 'heart-filled' : 'heart'];
- button.title = isFavourited ? 'Remove from favourites' : 'Add to favourites';
- });
- }
-
- /**
- * Clean up resources when component is destroyed
- */
- destroy() {
- // Disconnect observers
- if (this.imageObserver) {
- this.imageObserver.disconnect();
- this.imageObserver = null;
- }
-
- if (this.resizeObserver) {
- this.resizeObserver.disconnect();
- this.resizeObserver = null;
- }
-
- // Remove event listeners
- this.container.removeEventListener('click', this.handleContainerClick);
- window.removeEventListener('resize', this.debounce);
-
- // Clear template cache
- this.templateCache.clear();
-
- // Clear data
- this.galleryItems = [];
- this.loadedItems = 0;
- this.galleryOpenHandler = null;
- }
-
- /**
- * Debounce function to limit frequent calls
- */
- debounce(func, wait) {
- let timeout;
- return function(...args) {
- clearTimeout(timeout);
- timeout = setTimeout(() => func.apply(this, args), wait);
- };
- }
-
-}
-
-export default FeedGrid;
diff --git a/src/feed/feed/components/FilterPanel.js b/src/feed/feed/components/FilterPanel.js
deleted file mode 100644
index f79da55..0000000
--- a/src/feed/feed/components/FilterPanel.js
+++ /dev/null
@@ -1,935 +0,0 @@
-// FilterPanel.js - Handles UI for filtering feed content
-import cache from '../utils/cache';
-
-import { debounce } from '../utils/formatters';
-class FilterPanel {
- constructor(container, options = {}) {
- this.container = container;
- this.options = {
- taxonomies: [],
- contentTypes: [],
- onChange: null,
- taxonomyFor: {},
- ...options
- };
-
- // Store references to key elements
- this.selected = {};
- this.selectedFiltersContainer = container.querySelector('.selected-items');
- this.matchAll = container.querySelector('.toggle-text');
- this.filterToggles = container.querySelectorAll('.filter-toggle');
- this.filtersContainer = container.querySelector('.filters');
- this.filterDropdowns = container.querySelectorAll('.filter-dropdown');
- this.clearFiltersButton = container.querySelector('.clear-filters');
- this.contentTypeInputs = container.querySelectorAll('input[name="content_type"]');
- this.orderInputs = container.querySelectorAll('input[name="order"]');
-
- // Local cache for terms
- this.termCache = {
- terms: new Map(),
- timestamp: Date.now()
- };
-
- // Initialize
- this.bindEvents();
- }
-
- /**
- * Initialize filter dropdowns
- */
- initSelectors() {
- this.selectors = this.container.querySelectorAll('.jvb-selector');
-
- this.selectorInstances = {};
- let type = this.getCurrentContentType();
- this.selectors.forEach(selector => {
-
- if(selector.dataset.for.includes(type)){
- const taxonomy = selector.dataset.taxonomy;
- const options = selector.querySelector('.selected-items');
-
- this.selectorInstances[taxonomy] = new TaxonomySelector(selector, {
- multiple: true,
- feed: true,
- onSuccess: () => this.updateSelected(taxonomy)
- });
- }
-
- });
- }
-
- updateSelected(taxonomy){
- // selected = this.selectorInstances[taxonomy].selected;
- let selected = this.selectorInstances[taxonomy].selectedItems;
-
- }
- /**
- * New method to bind content type events
- */
- bindContentTypeEvents() {
- const contentTypeInputs = this.container.querySelectorAll('input[name="content_type"]');
-
- contentTypeInputs.forEach(input => {
- // Remove existing event listeners to prevent duplicates
- input.removeEventListener('change', this._handleContentTypeChange);
-
- // Store event handler as property so we can remove it later
- this._handleContentTypeChange = (e) => {
- if (input.checked) {
- const contentType = input.value;
-
- // First, directly update taxonomy and order filters UI
- this.updateTaxonomyFilters(contentType);
- this.updateOrderFilters(contentType);
-
- this.handleFormChange();
- }
- };
-
- // Add the event listener
- input.addEventListener('change', this._handleContentTypeChange);
- });
- }
-
- /**
- * Replace the existing bindEvents method in FilterPanel
- */
- bindEvents() {
- // Form change events
- this.container.addEventListener('submit', e => e.preventDefault());
- this.container.addEventListener('change', this.handleFormChanges.bind(this));
- this.container.addEventListener('click', this.handleToggleClick.bind(this));
-
- // Add global keydown listener for Escape key
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- this.closeAllDropdowns();
- }
- });
-
- // Dialog close events
- this.filterDropdowns.forEach(dialog => {
- dialog.addEventListener('close', this.handleDialogClose.bind(this));
- dialog.addEventListener('click', (e) => {
- // Check if click was directly on the dialog element (backdrop)
- // and not on any of its children
- if (e.target === dialog) {
- dialog.close();
- }
- });
- // Set up search inputs
- const search = dialog.querySelector('input[type="search"]');
- if (search) {
- search.addEventListener('input', debounce(e => {
- const taxonomy = dialog.dataset.taxonomy;
- const query = e.target.value;
- const container = dialog.querySelector('.options-container');
- this.handleSearch(taxonomy, query, container);
- }, 300));
- }
- });
-
- // Clear filters button
- if (this.clearFiltersButton) {
- this.clearFiltersButton.addEventListener('click', this.clearFilters.bind(this));
- }
- }
- /**
- * Handle form change events
- */
- handleFormChanges(e) {
- const target = e.target;
-
- // Skip changes in dialogs (handled separately)
- if (target.closest('dialog')) return;
-
- // Handle content type changes
- if (target.name === 'content_type' && target.checked) {
- const contentType = target.value;
- this.updateTaxonomyFilters(contentType);
- this.updateOrderFilters(contentType);
- }
-
- // Handle order type changes
- if (target.name === 'order') {
- this.handleOrderChange(target);
- }
-
- // For all changes, trigger form update
- this.handleFormChange();
- }
- handleToggleClick(e) {
- const toggle = e.target.closest('.filter-toggle');
- if (!toggle) return;
-
- e.preventDefault();
-
- const wasExpanded = toggle.getAttribute('aria-expanded') === 'true';
- this.closeAllDropdowns();
-
- const taxonomyFilter = toggle.closest('[data-taxonomy]');
- if (!taxonomyFilter) return;
-
- const taxonomy = taxonomyFilter.dataset.taxonomy;
- const dropdown = this.container.querySelector(
- `.filter-dropdown[data-taxonomy="${taxonomy}"]`
- );
-
- if (!dropdown) return;
-
- toggle.setAttribute('aria-expanded', !wasExpanded);
- dropdown.showModal();
-
- if (!wasExpanded) {
- const search = dropdown.querySelector('input[type="search"]');
- if (search) search.focus();
-
- const options = dropdown.querySelector('.options-container');
- if (options && options.children.length === 1) {
- this.loadTaxonomyOptions(taxonomy, options);
- }
- }
- }
-
- /**
- * Handle dialog close events
- */
- handleDialogClose(e) {
-
- const dialog = e.target;
- const taxonomy = dialog.dataset.taxonomy;
- const selected = this.selectorInstances[taxonomy].selectedItems;
- if (Object.keys(selected).length > 0) {
- this.updateFilters(taxonomy, selected);
- }
- }
-
- /**
- * Handle order type changes
- */
- handleOrderChange(target) {
- const orderDirection = this.container.querySelector('.order-direction');
-
- if (target.value === 'random') {
- orderDirection.hidden = true;
- // Reset direction to default
- this.container.querySelector('#order-desc').checked = true;
- } else if (target.value === 'title') {
- this.container.querySelector('#order-asc').checked = true;
- orderDirection.hidden = false;
- } else {
- orderDirection.hidden = false;
- }
- }
-
- /**
- * Handle search input in taxonomy dialogs
- */
- handleSearch(taxonomy, query, container) {
- if (!container) return;
-
- // Clear existing options
- container.innerHTML = '';
- container.setAttribute('data-loading', 'true');
-
- // Debounced search function
- if (this.searchDebounce) {
- clearTimeout(this.searchDebounce);
- }
-
- this.searchDebounce = setTimeout(async () => {
- try {
- // Get terms - either from cache or API
- let terms;
-
- if (query) {
- // Create cache key for search
- const cacheKey = `terms_${taxonomy}_search_${query}`;
- terms = cache.get(cacheKey);
-
- if (!terms) {
- // For search, always fetch from API if not cached
- const response = await fetch(
- `${window.feedSettings.apiUrl}terms/${taxonomy}?search=${encodeURIComponent(query)}`,
- {
- headers: {
- 'X-WP-Nonce': window.feedSettings.nonce
- }
- }
- );
-
- if (!response.ok) {
- throw new Error('Failed to fetch terms');
- }
-
- const data = await response.json();
- terms = data.terms;
-
- // Cache search results for 5 minutes
- cache.set(cacheKey, terms, 300000);
- }
- } else if (this.termCache.terms.has(taxonomy)) {
- // Use cached terms if available
- terms = this.termCache.terms.get(taxonomy);
- } else {
- // Fetch from API
- const response = await fetch(
- `${window.feedSettings.apiUrl}terms/${taxonomy}`,
- {
- headers: {
- 'X-WP-Nonce': window.feedSettings.nonce
- }
- }
- );
-
- if (!response.ok) {
- throw new Error('Failed to fetch terms');
- }
-
- const data = await response.json();
- terms = data.terms;
-
- // Update cache
- this.termCache.terms.set(taxonomy, terms);
- this.termCache.timestamp = Date.now();
- }
-
- // Render terms
- this.renderTermOptions(container, terms);
-
- } catch (error) {
- console.error('Error fetching terms:', error);
- container.innerHTML = '<div class="error">Failed to load options</div>';
- } finally {
- container.removeAttribute('data-loading');
- }
- }, 300);
- }
-
- /**
- * Handle form change events
- */
- handleFormChange() {
- // Get current form data
- const formData = this.getFormData();
-
- // Update URL
- this.updateURL(formData);
-
- // Call change handler if available
- if (typeof this.options.onChange === 'function') {
- this.options.onChange(formData);
- }
- }
-
- /**
- * Get current form data
- */
- getFormData() {
- if (!this.container) return {};
-
- const formData = new FormData(this.container);
-
- // Get content type - explicitly check the checked radio button
- const contentTypeInput = this.container.querySelector('input[name="content_type"]:checked');
- const contentType = contentTypeInput ? contentTypeInput.value : this.options.defaultContent;
-
- const filters = {
- content: contentType,
- order: formData.get('order') || 'date',
- direction: formData.get('direction') || 'desc',
- match: formData.get('match') || 'any',
- };
-
-
- for (const [taxonomy, selector] of Object.entries(this.selectorInstances)) {
- if(Object.keys(selector.selectedItems).length > 0){
- filters[taxonomy] = Object.keys(selector.selectedItems);
- }
- }
-
-
- // Add favourites flag if present
- if (formData.get('favourites_only')) {
- filters.favouritesOnly = true;
- }
-
- return filters;
- }
-
- /**
- * Update taxonomy filters based on content type
- */
- updateTaxonomyFilters(contentType) {
- if (!contentType) {
- contentType = this.options.defaultContent || 'tattoo';
- }
- // Show/hide taxonomy filters based on content type
- const filters = this.container.querySelectorAll('.taxonomy-filter');
-
- filters.forEach(filter => {
- const applicableTypes = filter.dataset.for.split(',');
- const isVisible = applicableTypes.includes(contentType);
- filter.hidden = !isVisible;
-
- // Update content type for visible selectors
- if (isVisible) {
- const taxonomy = filter.dataset.taxonomy;
- if (this.taxonomySelectors && this.taxonomySelectors[taxonomy]) {
- this.taxonomySelectors[taxonomy].setContentType(contentType);
- }
- }
- });
-
- // Remove selected filters for hidden taxonomies
- const hiddenFilters = this.container.querySelectorAll('.taxonomy-filter[hidden]');
- hiddenFilters.forEach(filter => {
- const taxonomy = filter.dataset.taxonomy;
- const selectedItems = this.selectedFiltersContainer.querySelectorAll(
- `.selected-item[data-taxonomy="${taxonomy}"]`
- );
-
- if (selectedItems.length > 0) {
- selectedItems.forEach(item => item.remove());
- }
- });
- this.initSelectors();
- // Update clear filters button visibility
- this.updateClearFiltersButton();
- }
-
- /**
- * Get current content type
- */
- getCurrentContentType() {
- const contentTypeInput = this.container.querySelector('input[name="content_type"]:checked');
- return contentTypeInput ? contentTypeInput.value : this.options.defaultContent;
- }
-
- /**
- * Update order filters based on content type
- */
- updateOrderFilters(contentType) {
- if (!contentType) return;
-
- const orderFilters = this.container.querySelectorAll('input[name="order"]');
-
- orderFilters.forEach(filter => {
- const applicableTypes = filter.dataset.for;
-
- if (applicableTypes) {
- const types = applicableTypes.split(',');
- filter.hidden = !types.includes(contentType);
-
- if (filter.hidden && filter.checked) {
- // Reset to default
- this.container.querySelector('input#order-date').checked = true;
- this.container.querySelector('input[name="direction"][value="desc"]').checked = true;
- }
- }
- });
- }
-
- /**
- * Close all open dropdowns
- */
- closeAllDropdowns() {
- this.filterToggles.forEach(toggle => {
- toggle.setAttribute('aria-expanded', 'false');
- });
-
- this.filterDropdowns.forEach(dialog => {
- if (dialog.open) {
- dialog.close();
- }
- });
- }
-
- /**
- * Update URL parameters based on filters
- */
- updateURL(filters) {
- const params = new URLSearchParams();
-
- // Add content type
- if (filters.content && filters.content !== 'all') {
- params.set('f_content', filters.content);
- }
-
- // Add order and direction
- if (filters.order) {
- params.set('f_order', filters.order);
- if (filters.direction) {
- params.set('f_direction', filters.direction);
- }
- }
-
- // Add taxonomy filters
-
- for (const [key, value] of Object.entries(filters)) {
- if (Array.isArray(value) && value.length > 0) {
- value.forEach(v => params.append('f_'+key, v));
- }
- }
-
- // Add favourites filter
- if (filters.favouritesOnly) {
- params.set('f_favourites', '1');
- }
- // if(filters.match){
- // params.set('f_match', filters.match);
- // }
-
- // Update URL without reloading page
- const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
- history.pushState({ filters }, '', newUrl);
- }
-
- /**
- * Update filters for a taxonomy
- */
- updateFilters(taxonomy, values) {
- this.selected[taxonomy] = Object.keys(values);
- // Remove existing selected items for this taxonomy
- const existingItems = this.selectedFiltersContainer.querySelectorAll(
- `.selected-item[data-taxonomy="${taxonomy}"]`
- );
- existingItems.forEach(item => item.remove());
-
- for (const [id, name] of Object.entries(values)) {
- const filterTag = this.createFilterTag(taxonomy, id, name);
- this.selectedFiltersContainer.appendChild(filterTag);
- }
-
- // Update clear filters button visibility
- this.updateClearFiltersButton();
-
- // Trigger form change
- this.handleFormChange();
- }
-
- /**
- * Create a filter tag element
- */
- createFilterTag(taxonomy, id, name) {
- const tag = document.createElement('span');
- tag.className = 'selected-item';
- tag.dataset.taxonomy = taxonomy;
- tag.dataset.id = id;
-
- const icon = feedSettings?.icons?.[taxonomy] || '';
-
- tag.innerHTML = `
- ${icon}
- <span class="filter-name">${this.escapeHtml(name)}</span>
- <button type="button" aria-label="Remove ${name}" class="remove-item">
- ${window.feedSettings?.icons?.close || '×'}
- </button>
- `;
-
- // Add click handler for remove button
- tag.querySelector('.remove-item').addEventListener('click', () => {
- // Uncheck checkbox if it exists
- let taxonomy = tag.dataset.taxonomy;
- delete this.selectorInstances[taxonomy].selectedItems[id];
-
- // Remove tag
- tag.remove();
-
- // Update clear filters button visibility
- this.updateClearFiltersButton();
-
- // Trigger form change
- this.handleFormChange();
- });
-
- return tag;
- }
-
- /**
- * Update clear filters button visibility
- */
- updateClearFiltersButton() {
- if (!this.clearFiltersButton) return;
-
- let filters = this.selectedFiltersContainer.children.length;
-
- const hasFilters = filters > 0;
-
- const hasMultiple = filters > 1;
-
- this.clearFiltersButton.hidden = !hasFilters;
-
- this.filtersContainer.classList.toggle('has-filters', hasFilters);
-
- this.matchAll.hidden = !hasMultiple;
- }
-
- /**
- * Clear all filters
- */
- clearFilters() {
- // Reset form
- if (this.container) {
- this.container.reset();
- }
-
- // Clear selected items
- if (this.selectedFiltersContainer) {
- this.selectedFiltersContainer.innerHTML = '';
- }
-
- // Uncheck all checkboxes in dropdowns
- this.filterDropdowns.forEach(dialog => {
- dialog.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
- checkbox.checked = false;
- });
- });
-
- // Update clear filters button visibility
- this.updateClearFiltersButton();
-
- // Trigger form change
- this.handleFormChange();
- }
-
- /**
- * Load taxonomy options
- */
- async loadTaxonomyOptions(taxonomy, container, page = 1) {
- if (!container) return;
-
- container.setAttribute('data-loading', 'true');
-
- try {
- // Check cache first (if first page and no search)
- if (page === 1 && this.termCache.terms.has(taxonomy)) {
- this.renderTermOptions(container, this.termCache.terms.get(taxonomy));
- return;
- }
-
- // Create cache key
- const cacheKey = `terms_${taxonomy}_page_${page}`;
- let terms = cache.get(cacheKey);
-
- if (!terms) {
- // Fetch from API
- const response = await fetch(
- `${window.feedSettings.apiUrl}terms/${taxonomy}?page=${page}`,
- {
- headers: {
- 'X-WP-Nonce': window.feedSettings.nonce
- }
- }
- );
-
- if (!response.ok) {
- throw new Error('Failed to fetch terms');
- }
-
- const data = await response.json();
- terms = data.terms;
-
- // Cache for 5 minutes
- cache.set(cacheKey, terms, 300000);
-
- // Update local cache if first page
- if (page === 1) {
- this.termCache.terms.set(taxonomy, terms);
- this.termCache.timestamp = Date.now();
- }
- }
-
- // Render terms
- this.renderTermOptions(container, terms, page > 1);
-
- } catch (error) {
- console.error('Error fetching terms:', error);
- if (container.children.length === 0) {
- container.innerHTML = '<div class="error">Failed to load options</div>';
- }
- } finally {
- container.removeAttribute('data-loading');
- }
- }
-
- /**
- * Render term options in a dropdown
- */
- renderTermOptions(container, terms = [], append = false) {
- if (!container) return;
-
- const termsArray = Array.isArray(terms) ? terms : Object.values(terms);
-
- // Get the relevant taxonomy
- const taxonomy = container.closest('.filter-dropdown')?.dataset.taxonomy;
- if (!taxonomy) return;
-
- // Get selected values
- const selectedValues = this.getSelectedValues(taxonomy);
-
- // Create HTML
- const html = termsArray.map(term => {
- const isChecked = selectedValues.includes(term.id.toString());
- const displayName = term.display_name || term.name;
- const inputId = `${taxonomy}-${term.id}`;
-
- return `
- <input type="checkbox"
- id="${inputId}"
- name="${taxonomy}"
- value="${term.id}"
- data-umami-event="toggle_taxonomy"
- data-umami-taxonomy="${taxonomy}"
- data-umami-term="${term.name}"
- data-umami-term-id="${term.id}"
- data-umami-action="${isChecked ? 'remove' : 'add'}"
- ${isChecked ? 'checked' : ''}>
- <label for="${inputId}" class="filter-option">
- <span>${this.escapeHtml(displayName)}</span>
- ${term.count ? `<span class="count">(${term.count})</span>` : ''}
- </label>
- `;
- }).join('');
-
- // Insert HTML
- if (append) {
- container.insertAdjacentHTML('beforeend', html);
- } else {
- container.innerHTML = html;
- }
- }
-
- /**
- * Get selected values for a taxonomy
- */
- getSelectedValues(taxonomy) {
- const selectedItems = this.selectedFiltersContainer.querySelectorAll(
- `.selected-item[data-taxonomy="${taxonomy}"]`
- );
-
- return Array.from(selectedItems).map(item => item.dataset.id);
- }
-
- /**
- * Get term details from cache
- */
- getTermDetails(taxonomy, id) {
- if (!this.termCache.terms.has(taxonomy)) return null;
-
- const terms = this.termCache.terms.get(taxonomy);
- return terms[id] || null;
- }
-
- /**
- * Load form state from URL parameters
- */
- loadFromURL() {
- const params = new URLSearchParams(window.location.search);
-
- // Reset form state
- if (this.container) {
- this.container.reset();
- }
-
- // Clear selected filters
- if (this.selectedFiltersContainer) {
- this.selectedFiltersContainer.innerHTML = '';
- }
-
- // Set content type
- const contentType = params.get('f_content');
- if (contentType) {
- const contentTypeInput = this.container.querySelector(
- `input[name="content_type"][value="${contentType}"]`
- );
- if (contentTypeInput) {
- contentTypeInput.checked = true;
- // Update visible taxonomies based on content type
- this.updateTaxonomyFilters(contentType);
- }
- }
-
- // Set order and direction
- const order = params.get('f_order');
- if (order) {
- const orderInput = this.container.querySelector(`input[name="order"][value="${order}"]`);
- if (orderInput) {
- orderInput.checked = true;
- this.updateOrderFilters(contentType || this.options.defaultContent);
- }
- }
-
- const direction = params.get('f_direction');
- if (direction) {
- const directionInput = this.container.querySelector(`input[name="direction"][value="${direction}"]`);
- if (directionInput) {
- directionInput.checked = true;
- }
- }
-
- // Set favourites filter
- if (params.get('f_favourites') === '1') {
- const favouritesCheckbox = this.container.querySelector('input[name="favourites_only"]');
- if (favouritesCheckbox) {
- favouritesCheckbox.checked = true;
- }
- }
-
- // Find and set taxonomy filters
- const taxonomyPromises = {};
-
- // Process all potential taxonomy parameters
- for (const [key, value] of params.entries()) {
- if (key.startsWith('f_') && key !== 'f_content' &&
- key !== 'f_order' && key !== 'f_direction' &&
- key !== 'f_favourites' && key !== 'f_match') {
-
- const taxName = key.replace('f_', '');
- const termId = value;
-
- // Create a promise to fetch term details and create a filter tag
- if(!Object.hasOwnProperty(taxName)){
- taxonomyPromises[taxName] = [];
- }
- taxonomyPromises[taxName].push(termId);
- }
- }
- //Check all terms at once
- this.fetchTermDetails(taxonomyPromises).then(()=>{
- this.updateClearFiltersButton();
- });
- }
-
- async fetchTermDetails(terms) {
- for(const [taxonomy, termIds] of Object.entries(terms)){
-
- termIds.forEach(termId => {
- if(this.termCache.terms.has(taxonomy) && this.termCache.terms.get(taxonomy)[termId]){
- //add to Selected from cache.
-
- let name = this.termCache.terms.get(taxonomy)[termId].name;
- const filterTag = this.createFilterTag(taxonomy, termId, name);
- this.selectedFiltersContainer.appendChild(filterTag);
- //remove any terms we already have
- terms[taxonomy] = terms[taxonomy].filter(function(item) {
- return item !== termId;
- });
- }
- });
- }
- 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 = `${window.feedSettings?.apiUrl}terms/check?`+params.toString();
-
- const response = await fetch(url, {
- headers: {
- 'X-WP-Nonce': window.feedSettings?.nonce || ''
- }
- });
-
- if (!response.ok) {
- throw new Error('Failed to fetch term details');
- }
-
- const termData = await response.json();
-
- for(const [taxonomy, termIds] of Object.entries(termData.terms)){
- if(!this.termCache.terms.has(taxonomy)){
- this.termCache.terms.set(taxonomy, new Map());
- }
-
- for(const [termId, termData] of Object.entries(termIds)){
- const filterTag = this.createFilterTag(taxonomy, termId, termData.name);
- this.selectedFiltersContainer.appendChild(filterTag);
-
- this.termCache.terms.get(taxonomy).set(parseInt(termId), termData);
- }
-
- }
-
-
- return true;
- } catch (error) {
- console.error(`Error fetching term details for ${taxonomy}/${termId}:`, error);
- return null;
- }
- }
- /**
- * Safe HTML escaping
- */
- escapeHtml(text) {
- if (!text) return '';
-
- return text
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
- }
-
- /**
- * Set up focus traps for modals
- */
- setupFocusTraps() {
- this.filterDropdowns.forEach(dialog => {
- dialog.addEventListener('show', () => {
- // Store current focus to restore later
- this._previouslyFocused = document.activeElement;
-
- // Focus first focusable element
- const focusable = dialog.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (focusable.length) {
- focusable[0].focus();
- }
-
- // Trap focus in modal
- this.trapFocus(dialog);
- });
-
- dialog.addEventListener('close', () => {
- // Restore focus
- if (this._previouslyFocused) {
- this._previouslyFocused.focus();
- }
- });
- });
- }
-
- /**
- * Trap focus within an element
- */
- trapFocus(element) {
- const focusableElements = element.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (!focusableElements.length) return;
-
- const firstFocusable = focusableElements[0];
- const lastFocusable = focusableElements[focusableElements.length - 1];
-
- const trapHandler = function(e) {
- if (e.key !== 'Tab') return;
-
- if (e.shiftKey && document.activeElement === firstFocusable) {
- lastFocusable.focus();
- e.preventDefault();
- } else if (!e.shiftKey && document.activeElement === lastFocusable) {
- firstFocusable.focus();
- e.preventDefault();
- }
- };
-
- // Store the handler to remove it later
- element._trapHandler = trapHandler;
- element.addEventListener('keydown', trapHandler);
- }
-}
-
-export default FilterPanel;
diff --git a/src/feed/feed/components/GalleryModal.js b/src/feed/feed/components/GalleryModal.js
deleted file mode 100644
index ceabaa1..0000000
--- a/src/feed/feed/components/GalleryModal.js
+++ /dev/null
@@ -1,565 +0,0 @@
-// GalleryModal.js - Fullscreen gallery viewer component
-class GalleryModal {
- constructor(items, initialIndex = 0) {
- this.items = items || [];
- this.currentIndex = initialIndex;
- this.touchStart = null;
- this.touchEnd = null;
- this.minSwipeDistance = 50;
- this.modal = null;
- this.keyHandler = null;
- this.loading = false;
- }
-
- /**
- * Show the gallery modal
- */
- show() {
- // Create modal if not already created
- if (!this.modal) {
- this.modal = this.createModal();
- document.body.appendChild(this.modal);
- }
-
- // Lock body scroll
- document.body.style.overflow = 'hidden';
-
- // Bind event handlers
- this.bindEvents();
-
- // Show current image
- this.updateDisplay();
-
- // Preload adjacent images
- this.preloadImages();
-
- // Announce to screen readers
- this.announceToScreenReaders();
- }
-
- /**
- * Create the modal element
- */
- createModal() {
- const modal = document.createElement('div');
- modal.className = 'gallery-modal';
- modal.setAttribute('role', 'dialog');
- modal.setAttribute('aria-modal', 'true');
- modal.setAttribute('aria-label', 'Image Gallery');
-
- modal.innerHTML = `
- <div class="gallery-overlay">
- <button class="gallery-close" aria-label="Close gallery">
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <line x1="18" y1="6" x2="6" y2="18"></line>
- <line x1="6" y1="6" x2="18" y2="18"></line>
- </svg>
- </button>
-
- <button class="gallery-nav gallery-prev" aria-label="Previous image">
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <polyline points="15 18 9 12 15 6"></polyline>
- </svg>
- </button>
-
- <button class="gallery-nav gallery-next" aria-label="Next image">
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <polyline points="9 18 15 12 9 6"></polyline>
- </svg>
- </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"></div>
-
- <div class="live-region" role="status" aria-live="polite" class="screen-reader-text"></div>
- </div>
- `;
-
- // Add styles if they don't exist
- this.ensureGalleryStyles();
-
- return modal;
- }
-
- /**
- * Ensure gallery styles are in the document
- */
- ensureGalleryStyles() {
- if (!document.getElementById('gallery-styles')) {
- const styles = document.createElement('style');
- styles.id = 'gallery-styles';
- styles.textContent = `
- .gallery-modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 9999;
- background: rgba(27, 27, 27, 0.9);
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .gallery-overlay {
- position: relative;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .gallery-content {
- position: relative;
- max-width: 100%;
- max-height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 2rem;
- }
-
- .gallery-favourite button.favourite {
- top: unset;
- bottom: 1rem;
- right: 1rem;
- }
-
- .gallery-image {
- max-width: 100%;
- max-height: calc(100vh - 4rem);
- object-fit: contain;
- }
-
- .gallery-close {
- position: absolute;
- top: 1rem;
- right: 1rem;
- background: none;
- border: none;
- color: white;
- cursor: pointer;
- padding: 0.5rem;
- z-index: 10;
- transition: color 0.3s ease;
- }
-
- .gallery-close:hover {
- color: #FF0080;
- }
-
- .gallery-nav {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- background: none;
- border: none;
- color: white;
- cursor: pointer;
- padding: 1rem;
- transition: color 0.3s ease;
- }
-
- .gallery-nav:hover {
- color: #FF0080;
- }
-
- .gallery-prev {
- left: 1rem;
- }
-
- .gallery-next {
- right: 1rem;
- }
-
- .gallery-counter {
- position: absolute;
- top: 1rem;
- left: 1rem;
- color: white;
- font-size: 0.875rem;
- }
-
- .gallery-content details {
- position: absolute;
- bottom: 1rem;
- left: 2rem;
- width: calc(100% - 4rem);
- padding: 0;
- }
-
- .gallery-content details summary {
- background-color: rgba(249,249,249,.2);
- backdrop-filter: blur(5px);
- border: none;
- cursor: pointer;
- }
-
- .gallery-content details:hover summary,
- .gallery-content details[open] summary {
- background-color: rgba(255,0,128,.4);
- backdrop-filter: blur(5px);
- }
-
- .gallery-content .item-info {
- background-color: rgba(249,249,249,.6);
- backdrop-filter: blur(5px);
- }
- `;
- document.head.appendChild(styles);
- }
- }
-
- /**
- * Bind event handlers
- */
- bindEvents() {
- // Close button
- this.modal.querySelector('.gallery-close').addEventListener('click', () => this.close());
-
- // Navigation buttons
- const prevBtn = this.modal.querySelector('.gallery-prev');
- const nextBtn = this.modal.querySelector('.gallery-next');
-
- prevBtn.addEventListener('click', () => this.navigate(-1));
- nextBtn.addEventListener('click', () => this.navigate(1));
-
- // Keyboard navigation
- this.keyHandler = (e) => {
- switch (e.key) {
- case 'ArrowLeft':
- this.navigate(-1);
- break;
- case 'ArrowRight':
- this.navigate(1);
- break;
- case 'Escape':
- this.close();
- break;
- }
- };
- document.addEventListener('keydown', this.keyHandler);
-
- // Touch events
- this.modal.addEventListener('touchstart', (e) => {
- this.touchStart = e.touches[0].clientX;
- });
-
- this.modal.addEventListener('touchmove', (e) => {
- this.touchEnd = e.touches[0].clientX;
- });
-
- this.modal.addEventListener('touchend', () => {
- if (!this.touchStart || !this.touchEnd) return;
-
- const distance = this.touchStart - this.touchEnd;
- const isLeftSwipe = distance > this.minSwipeDistance;
- const isRightSwipe = distance < -this.minSwipeDistance;
-
- if (isLeftSwipe) {
- this.navigate(1);
- } else if (isRightSwipe) {
- this.navigate(-1);
- }
-
- this.touchStart = null;
- this.touchEnd = null;
- });
- }
-
- /**
- * Navigate to previous/next image
- */
- async navigate(direction) {
- const newIndex = this.currentIndex + direction;
-
- // Check if out of bounds
- if (newIndex < 0 || newIndex >= this.items.length) {
- this.announceNavigation(direction > 0 ? 'last' : 'first');
- return;
- }
-
- // Update current index
- this.currentIndex = newIndex;
-
- // Update display
- this.updateDisplay();
-
- // Preload adjacent images
- this.preloadImages();
-
- // Announce to screen readers
- this.announceNavigation(direction > 0 ? 'next' : 'previous');
-
- // Trigger onNavigate callback if provided
- if (this.onNavigate) {
- this.onNavigate(this.currentIndex);
- }
-
- // Check if near the end and can load more
- if (direction > 0 && newIndex >= this.items.length - 3 && this.onLoadMore) {
- if (!this.loading) {
- this.loading = true;
- const loadedMore = await this.onLoadMore();
- this.loading = false;
-
- if (loadedMore) {
- // Update navigation buttons
- this.updateNavigationButtons();
- }
- }
- }
- }
-
- /**
- * Preload adjacent images
- */
- preloadImages() {
- // Preload current, previous and next images
- [-1, 0, 1].forEach(offset => {
- const index = this.currentIndex + offset;
- if (index >= 0 && index < this.items.length) {
- const img = new Image();
- const item = this.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() {
- const item = this.items[this.currentIndex];
- if (!item) return;
-
- // Get elements
- const favourite = this.modal.querySelector('.gallery-favourite');
- const image = this.modal.querySelector('.gallery-image');
- const counter = this.modal.querySelector('.gallery-counter');
- const info = this.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) {
- favourite.innerHTML = '';
- favourite.appendChild(item.fav.cloneNode(true));
- }
-
- // Update info
- if (info && item.info) {
- info.innerHTML = '';
- const clone = item.info.cloneNode(true);
- info.appendChild(clone);
- }
-
- // Update counter
- counter.textContent = `${this.currentIndex + 1} / ${this.items.length}`;
-
- // Update navigation buttons
- this.updateNavigationButtons();
- }
-
- /**
- * Update navigation button visibility
- */
- updateNavigationButtons() {
- const prevBtn = this.modal.querySelector('.gallery-prev');
- const nextBtn = this.modal.querySelector('.gallery-next');
-
- prevBtn.style.display = this.currentIndex > 0 ? '' : 'none';
- nextBtn.style.display = this.currentIndex < this.items.length - 1 ? '' : 'none';
- }
-
- /**
- * Close the gallery
- */
- close() {
- // Remove event listeners
- document.removeEventListener('keydown', this.keyHandler);
-
- // Remove modal from DOM
- if (this.modal && this.modal.parentNode) {
- document.body.removeChild(this.modal);
- }
-
- // Restore body scroll
- document.body.style.overflow = '';
-
- // Reset state
- this.modal = null;
- this.keyHandler = null;
-
- // Dispatch close event
- document.dispatchEvent(new CustomEvent('galleryClose'));
-
- // Call onClose callback if provided
- if (this.onClose) {
- this.onClose();
- }
- }
-
- /**
- * Announce to screen readers
- */
- announceToScreenReaders() {
- const liveRegion = this.modal.querySelector('.live-region');
- if (liveRegion) {
- liveRegion.textContent = `Image ${this.currentIndex + 1} of ${this.items.length}. Use arrow keys to navigate.`;
- }
- }
-
-
- /**
- * Update gallery items
- * @param {Array} newItems - New gallery items
- */
- updateItems(newItems) {
- // Store original current index and item
- const currentItem = this.items[this.currentIndex];
-
- // Update items array
- this.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.items.findIndex(item =>
- item.full === currentItem.full ||
- item.large === currentItem.large
- );
-
- if (newIndex !== -1) {
- this.currentIndex = newIndex;
- }
- }
-
- // Update navigation buttons
- this.updateNavigationButtons();
- }
-
-
- /**
- * Set callbacks
- * @param {Object} callbacks - Callback functions
- */
- setCallbacks(callbacks = {}) {
- const { onClose, onLoadMore, onNavigate } = callbacks;
-
- this.onClose = onClose;
- this.onLoadMore = onLoadMore;
- this.onNavigate = onNavigate;
- }
-
-
- /**
- * Ensure gallery is accessible
- */
- setupAccessibility() {
- if (!this.modal) return;
-
- // Add ARIA attributes
- this.modal.setAttribute('role', 'dialog');
- this.modal.setAttribute('aria-modal', 'true');
- this.modal.setAttribute('aria-label', 'Image Gallery');
-
- // Create live region for announcements
- this.liveRegion = document.createElement('div');
- this.liveRegion.setAttribute('aria-live', 'polite');
- this.liveRegion.setAttribute('role', 'status');
- this.liveRegion.className = 'screen-reader-text';
- this.modal.querySelector('.gallery-overlay').appendChild(this.liveRegion);
-
- // Announce initial state
- this.announceToScreenReader(`Image ${this.currentIndex + 1} of ${this.items.length}. Use arrow keys to navigate.`);
-
- // Set up focus trap
- const focusableElements = this.modal.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (focusableElements.length) {
- focusableElements[0].focus();
- this.trapFocus(this.modal);
- }
- }
-
- /**
- * Announce to screen readers
- */
- announceToScreenReader(message) {
- if (!this.liveRegion) return;
- this.liveRegion.textContent = message;
- }
-
- /**
- * Trap focus within gallery modal
- */
- trapFocus(element) {
- const focusableElements = element.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
- const firstFocusable = focusableElements[0];
- const lastFocusable = focusableElements[focusableElements.length - 1];
-
- element.addEventListener('keydown', function(e) {
- if (e.key === 'Tab') {
- // Shift+Tab on first element focuses last element
- if (e.shiftKey && document.activeElement === firstFocusable) {
- lastFocusable.focus();
- e.preventDefault();
- }
- // Tab on last element focuses first element
- else if (!e.shiftKey && document.activeElement === lastFocusable) {
- firstFocusable.focus();
- e.preventDefault();
- }
- }
- });
- }
- /**
- * Announce navigation to screen readers
- */
- announceNavigation(direction) {
- if (!this.liveRegion) return;
-
- if (direction === 'first') {
- this.liveRegion.textContent = 'At first image';
- } else if (direction === 'last') {
- this.liveRegion.textContent = 'At last image';
- } else {
- this.liveRegion.textContent = `Image ${this.currentIndex + 1} of ${this.items.length}`;
- }
- }
-
- /**
- * Set callbacks
- */
- setCallbacks(callbacks = {}) {
- const { onClose, onLoadMore, onNavigate } = callbacks;
-
- if (onClose) this.onClose = onClose;
- if (onLoadMore) this.onLoadMore = onLoadMore;
- if (onNavigate) this.onNavigate = onNavigate;
- }
-}
-
-export default GalleryModal;
diff --git a/src/feed/feed/components/LoadingState.js b/src/feed/feed/components/LoadingState.js
deleted file mode 100644
index 77b30c1..0000000
--- a/src/feed/feed/components/LoadingState.js
+++ /dev/null
@@ -1,241 +0,0 @@
-// LoadingState.js - Manages loading overlay and animations
-class LoadingState {
- constructor(container, options = {}) {
- this.container = container;
- this.options = {
- loadingMessages: {},
- contentTypes: [],
- taxonomies: [],
- cycleInterval: 2000, // Time between loading message changes
- ...options
- };
-
- this.messageElement = this.container.querySelector('.loading-message');
- this.dotsElement = this.container.querySelector('.loading-dots');
- this.active = false;
- this.quipInterval = null;
- this.currentIndex = 0;
-
- // Initialize quips based on content types and taxonomies
- this.quips = this.initializeQuips();
- }
-
- /**
- * Initialize quips for loading messages
- */
- initializeQuips() {
- // Default quips for different content types
- const defaultQuips = {
- artist: [
- "Seeking ink masters",
- "Finding your next artist",
- "Gathering the talent",
- "Meeting the makers",
- "Summoning art wizards",
- "Finding ink slingers",
- "Priming the inkernet",
- "Loading talent pool",
- "Herding needle ninjas",
- "Rallying rad artists"
- ],
- tattoo: [
- "Converting ink to pixels",
- "Curating fresh ink",
- "Making tattoos internet-famous",
- "Processing perfection",
- "Finding forever art",
- "Making masterpieces mobile",
- "Finding fresh flash",
- ],
- shop: [
- "Mapping the scene",
- "Finding your next spot",
- "Checking shop vibes",
- "Scanning studio space",
- "Locating ink havens",
- "Loading local legends",
- "Finding fresh digs",
- ],
- artwork: [
- "Gathering masterpieces",
- "Curating creativity",
- "Finding fine art",
- "Collecting canvas magic",
- "Processing paintings",
- "Loading creative chaos",
- ],
- piercing: [
- "Finding fresh mods",
- "Piercing through the data",
- "Seeking sparkly things",
- "Processing piercings",
- "Curating cool mods",
- "Seeking shiny stuffs",
- "Loading mod magic",
- "Processing pretty pieces",
- "Scanning sparkly stuff"
- ],
- style: [
- "Sorting styles",
- "Categorizing cool",
- "Finding your vibe",
- "Processing aesthetics",
- "Matching moods",
- "Style scanning",
- "Finding your flavour"
- ],
- theme: [
- "Theming things up",
- "Categorizing content",
- "Sorting subjects",
- "Finding your flavor",
- "Organizing ideas",
- ],
- city: [
- "Looking around corners",
- "Checking the map",
- "Checking it twice",
- "Pushing past obstacles"
- ],
- type: [
- "Searching for artists",
- ]
- };
-
- // 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
- this.options.contentTypes.forEach(type => {
- if (defaultQuips[type]) {
- defaultQuips[type].forEach(quip => {
- allQuips.push({
- icon: type,
- quip: quip
- });
- });
- }
- });
-
- // Add taxonomy quips
- this.options.taxonomies.forEach(taxonomy => {
- const baseType = typeMapping[taxonomy] || taxonomy;
- if (defaultQuips[baseType]) {
- defaultQuips[baseType].forEach(quip => {
- allQuips.push({
- icon: taxonomy,
- quip: quip
- });
- });
- }
- });
-
- // Shuffle the quips array
- return this.shuffleArray(allQuips);
- }
-
- /**
- * Show the loading overlay
- */
- show() {
- this.container.classList.add('active');
- this.active = true;
- this.startQuipCycle();
- document.body.classList.add('loading');
- }
-
- /**
- * Hide the loading overlay
- */
- hide() {
- this.container.classList.remove('active');
- this.active = false;
- this.stopQuipCycle();
- document.body.classList.remove('loading');
- }
-
- /**
- * Start cycling through loading messages
- */
- startQuipCycle() {
- if (this.quipInterval) {
- clearInterval(this.quipInterval);
- }
-
- if (!this.quips.length) return;
-
- // Set initial message
- this.updateMessage(this.quips[0]);
- this.container.classList.remove('changing');
-
- this.quipInterval = setInterval(() => {
- this.container.classList.add('changing');
-
- setTimeout(() => {
- this.currentIndex = (this.currentIndex + 1) % this.quips.length;
- this.updateMessage(this.quips[this.currentIndex]);
-
- setTimeout(() => {
- this.container.classList.remove('changing');
- }, 50);
- }, 350);
- }, this.options.cycleInterval);
- }
-
- /**
- * Stop cycling through loading messages
- */
- stopQuipCycle() {
- if (this.quipInterval) {
- clearInterval(this.quipInterval);
- this.quipInterval = null;
- }
- }
-
- /**
- * Update the loading message
- */
- updateMessage(quipData) {
- if (!this.messageElement) return;
-
- const icon = window.feedSettings?.icons?.[quipData.icon] || '';
- this.messageElement.innerHTML = `${icon}<p>${quipData.quip}</p>`;
- }
-
- /**
- * Set a custom loading message
- */
- setMessage(message, icon = null) {
- if (!this.messageElement) return;
-
- this.stopQuipCycle();
-
- const iconHtml = icon ? window.feedSettings?.icons?.[icon] || '' : '';
- this.messageElement.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;
- }
-}
-
-export default LoadingState;
diff --git a/src/feed/feed/components/TaxonomySelector.js b/src/feed/feed/components/TaxonomySelector.js
deleted file mode 100644
index 944ccfd..0000000
--- a/src/feed/feed/components/TaxonomySelector.js
+++ /dev/null
@@ -1,327 +0,0 @@
-/**
- * FeedTaxonomySelector - Adapts TaxonomySelector for use in the Feed Block
- *
- * This component provides the glue between the Feed Block filtering UI
- * and the existing TaxonomySelector used in the dashboard.
- */
-import { debounce } from '../utils/formatters';
-import cache from '../utils/cache';
-class FeedTaxonomySelector {
- constructor(container, options = {}) {
- this.container = container;
- this.options = {
- taxonomy: '',
- contentType: '',
- onChange: null,
- apiUrl: window.feedSettings?.apiUrl || '',
- nonce: window.feedSettings?.nonce || '',
- ...options
- };
-
- // Initialize state
- this.selectedTerms = [];
- this.isInitialized = false;
- this.taxonomy = this.options.taxonomy;
- this.contentType = this.options.contentType;
-
- // Create selector container if it doesn't exist
- this.selectorContainer = this.container.querySelector('.taxonomy-selector-container');
- if (!this.selectorContainer) {
- this.selectorContainer = document.createElement('div');
- this.selectorContainer.className = 'taxonomy-selector-container';
- this.container.appendChild(this.selectorContainer);
- }
-
- // Initialize when contentType is set
- if (this.contentType) {
- this.init();
- }
- }
-
- /**
- * Initialize the selector with the current content type
- */
- init() {
- // Skip if already initialized with this content type
- if (this.isInitialized && this.contentType === this._lastContentType) {
- return;
- }
-
- // Store content type
- this._lastContentType = this.contentType;
-
- // Set up the container with necessary data attributes
- this.selectorContainer.dataset.taxonomy = this.taxonomy;
- this.selectorContainer.dataset.config = JSON.stringify({
- multiple: true,
- hierarchical: true,
- search: true,
- createNew: false,
- required: false,
- name: this.taxonomy,
- base: BASE || 'jvb_'
- });
-
- // Create the selector markup
- this.createSelectorMarkup();
-
- // Initialize the TaxonomySelector
- this.selector = new TaxonomySelector(this.selectorContainer, {
- onSuccess: () => {
- // When selection changes, notify parent component
- if (typeof this.options.onChange === 'function') {
- this.options.onChange(this.getSelectedTerms());
- }
- }
- });
-
- // Load terms for the current content type
- this.loadTermsForContentType();
-
- this.isInitialized = true;
- }
-
- /**
- * Create the selector markup
- */
- createSelectorMarkup() {
- this.selectorContainer.innerHTML = `
- <div class="selected-items"></div>
- <dialog class="selector-modal">
- <div class="modal-content">
- <header class="modal-header">
- <h3>Select ${this.getTaxonomyLabel()}</h3>
- <button type="button" class="cancel" aria-label="Close">×</button>
- </header>
-
- <div class="items-wrap">
- <details class="favourite-terms">
- <summary class="title">Common ${this.getTaxonomyLabel()}</summary>
- <ul></ul>
- </details>
- <p class="pagination-info"></p>
- <nav class="term-navigation"></nav>
- <ul class="items-container"></ul>
- <div class="scroll-sentinel"></div>
- </div>
-
- <div class="search-wrapper">
- <div class="search-bar">
- <input type="search" placeholder="Search..." aria-label="Search ${this.getTaxonomyLabel()}">
- </div>
- </div>
- </div>
- </dialog>
- <button type="button" class="add-item-btn">
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M12 4V20M4 12H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
- </svg>
- Add ${this.getTaxonomyLabel()}
- </button>
- `;
- }
-
- /**
- * Get a user-friendly label for the taxonomy
- */
- getTaxonomyLabel() {
- const labels = {
- 'style': 'Style',
- 'theme': 'Theme',
- 'city': 'Location',
- 'shop': 'Shop',
- 'artstyle': 'Art Style',
- 'arttheme': 'Art Theme',
- 'pstyle': 'Piercing Style',
- 'placement': 'Placement'
- };
-
- return labels[this.taxonomy] || this.taxonomy.charAt(0).toUpperCase() + this.taxonomy.slice(1);
- }
-
- /**
- * Load terms specific to the current content type
- */
- async loadTermsForContentType() {
- try {
- const params = new URLSearchParams({
- page: 1,
- per_page: 50,
- min_count: 1
- });
-
- const url = `${this.options.apiUrl}terms/for/${this.contentType}/${this.taxonomy}?${params.toString()}`;
-
- const response = await fetch(url, {
- headers: {
- 'X-WP-Nonce': this.options.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Also load popular terms
- const popularTerms = await this.loadPopularTerms();
-
- // Set the terms and common terms in the selector
- if (this.selector) {
- // Update the common terms
- this.selector.commonTerms = new Map(
- Object.entries(popularTerms.terms).map(([id, term]) => [id, term])
- );
-
- // Update the current terms
- this.selector.currentTerms = new Map(
- Object.entries(data.terms).map(([id, term]) => [id, term.name])
- );
-
- // Reset parent options
- this.selector.resetParentOptions();
-
- // Render common terms
- const commonTermsList = this.selectorContainer.querySelector('details.favourite-terms ul');
- if (commonTermsList) {
- commonTermsList.innerHTML = '';
- Object.entries(popularTerms.terms).forEach(([id, term]) => {
- const li = document.createElement('li');
- li.dataset.id = id;
-
- const isSelected = this.selector.selectedItems[id];
-
- li.innerHTML = `
- <input type="${this.selector.config.multiple ? 'checkbox' : 'radio'}"
- id="${this.selector.config.base}${id}"
- name="${this.selector.config.name}"
- value="${id}"
- ${isSelected ? 'checked' : ''}>
- <label for="${this.selector.config.base}${id}" title="${this.escapeHtml(term.name)}">
- <span class="term-name">${this.escapeHtml(term.name)}</span>
- </label>
- `;
-
- commonTermsList.appendChild(li);
- });
- }
-
- // Render all terms
- const termsContainer = this.selectorContainer.querySelector('.items-container');
- if (termsContainer) {
- termsContainer.innerHTML = '';
- Object.entries(data.terms).forEach(([id, term]) => {
- const li = document.createElement('li');
- li.dataset.id = id;
-
- const isSelected = this.selector.selectedItems[id];
-
- li.innerHTML = `
- <input type="${this.selector.config.multiple ? 'checkbox' : 'radio'}"
- id="${this.selector.config.base}${term.id}"
- name="${this.selector.config.name}"
- value="${term.id}"
- ${isSelected ? 'checked' : ''}>
- <label for="${this.selector.config.base}${term.id}" title="${this.escapeHtml(term.path || term.name)}">
- <span class="term-name">${this.escapeHtml(term.name)}</span>
- <span class="count">(${term.count})</span>
- </label>
- `;
-
- termsContainer.appendChild(li);
- });
- }
- }
-
- } catch (error) {
- console.error('Error loading terms for content type:', error);
- }
- }
-
- /**
- * Load popular terms for this taxonomy
- */
- async loadPopularTerms() {
- try {
- const url = `${this.options.apiUrl}terms/popular/${this.taxonomy}?limit=10`;
-
- const response = await fetch(url, {
- headers: {
- 'X-WP-Nonce': this.options.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- return await response.json();
-
- } catch (error) {
- console.error('Error loading popular terms:', error);
- return {terms: []};
- }
- }
-
- /**
- * Update the content type and reload terms
- */
- setContentType(contentType) {
- if (this.contentType === contentType) return;
-
- this.contentType = contentType;
-
- // If already initialized, reload terms for new content type
- if (this.isInitialized) {
- this.loadTermsForContentType();
- } else {
- this.init();
- }
- }
-
- /**
- * Get selected terms
- */
- getSelectedTerms() {
- if (!this.selector) return [];
-
- return Object.keys(this.selector.selectedItems).map(id => parseInt(id));
- }
-
- /**
- * Set selected terms
- */
- setSelectedTerms(termIds) {
- if (!this.selector) return;
-
- // Clear current selections
- this.selector.selectedItems = {};
-
- // Add new selections
- termIds.forEach(id => {
- const termElement = this.selectorContainer.querySelector(`li[data-id="${id}"]`);
- if (termElement) {
- const name = termElement.querySelector('.term-name').textContent;
- this.selector.selectedItems[id] = name;
- }
- });
-
- // Update UI
- this.selector.updateSelected();
- }
-
- /**
- * Escape HTML special characters to prevent XSS
- */
- escapeHtml(text) {
- if (!text) return '';
-
- return text
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
- }
-}
diff --git a/src/feed/feed/components/index.php b/src/feed/feed/components/index.php
deleted file mode 100644
index e69de29..0000000
--- a/src/feed/feed/components/index.php
+++ /dev/null
diff --git a/src/feed/feed/index.js b/src/feed/feed/index.js
deleted file mode 100644
index cc34d9d..0000000
--- a/src/feed/feed/index.js
+++ /dev/null
@@ -1,698 +0,0 @@
-// index.js - Main entry point for the Feed Block
-import ErrorHandling from './services/ErrorHandlingService';
-import StateManager from './services/StateManager';
-import FeedService from './services/FeedService';
-import FavouritesService from './services/FavouritesService';
-import FilterService from './services/FilterService';
-import FeedGrid from './components/FeedGrid';
-import FilterPanel from './components/FilterPanel';
-import GalleryModal from './components/GalleryModal';
-import LoadingState from './components/LoadingState';
-// import TaxonomySelector from './components/TaxonomySelector';
-import cache from './utils/cache';
-import { debounce } from './utils/formatters';
-
-class FeedBlock {
- constructor(container) {
- // Store container reference
- this.container = container;
-
- // Initialize config
- this.initializeConfig();
-
- // Create services and components
- this.initializeServices();
- this.initializeComponents();
- this.initializeErrorHandling();
- this.initializeAccessibility();
-
- // Initialize state from URL
- this.loadStateFromURL();
- // Set up event delegation for efficiency
- this.setupEventDelegation();
- this.loadItems();
-
-
- // Check for highlighted item
- if (this.config.highlight && this.stateManager.getState().page === 1) {
- console.log(this.config.highlight);
- console.log('Opening Highlighted Item');
- this.openGallery();
- }
-
- }
-
- /**
- * Initialize error handling
- */
- initializeErrorHandling() {
- this.errorHandler = new ErrorHandling({
- apiUrl: this.config.apiUrl,
- logToServer: true,
- displayNotifications: true
- });
-
- // Pass error handler to services
- this.feedService.errorHandler = this.errorHandler;
- this.favouritesService.errorHandler = this.errorHandler;
- }
-
- /**
- * Initialize configuration from container data
- */
- initializeConfig() {
- // Get settings from container data attribute
- const settings = JSON.parse(this.container.dataset.settings || '{}');
-
- // Merge with globals
- this.config = {
- apiUrl: window.feedSettings?.apiUrl || '',
- nonce: window.feedSettings?.nonce || '',
- currentUser: window.feedSettings?.currentUser || null,
-
- content: settings.content || 'tattoo',
- contentTypes: settings.contentTypes || ['tattoo'],
- taxonomies: settings.taxonomies || [],
- defaultOrder: settings.defaultOrder || 'date',
- itemsPerPage: settings.itemsPerPage || 12,
-
- // Context information
- context: settings.context || null,
- name: settings.name || '',
- id: settings.id || '',
- inheritQuery: settings.inheritQuery || false,
-
- // Initial terms for taxonomies
- initialTerms: settings.initial_terms || {},
-
- // Source information for analytics
- source: this.container.dataset.source || '',
- sourceType: this.container.dataset.context || '',
-
- // Optional highlight
- highlight: null,
-
- // Gallery mode
- isGallery: settings.isGallery || false,
- showAuthor: true,
- showDate: false,
-
- // Feature flags
- loadMoreTax: settings.loadMoreTax ?? true,
-
- // User preferences
- viewMode: localStorage.getItem('feedViewMode') || 'grid',
- };
-
- // Get highlight from URL if present
- this.config.highlight = this.getHighlightFromURL();
-
- // Adjust config based on context
- if (this.config.context) {
- switch (this.config.context.type) {
- case 'author':
- this.config.isGallery = true;
- this.config.showAuthor = false;
- this.config.showDate = true;
- break;
- }
- }
- }
-
- /**
- * Initialize services
- */
- initializeServices() {
- // Create state manager
- this.stateManager = new StateManager({
- content: this.config.content,
- defaultOrder: this.config.defaultOrder,
- });
-
- // Create filter service
- this.filterService = new FilterService({
- taxonomyMap: window.taxonomy_for || {},
- contentTypeMap: window.feed_types || {},
- initialTerms: this.config.initialTerms,
- });
-
- // Create API service
- this.feedService = new FeedService(
- this.config.apiUrl,
- this.config.nonce
- );
-
- // Create favourites service
- this.favouritesService = new FavouritesService(
- this.config.apiUrl,
- this.config.nonce
- );
-
- // Initialize favourites
- this.favouritesService.init();
-
- // Make available globally for legacy code
- window.hasFavourited = (type, id) => this.favouritesService.isFavourited(type, id);
- }
-
- /**
- * Initialize components
- */
- initializeComponents() {
- // Create filter panel with centralized onChange handler
- this.filterPanel = new FilterPanel(
- this.container.querySelector('.feed-filters'),
- {
- taxonomies: this.config.taxonomies,
- contentTypes: this.config.contentTypes,
- taxonomyFor: window.taxonomy_for || {},
- defaultContent: this.config.content,
- // Pass a reference to the handler instead of binding to avoid duplication
- onChange: this.handleFilterChange.bind(this)
- }
- );
-
- // IMPORTANT: Initialize taxonomy filters with current content type
- this.filterPanel.updateTaxonomyFilters(this.config.content);
- this.filterPanel.updateOrderFilters(this.config.content);
-
- // Create feed grid
- this.feedGrid = new FeedGrid(
- this.container.querySelector('.feed-grid'),
- {
- isGallery: this.config.isGallery,
- showAuthor: this.config.showAuthor,
- showDate: this.config.showDate,
- }
- );
-
- // Create loading state
- this.loadingState = new LoadingState(
- this.container.querySelector('.feed-overlay'),
- {
- contentTypes: this.config.contentTypes,
- taxonomies: this.config.taxonomies,
- }
- );
-
- // Set up gallery if needed
- if (this.config.isGallery) {
- this.feedGrid.setGalleryOpenHandler(this.openGallery.bind(this));
- }
- }
-
- /**
- * Use event delegation for improved performance
- */
- setupEventDelegation() {
- // State change listener
- this.stateManager.subscribe(this.handleStateChange.bind(this));
-
- // Single container event listener for load more
- this.container.addEventListener('click', (e) => {
- // Handle load more button
- if (e.target.closest('.load-more')) {
- this.handleLoadMore();
- e.preventDefault();
- }
- });
-
- // Global events that should only have one listener
- window.addEventListener('popstate', this.handlePopState.bind(this));
- document.addEventListener('galleryClose', () => this.gallery = null);
- document.addEventListener('favourites-updated', this.handleFavouritesUpdate.bind(this));
- }
-
- /**
- * Handle browser history navigation
- */
- handlePopState(e) {
- if (e.state && e.state.filters) {
- // Update state manager with filters from URL
- this.stateManager.updateFilters(e.state.filters);
-
- // Update UI
- this.filterPanel.loadFromURL();
-
- // Update taxonomy and order filters for current content type
- const contentType = e.state.filters.content || this.config.content;
- this.filterPanel.updateTaxonomyFilters(contentType);
- this.filterPanel.updateOrderFilters(contentType);
-
- // Load items with updated filters
- this.loadItems();
-
-
-
- // Announce to screen readers
- this.announceToScreenReader('Updated filters from browser history');
- }
- }
-
- /**
- * Handle state changes
- */
- handleStateChange(state) {
- // Update loading UI
- this.updateLoadingUI(state.loading);
-
- // Update load more button visibility
- this.updateLoadMoreButton(state.hasMore);
- }
-
- /**
- * Update loading UI
- */
- updateLoadingUI(loading) {
- if (loading) {
- this.loadingState.show();
- } else {
- this.loadingState.hide();
- }
-
- // Update loading spinner
- const spinner = this.container.querySelector('.loading-spinner');
- if (spinner) {
- spinner.hidden = !loading;
- }
-
- // Update load more button
- const loadMoreBtn = this.container.querySelector('.load-more');
- if (loadMoreBtn) {
- loadMoreBtn.disabled = loading;
- }
- }
-
- /**
- * Update load more button visibility
- */
- updateLoadMoreButton(hasMore) {
- const loadMoreBtn = this.container.querySelector('.load-more');
- if (loadMoreBtn) {
- loadMoreBtn.hidden = !hasMore;
- }
- }
-
- /**
- * Handle filter changes
- */
- handleFilterChange(filters) {
- // Check if content type has changed
- const prevContentType = this.stateManager.getState().filters;
- const contentTypeChanged = prevContentType !== filters;
-
- // Update state
- this.stateManager.updateFilters(filters);
-
- // Reset pagination
- this.stateManager.resetPagination();
-
- // Reload items (with cache reset if content type changed)
- this.loadItems(contentTypeChanged);
- }
-
- /**
- * Handle favourites updates
- */
- handleFavouritesUpdate(event) {
- const { type, id, isFavourited } = event.detail;
-
- // Update UI
- this.feedGrid.updateFavouriteStatus(type, id, isFavourited);
-
- // Show notification
- this.showNotification(
- isFavourited ? 'Added to favourites' : 'Removed from favourites',
- 'success'
- );
- }
-
- /**
- * Handle load more button click
- */
- handleLoadMore() {
- const state = this.stateManager.getState();
-
- if (state.loading || !state.hasMore) {
- return;
- }
-
- // Increment page
- this.stateManager.nextPage();
-
- // Load more items
- this.loadItems();
- }
-
- /**
- * Load items from API with optional cache busting
- */
- async loadItems(resetCache = false) {
- const state = this.stateManager.getState();
-
- // Skip if already loading
- if (state.loading) {
- return;
- }
-
- // Update loading state
- this.stateManager.setLoading(true);
- console.log(this.config);
- try {
- // Get query parameters - explicitly include content type
- const params = {
- filters: {
- ...state.filters,
- // Ensure content type is explicitly set
- content: state.filters.content || this.config.content
- },
- page: state.page,
- highlight: this.config.highlight,
- source: this.config.source,
- sourceType: this.config.sourceType
- };
-
-
- // Fetch data with cache busting if specified
- const data = await this.feedService.fetchFeed(params, resetCache);
-
- // Clear grid on first page
- if (state.page === 1) {
- this.feedGrid.clear();
- }
- // Handle empty results
- if (!data || !data.items || data.items.length === 0) {
- if (state.page === 1) {
- this.feedGrid.showEmptyState(!!state.filters.favouritesOnly);
- }
- this.stateManager.setState({ hasMore: false });
- } else {
- // Render items
- this.feedGrid.renderItems(data.items, state.page > 1);
-
- // Update has more
- this.stateManager.setState({ hasMore: data.hasMore });
-
-
- }
- } catch (error) {
- this.handleError(error);
- } finally {
- // Update loading state
- this.stateManager.setLoading(false);
- }
- }
-
- /**
- * Handle errors
- */
- handleError(error) {
- if (this.errorHandler) {
- return this.errorHandler.handleApiError(
- error,
- {
- component: 'FeedBlock',
- action: 'loadItems'
- },
- () => this.loadItems()
- );
- }
-
- // Fallback to basic error handling if errorHandler not available
- this.showNotification(
- 'Failed to load content. Please try again.',
- 'error',
- [{
- label: 'Refresh',
- icon: 'refresh',
- action: () => {
- window.location.reload();
- }
- }]
- );
- }
-
- /**
- * Show notification
- */
- showNotification(message, type = 'info', actions = []) {
- if (window.jvbNotifications) {
- window.jvbNotifications.queuePopupNotification({
- type: type,
- message: message,
- icon: type === 'error' ? 'alert' : (type === 'success' ? 'heart' : 'info'),
- priority: type === 'error' ? 'high' : 'medium',
- displayDuration: 3000,
- actions: actions
- });
- }
-
- // Update live region for accessibility
- const liveRegion = this.container.querySelector('.live-region');
- if (liveRegion) {
- liveRegion.textContent = message;
- }
- }
-
- /**
- * Load state from URL parameters
- */
- loadStateFromURL() {
- // Get filters from URL via the filter service
- const urlFilters = this.filterService.parseFiltersFromURL();
- if (Object.keys(urlFilters).length === 0) {
- return;
- }
-
- // Determine content type - from URL or default config
- const contentType = urlFilters['f_content'] || this.config.content;
-
- // Update state manager with URL filters
- if (Object.keys(urlFilters).length > 0) {
- this.stateManager.updateFilters(urlFilters);
- }
-
- // Update UI to match current state (handled by FilterPanel)
- this.filterPanel.loadFromURL();
-
- // IMPORTANT: Make sure taxonomy filters visibility is correct
- // for the current content type
- this.filterPanel.updateTaxonomyFilters(contentType);
- this.filterPanel.updateOrderFilters(contentType);
-
- }
-
- /**
- * Get highlighted item from URL
- */
- getHighlightFromURL() {
- const searchParams = new URLSearchParams(window.location.search);
-
- // Check for content type parameters
- const contentTypes = ['tattoo', 'piercing', 'artwork'];
-
- for (const type of contentTypes) {
- if (searchParams.has(type)) {
- return { [type]: searchParams.get(type) };
- }
- }
-
- return null;
- }
-
- /**
- * Scroll to highlighted item
- */
- scrollToHighlightedItem() {
- if (!this.config.highlight) return;
-
- // Get highlight type and ID
- const type = Object.keys(this.config.highlight)[0];
- const id = this.config.highlight[type];
-
- if (!type || !id) return;
-
- // Find the item
- const item = this.container.querySelector(`#${type}-${id}`);
-
- if (item) {
- // Scroll to item
- item.scrollIntoView({ behavior: 'smooth', block: 'center' });
-
- // Highlight the item
- item.classList.add('highlighted');
-
- // Open gallery if in gallery mode
- if (this.config.isGallery) {
- console.log('It is a gallery item!');
- const items = Array.from(this.container.querySelectorAll('.feed-item'));
- const index = items.indexOf(item);
-
- if (index !== -1) {
- this.openGallery(index);
- }
- }
-
- // Clean up URL
- window.history.replaceState('', '', window.location.origin + window.location.pathname);
- }
- }
-
- /**
- * Open gallery view
- */
- openGallery(index) {
- if (!this.config.isGallery || this.gallery) return;
-
- // Get gallery items from grid
- const items = this.feedGrid.getGalleryItems();
- console.log(items);
-
- // Create gallery with unified callbacks object
- this.gallery = new GalleryModal(items, index);
-
- // Set callbacks
- this.gallery.setCallbacks({
- onClose: () => this.gallery = null,
- onLoadMore: this.handleGalleryLoadMore.bind(this),
- onNavigate: (newIndex) => {
- if (newIndex >= items.length - 3 && this.stateManager.getState().hasMore) {
- this.handleGalleryLoadMore();
- }
- }
- });
-
- this.gallery.show();
- }
-
- /**
- * Handle load more request from gallery
- */
- async handleGalleryLoadMore() {
- const state = this.stateManager.getState();
-
- if (!state.hasMore || state.loading) {
- return false;
- }
-
- // Increment page
- this.stateManager.nextPage();
-
- // Load more items
- await this.loadItems();
-
- // Update gallery items
- if (this.gallery) {
- this.gallery.updateItems(this.feedGrid.getGalleryItems());
- }
-
- return true;
- }
-
- /**
- * Initialize accessibility features
- */
- initializeAccessibility() {
- // Create live region for screen reader announcements
- this.liveRegion = document.createElement('div');
- this.liveRegion.setAttribute('aria-live', 'polite');
- this.liveRegion.setAttribute('role', 'status');
- this.liveRegion.className = 'screen-reader-text live-region';
- this.container.appendChild(this.liveRegion);
-
- // Add keyboard shortcut support
- this.bindKeyboardShortcuts();
- }
-
- /**
- * Set up focus traps for modals
- */
- setupFocusTraps() {
- // Add focus trap to filter dropdowns
- this.filterDropdowns = this.container.querySelectorAll('.filter-dropdown');
- this.filterDropdowns.forEach(dialog => {
- dialog.addEventListener('show', () => {
- // Store current focus
- this._previouslyFocused = document.activeElement;
-
- // Focus first focusable element
- const focusable = dialog.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (focusable.length) {
- focusable[0].focus();
- }
- });
-
- dialog.addEventListener('close', () => {
- // Restore focus
- if (this._previouslyFocused) {
- this._previouslyFocused.focus();
- }
- });
- });
- }
-
- /**
- * Bind keyboard shortcuts
- */
- bindKeyboardShortcuts() {
- // Use a single event listener for keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- // Only handle when feed is visible (check if in viewport)
- if (!this.isElementInViewport(this.container)) return;
-
- // Escape key closes any open dialogs
- if (e.key === 'Escape') {
- const openDialogs = this.container.querySelectorAll('dialog[open]');
- if (openDialogs.length) {
- openDialogs[0].close();
- e.preventDefault();
- }
- }
-
- // Alt+F opens filters (common accessibility pattern)
- if (e.key === 'f' && e.altKey) {
- const filterToggle = this.container.querySelector('.filter-toggle');
- if (filterToggle) {
- filterToggle.click();
- e.preventDefault();
- }
- }
-
- // Space or Enter on focused items activates them
- if ((e.key === ' ' || e.key === 'Enter') && document.activeElement.classList.contains('feed-item')) {
- const link = document.activeElement.querySelector('a');
- if (link) {
- link.click();
- e.preventDefault();
- }
- }
- });
- }
-
-
- /**
- * Check if element is in viewport
- */
- isElementInViewport(el) {
- const rect = el.getBoundingClientRect();
- return (
- rect.top >= 0 &&
- rect.left >= 0 &&
- rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
- rect.right <= (window.innerWidth || document.documentElement.clientWidth)
- );
- }
-
- /**
- * Announce message to screen readers
- */
- announceToScreenReader(message) {
- if (!this.liveRegion) return;
-
- this.liveRegion.textContent = message;
- }
-}
-
-export default FeedBlock;
diff --git a/src/feed/feed/index.php b/src/feed/feed/index.php
deleted file mode 100644
index e69de29..0000000
--- a/src/feed/feed/index.php
+++ /dev/null
diff --git a/src/feed/feed/services/ErrorHandlingService.js b/src/feed/feed/services/ErrorHandlingService.js
deleted file mode 100644
index 8810467..0000000
--- a/src/feed/feed/services/ErrorHandlingService.js
+++ /dev/null
@@ -1,349 +0,0 @@
-/**
- * ErrorHandlingService.js - Centralized error handling for the feed block
- */
-class ErrorHandlingService {
- constructor(options = {}) {
- this.options = {
- apiUrl: '',
- logToServer: true,
- displayNotifications: true,
- notificationDuration: 5000,
- retryEnabled: true,
- maxRetries: 3,
- ...options
- };
-
- this.retryCount = 0;
- }
-
- /**
- * Handle API errors
- * @param {Error} error - The error object
- * @param {Object} context - Additional context information
- * @param {Function} retryCallback - Function to retry the operation
- * @returns {Promise} - Result of error handling
- */
- async handleApiError(error, context = {}, retryCallback = null) {
- // Log error to console
- console.error('API Error:', error, context);
-
- // Determine error type and message
- const errorType = this.getErrorType(error);
- const errorMessage = this.getErrorMessage(error, errorType);
-
- // Log to server if enabled
- if (this.options.logToServer) {
- await this.logErrorToServer(errorType, errorMessage, context);
- }
-
- // Handle specific error types
- switch (errorType) {
- case 'network':
- // Check if we should retry
- if (this.options.retryEnabled && this.retryCount < this.options.maxRetries && retryCallback) {
- this.retryCount++;
- return this.retryWithBackoff(retryCallback);
- }
- break;
-
- case 'auth':
- // Handle authentication errors - possibly redirect to login
- this.handleAuthError();
- break;
-
- case 'rate_limit':
- // Handle rate limiting
- return this.handleRateLimitError(retryCallback);
-
- case 'server':
- // Server errors may be temporary
- if (this.options.retryEnabled && this.retryCount < this.options.maxRetries && retryCallback) {
- this.retryCount++;
- return this.retryWithBackoff(retryCallback);
- }
- break;
- }
-
- // Display error notification if enabled
- if (this.options.displayNotifications) {
- this.displayErrorNotification(errorMessage, errorType, retryCallback);
- }
-
- // Reset retry count if we're not retrying
- if (!retryCallback || !this.options.retryEnabled) {
- this.retryCount = 0;
- }
-
- // Return standardized error object
- return {
- success: false,
- error: errorType,
- message: errorMessage,
- context
- };
- }
-
- /**
- * Get error type based on error object
- */
- getErrorType(error) {
- if (error.name === 'AbortError') {
- return 'timeout';
- }
-
- if (!navigator.onLine) {
- return 'offline';
- }
-
- if (error.response) {
- const status = error.response.status;
-
- if (status >= 400 && status < 500) {
- if (status === 401 || status === 403) {
- return 'auth';
- }
- if (status === 429) {
- return 'rate_limit';
- }
- return 'client';
- }
-
- if (status >= 500) {
- return 'server';
- }
- }
-
- return 'network';
- }
-
- /**
- * Get user-friendly error message
- */
- getErrorMessage(error, type) {
- const defaultMessages = {
- network: "We couldn't connect to the server. Please check your connection and try again.",
- timeout: "The request took too long to complete. Please try again.",
- offline: "You appear to be offline. Please check your internet connection.",
- auth: "Your session may have expired. Please log in again.",
- rate_limit: "You've made too many requests. Please wait a moment and try again.",
- server: "We're experiencing technical difficulties. Please try again later.",
- client: "Something went wrong with your request. Please try again.",
- unknown: "An unexpected error occurred. Please try again."
- };
-
- // Try to get message from error object
- if (error.response && error.response.data && error.response.data.message) {
- return error.response.data.message;
- }
-
- if (error.message) {
- return error.message;
- }
-
- // Fall back to default message
- return defaultMessages[type] || defaultMessages.unknown;
- }
-
- /**
- * Log error to server
- */
- async logErrorToServer(type, message, context) {
- try {
- if (!this.options.apiUrl) return;
-
- const data = new FormData();
- data.append('error_type', type);
- data.append('message', message);
- data.append('context', JSON.stringify({
- ...context,
- url: window.location.href,
- userAgent: navigator.userAgent,
- timestamp: new Date().toISOString()
- }));
-
- // Use fetch with no-cors to ensure this always succeeds
- // even if there are CORS issues
- await fetch(`${this.options.apiUrl}errors/log`, {
- method: 'POST',
- headers: {
- 'X-WP-Nonce': window.feedSettings?.nonce || ''
- },
- body: data
- });
- } catch (e) {
- // Silently fail - we don't want errors in error reporting
- console.warn('Failed to log error to server', e);
- }
- }
-
- /**
- * Display error notification
- */
- displayErrorNotification(message, type, retryCallback) {
- // Use WordPress notification system if available
- if (window.jvbNotifications) {
- const actions = [];
-
- // Add retry action if callback provided
- if (retryCallback) {
- actions.push({
- label: 'Try Again',
- icon: 'refresh',
- action: retryCallback
- });
- }
-
- window.jvbNotifications.queuePopupNotification({
- type: 'error',
- message: message,
- icon: 'alert',
- priority: 'high',
- displayDuration: this.options.notificationDuration,
- actions: actions
- });
- return;
- }
-
- // Fallback to basic alert if notification system not available
- alert(message);
- }
-
- /**
- * Handle authentication errors
- */
- handleAuthError() {
- // Redirect to login page if user isn't logged in
- if (window.feedSettings && window.feedSettings.loginUrl) {
- window.location.href = window.feedSettings.loginUrl;
- return;
- }
-
- // Or reload the page to refresh session
- window.location.reload();
- }
-
- /**
- * Handle rate limit errors
- */
- async handleRateLimitError(retryCallback) {
- // Wait for escalating periods before retrying
- const waitTime = 2000 * (this.retryCount + 1);
-
- await new Promise(resolve => setTimeout(resolve, waitTime));
-
- if (retryCallback) {
- this.retryCount++;
- return retryCallback();
- }
- }
-
- /**
- * Retry with exponential backoff
- */
- async retryWithBackoff(callback) {
- const backoffTime = Math.min(1000 * Math.pow(2, this.retryCount), 10000);
-
- // Display retry notification
- if (this.options.displayNotifications) {
- this.displayRetryNotification(backoffTime);
- }
-
- // Wait before retrying
- await new Promise(resolve => setTimeout(resolve, backoffTime));
-
- // Try again
- return callback();
- }
-
- /**
- * Display retry notification
- */
- displayRetryNotification(backoffTime) {
- if (window.jvbNotifications) {
- window.jvbNotifications.queuePopupNotification({
- type: 'info',
- message: `Retrying in ${backoffTime/1000} seconds...`,
- icon: 'refresh',
- priority: 'medium',
- displayDuration: backoffTime
- });
- }
- }
-
- /**
- * Reset retry counter
- */
- resetRetryCount() {
- this.retryCount = 0;
- }
-
- /**
- * Handle user feedback for errors
- */
- collectUserFeedback(errorInfo) {
- // Create a modal for collecting feedback
- const modal = document.createElement('dialog');
- modal.className = 'error-feedback-modal';
-
- modal.innerHTML = `
- <h2>Help Us Improve</h2>
- <p>We encountered an error. Would you like to tell us what happened?</p>
- <form method="dialog">
- <textarea placeholder="What were you trying to do when this error occurred?"></textarea>
- <div class="actions">
- <button value="cancel">Skip</button>
- <button value="submit" class="primary">Send Feedback</button>
- </div>
- </form>
- `;
-
- document.body.appendChild(modal);
-
- return new Promise((resolve) => {
- modal.addEventListener('close', () => {
- const feedback = modal.returnValue === 'submit'
- ? modal.querySelector('textarea').value
- : null;
-
- document.body.removeChild(modal);
- resolve(feedback);
- });
-
- modal.showModal();
- });
- }
-
- /**
- * Handle global errors
- */
- setupGlobalErrorHandling() {
- // Handle uncaught errors
- window.addEventListener('error', event => {
- this.handleApiError(
- event.error || new Error(event.message),
- {
- message: event.message,
- filename: event.filename,
- lineno: event.lineno,
- colno: event.colno,
- type: 'global_error'
- }
- );
-
- // Don't prevent default - let browser show its own error if needed
- });
-
- // Handle unhandled promise rejections
- window.addEventListener('unhandledrejection', event => {
- this.handleApiError(
- event.reason,
- {
- type: 'unhandled_promise',
- message: event.reason?.message || 'Unhandled promise rejection'
- }
- );
- });
- }
-}
-
-export default ErrorHandlingService;
diff --git a/src/feed/feed/services/FavouritesService.js b/src/feed/feed/services/FavouritesService.js
deleted file mode 100644
index f24eaac..0000000
--- a/src/feed/feed/services/FavouritesService.js
+++ /dev/null
@@ -1,271 +0,0 @@
-// FavouritesService.js - Manages favourites functionality
-class FavouritesService {
- constructor(apiUrl, nonce) {
- this.apiUrl = apiUrl;
- this.nonce = nonce;
- this.cache = {};
- this.eventHandlersBound = false;
- this.initialized = false;
- }
-
- /**
- * Initialize the service
- */
- async init() {
- if (this.initialized) return;
-
- // Load cached favourites from localStorage
- this.loadFromLocalStorage();
-
- // Bind event handlers if not already bound
- if (!this.eventHandlersBound) {
- this.bindEventHandlers();
- }
-
- // Mark as initialized
- this.initialized = true;
-
- // If user is logged in, fetch favourites from server
- if (feedSettings.currentUser) {
- await this.fetchFavourites();
- }
- }
-
- /**
- * Load favourites from localStorage
- */
- loadFromLocalStorage() {
- try {
- const storedCache = localStorage.getItem('userFavourites');
- if (storedCache) {
- this.cache = JSON.parse(storedCache);
- } else {
- this.cache = {};
- localStorage.setItem('userFavourites', '{}');
- }
- } catch (e) {
- console.error('Error loading favourites from localStorage', e);
- this.cache = {};
- localStorage.setItem('userFavourites', '{}');
- }
- }
-
- /**
- * Save favourites to localStorage
- */
- saveToLocalStorage() {
- try {
- localStorage.setItem('userFavourites', JSON.stringify(this.cache));
- } catch (e) {
- console.error('Error saving favourites to localStorage', e);
- }
- }
-
- /**
- * Bind event handlers for favourites
- */
- bindEventHandlers() {
- // Listen for favourites updated events
- document.addEventListener('favourites-updated', this.handleFavouritesUpdate.bind(this));
-
- // Listen for document clicks to handle favourite buttons
- document.addEventListener('click', this.handleClick.bind(this));
-
- this.eventHandlersBound = true;
- }
-
- /**
- * Handle click events for favourite buttons
- */
- handleClick(event) {
- const target = event.target.closest('button.favourite');
- if (!target) return;
-
- // Check if user is logged in
- if (!feedSettings.currentUser) {
- // Redirect to login if not logged in
- window.location.href = feedSettings.loginUrl;
- return;
- }
-
- // Get favourite data
- const type = target.dataset.type;
- const id = target.dataset.id;
- const artistId = target.dataset.artist || '';
-
- // Toggle favourite
- const isFavourited = target.classList.contains('favourited');
- this.toggleFavourite(type, id, !isFavourited, artistId);
-
- // Prevent default behavior
- event.preventDefault();
- event.stopPropagation();
- }
-
- /**
- * Handle favourites update events
- */
- handleFavouritesUpdate(event) {
- const { type, id, isFavourited } = event.detail;
-
- // Update cache
- this.updateCache(type, id, isFavourited);
-
- // Update UI
- this.updateUI(type, id, isFavourited);
- }
-
- /**
- * Update UI elements for a favourite
- */
- updateUI(type, id, isFavourited) {
- // Find all matching buttons
- const buttons = document.querySelectorAll(
- `button.favourite[data-type="${type.replace('e_', '')}"][data-id="${id}"]`
- );
-
- // Update each button
- buttons.forEach(button => {
- button.classList.toggle('favourited', isFavourited);
- button.innerHTML = feedSettings.icons[isFavourited ? 'heart-filled' : 'heart'];
- button.title = isFavourited ? 'Remove from favourites' : 'Add to favourites';
- });
- }
-
- /**
- * Toggle a favourite
- */
- async toggleFavourite(type, id, favourite, artistId = '') {
- try {
- // Prepare request data
- const data = new FormData();
- data.append('user_id', feedSettings.currentUser.id);
- data.append('type', `e_${type}`);
- data.append('target_id', id);
- data.append('isFavourited', favourite ? '1' : '0');
-
- // Optional artist data
- if (artistId) {
- data.append('artist_id', artistId);
- }
-
- // Make request
- const response = await fetch(`${this.apiUrl}favourites/toggle`, {
- method: 'POST',
- headers: {
- 'X-WP-Nonce': this.nonce
- },
- body: data
- });
-
- if (!response.ok) {
- throw new Error('Failed to toggle favourite');
- }
-
- // Update cache
- this.updateCache(`e_${type}`, id, favourite);
-
- // Dispatch event for other components
- document.dispatchEvent(new CustomEvent('favourites-updated', {
- detail: {
- type: `e_${type}`,
- id: id,
- isFavourited: favourite
- }
- }));
-
- return true;
-
- } catch (error) {
- console.error('Error toggling favourite:', error);
-
- // Show error notification
- if (window.jvbNotifications) {
- window.jvbNotifications.queuePopupNotification({
- type: 'error',
- message: 'Failed to update favourite',
- priority: 'medium',
- displayDuration: 3000
- });
- }
-
- return false;
- }
- }
-
- /**
- * Fetch favourites from the server
- */
- async fetchFavourites() {
- if (!feedSettings.currentUser) return;
-
- try {
- const userId = feedSettings.currentUser.id;
- const queryParams = new URLSearchParams({ user_id: userId });
-
- const response = await fetch(`${this.apiUrl}favourites/all?${queryParams.toString()}`, {
- headers: {
- 'X-WP-Nonce': this.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- if (data && data.favourites) {
- // Convert to cache format
- const newCache = {};
- data.favourites.forEach(fav => {
- newCache[`${fav.type}_${fav.target_id}`] = true;
- });
-
- // Update cache
- this.cache = newCache;
- this.saveToLocalStorage();
- }
-
- return this.cache;
-
- } catch (error) {
- console.error('Failed to load favourites:', error);
- return this.cache;
- }
- }
-
- /**
- * Check if an item is favourited
- */
- isFavourited(type, id) {
- // Ensure type has prefix
- if (!type.startsWith('e_')) {
- type = `e_${type}`;
- }
-
- return !!this.cache[`${type}_${id}`];
- }
-
- /**
- * Update the cache
- */
- updateCache(type, id, isFavourited) {
- // Ensure type has prefix
- if (!type.startsWith('e_')) {
- type = `e_${type}`;
- }
-
- // Update cache
- if (isFavourited) {
- this.cache[`${type}_${id}`] = true;
- } else {
- delete this.cache[`${type}_${id}`];
- }
-
- // Save to localStorage
- this.saveToLocalStorage();
- }
-}
-
-export default FavouritesService;
diff --git a/src/feed/feed/services/FeedService.js b/src/feed/feed/services/FeedService.js
deleted file mode 100644
index f454c6d..0000000
--- a/src/feed/feed/services/FeedService.js
+++ /dev/null
@@ -1,430 +0,0 @@
-// FeedService.js - Handles API interactions for the feed block
-import cache from '../utils/cache';
-class FeedService {
- constructor(apiUrl, nonce) {
- this.apiUrl = apiUrl;
- this.nonce = nonce;
- this.currentRequest = null;
- this.timeoutId = null;
- this.pendingRequests = new Map(); // Track in-flight requests by cache key
- }
- /**
- * Fetch feed data from the API with request deduplication and caching
- * @param {Object} params - Request parameters
- * @param {boolean} resetCache - Whether to bypass cache
- * @returns {Promise<Object>} - Feed data
- */
- async fetchFeed(params, resetCache = false) {
- // Create cache key from params
- const cacheKey = this.createCacheKey(params);
- resetCache = true;
- //TODO: remove default reset
- // Check cache first (unless reset requested)
- if (!resetCache) {
- const cached = cache.get(cacheKey);
- if (cached) {
- return cached;
- }
- }
-
- // Check if this exact request is already in flight
- if (this.pendingRequests.has(cacheKey)) {
- // Return the existing promise for the in-flight request
- return this.pendingRequests.get(cacheKey);
- }
-
- // Cancel any existing request for different params
- this.abortCurrentRequest();
-
- // Create a new request
- try {
- // Create abort controller for this request
- const controller = new AbortController();
- this.currentRequest = controller;
-
- // Set timeout for request
- this.timeoutId = setTimeout(() => {
- if (this.currentRequest === controller) {
- controller.abort();
- }
- }, 30000); // 30 second timeout
-
-
- // Build query parameters
- const queryParams = this.buildQueryParams(params);
-
- // Add cache busting if needed
- if (resetCache) {
- queryParams.append('_', Date.now());
- }
-
- const requestUrl = `${this.apiUrl}feed?${queryParams.toString()}`;
-
- // Create the promise for this request
- const requestPromise = (async () => {
- try {
- const response = await fetch(requestUrl, {
- headers: {
- 'X-WP-Nonce': this.nonce
- },
- signal: controller.signal
- });
-
- // Clear timeout
- if (this.timeoutId) {
- clearTimeout(this.timeoutId);
- this.timeoutId = null;
- }
-
- // Check for successful response
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- // Parse response
- const data = await response.json();
-
-
- // Reset error handler retry count on success
- if (this.errorHandler) {
- this.errorHandler.resetRetryCount();
- }
-
- // Cache the result for 5 minutes (unless it's a reset request)
- if (!resetCache) {
- cache.set(cacheKey, data, 300000);
- }
-
- return data;
- } finally {
- // Remove from pending requests
- this.pendingRequests.delete(cacheKey);
- }
- })();
-
- // Store the promise in the pending requests map
- this.pendingRequests.set(cacheKey, requestPromise);
-
- return requestPromise;
-
- } catch (error) {
- // Clean up pending request
- this.pendingRequests.delete(cacheKey);
-
- if (this.errorHandler) {
- return this.errorHandler.handleApiError(
- error,
- { params },
- () => this.fetchFeed(params, resetCache)
- );
- }
-
- // Don't throw for aborted requests
- if (error.name === 'AbortError') {
- return null;
- }
-
- // Re-throw other errors
- throw error;
-
- } finally {
- this.currentRequest = null;
-
- if (this.timeoutId) {
- clearTimeout(this.timeoutId);
- this.timeoutId = null;
- }
- }
- }
-
- /**
- * Create a cache key from params object
- * @param {Object} params - Request parameters
- * @returns {string} - Cache key
- */
- createCacheKey(params) {
- // Clone to avoid modifying original
- const normalized = JSON.parse(JSON.stringify(params));
-
- // Sort arrays to ensure consistent keys
- if (normalized.filters) {
- Object.keys(normalized.filters).forEach(key => {
- if (Array.isArray(normalized.filters[key])) {
- normalized.filters[key].sort();
- }
- });
- }
-
- return `feed_${JSON.stringify(normalized)}`;
- }
-
- /**
- * Build query parameters from params object
- * @param {Object} params - Request parameters
- * @returns {URLSearchParams} - URL search params
- */
- buildQueryParams(params) {
- const queryParams = new URLSearchParams();
-
- // Add page parameter
- queryParams.append('page', params.page || 1);
-
- // Add context and other metadata
- if (params.context) {
- queryParams.append('context', JSON.stringify(params.context));
- }
-
- if (params.highlight) {
- queryParams.append('highlight', JSON.stringify(params.highlight));
- }
-
- // Add source and source type
- if (params.source) {
- queryParams.append('source', params.source);
- }
-
- if (params.sourceType) {
- queryParams.append('type', params.sourceType);
- }
-
- // Add filters
- const filterParams = params.filters || {};
-
- // Ensure content type is explicitly added as a filter
- if (filterParams.content) {
- queryParams.append('filters[content]', filterParams.content);
- }
-
- // Add remaining filters
- Object.entries(filterParams).forEach(([key, value]) => {
- // Skip content since we added it separately
- if (key === 'content') return;
-
- if (Array.isArray(value) && value.length > 0) {
-
- value.forEach(v => {
- queryParams.append(`filters[${key}][]`, v);
- });
- } else if (value !== null && value !== undefined && value !== '') {
- if(typeof value === 'object' && Object.keys(value).length === 0){
- return;
- }
- queryParams.append(`filters[${key}]`, value);
- }
- });
-
- return queryParams;
- }
-
- /**
- * Fetch taxonomy terms with caching
- * @param {string} taxonomy - Taxonomy name
- * @param {string} search - Search query
- * @param {number} page - Page number
- * @returns {Promise<Object>} - Terms data
- */
- async fetchTerms(taxonomy, search = '', page = 1) {
- // Create cache key
- const cacheKey = `terms_${taxonomy}_${search}_${page}`;
-
- // Check cache first
- const cached = cache.get(cacheKey);
- if (cached) {
- return cached;
- }
-
- try {
- const params = new URLSearchParams({
- search,
- page
- });
-
- const response = await fetch(`${this.apiUrl}terms/${taxonomy}?${params.toString()}`, {
- headers: {
- 'X-WP-Nonce': this.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Cache for 5 minutes
- cache.set(cacheKey, data, 300000);
-
- return data;
-
- } catch (error) {
- console.error('Error fetching terms:', error);
- throw error;
- }
- }
-
- /**
- * Fetch taxonomy terms specific to a content type
- * @param {string} taxonomy - Taxonomy name
- * @param {string} contentType - Content type
- * @param {string} search - Search query
- * @param {number} page - Page number
- * @returns {Promise<Object>} - Terms data
- */
- async fetchTermsForContentType(taxonomy, contentType, search = '', page = 1) {
- // Create cache key
- const cacheKey = `terms_${contentType}_${taxonomy}_${search}_${page}`;
-
- // Check cache first
- const cached = cache.get(cacheKey);
- if (cached) {
- return cached;
- }
-
- try {
- const params = new URLSearchParams({
- search,
- page,
- per_page: 20,
- min_count: 1
- });
-
- const response = await fetch(`${this.apiUrl}terms/for/${contentType}/${taxonomy}?${params.toString()}`, {
- headers: {
- 'X-WP-Nonce': this.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Cache for 5 minutes
- cache.set(cacheKey, data, 300000);
-
- return data;
-
- } catch (error) {
- console.error('Error fetching terms for content type:', error);
- throw error;
- }
- }
- /**
- * Fetch popular terms
- * @param {string} taxonomy - Taxonomy name
- * @returns {Promise<Object>} - Popular terms data
- */
- async fetchPopularTerms(taxonomy) {
- // Create cache key
- const cacheKey = `popular_terms_${taxonomy}`;
-
- // Check cache first
- const cached = cache.get(cacheKey);
- if (cached) {
- return cached;
- }
-
- try {
- const response = await fetch(`${this.apiUrl}terms/popular/${taxonomy}?limit=10`, {
- headers: {
- 'X-WP-Nonce': this.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Cache for 1 hour
- cache.set(cacheKey, data, 3600000);
-
- return data;
-
- } catch (error) {
- console.error('Error fetching popular terms:', error);
- throw error;
- }
- }
-
- /**
- * Fetch related terms
- * @param {string} taxonomy - Taxonomy name
- * @param {number} termId - Term ID
- * @returns {Promise<Object>} - Related terms data
- */
- async fetchRelatedTerms(taxonomy, termId) {
- // Create cache key
- const cacheKey = `related_terms_${taxonomy}_${termId}`;
-
- // Check cache first
- const cached = cache.get(cacheKey);
- if (cached) {
- return cached;
- }
-
- try {
- const response = await fetch(`${this.apiUrl}terms/related/${taxonomy}/${termId}`, {
- headers: {
- 'X-WP-Nonce': this.nonce
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Cache for 1 hour
- cache.set(cacheKey, data, 3600000);
-
- return data;
-
- } catch (error) {
- console.error('Error fetching related terms:', error);
- throw error;
- }
- }
-
- /**
- * Abort the current request if it exists
- */
- abortCurrentRequest() {
- if (this.currentRequest) {
- this.currentRequest.abort();
- this.currentRequest = null;
- }
-
- if (this.timeoutId) {
- clearTimeout(this.timeoutId);
- this.timeoutId = null;
- }
- }
-
- /**
- * Clear feed cache
- * @param {string} contentType - Optional content type to clear cache for
- */
- clearCache(contentType = null) {
- if (contentType) {
- // Find and delete cache entries for specific content type
- Object.keys(cache.storage).forEach(key => {
- if (key.startsWith('feed_') && key.includes(`"content":"${contentType}"`)) {
- cache.remove(key.replace(cache.options.namespace, ''));
- }
- });
- } else {
- // Clear all feed-related cache entries
- Object.keys(cache.storage).forEach(key => {
- if (key.startsWith(cache.options.namespace + 'feed_')) {
- cache.remove(key.replace(cache.options.namespace, ''));
- }
- });
- }
- }
-}
-
-export default FeedService;
diff --git a/src/feed/feed/services/FilterService.js b/src/feed/feed/services/FilterService.js
deleted file mode 100644
index f77814a..0000000
--- a/src/feed/feed/services/FilterService.js
+++ /dev/null
@@ -1,317 +0,0 @@
-// FilterService.js - Service for processing and managing filters
-import cache from '../utils/cache';
-
-class FilterService {
- constructor(options = {}) {
- this.options = {
- taxonomyMap: {},
- contentTypeMap: {},
- initialFilters: {},
- ...options
- };
-
- this.termCache = {
- terms: new Map(),
- timestamp: Date.now()
- };
- }
-
- /**
- * Build query parameters from filters
- * @param {Object} filters - Filter state
- * @param {Object} context - Context data
- * @param {number} page - Page number
- * @param {Object} highlight - Highlighted item
- * @returns {URLSearchParams} - URL search params
- */
- buildQueryParams(filters, context, page = 1, highlight = null) {
- const params = new URLSearchParams();
-
- // Add page parameter
- params.append('page', page);
-
- // Add context if present
- if (context) {
- params.append('context', JSON.stringify(context));
- }
-
- // Add highlight if present
- if (highlight) {
- params.append('highlight', JSON.stringify(highlight));
- }
-
- // Add filters
- if (filters) {
- Object.entries(filters).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach(v => {
- params.append(`filters[${key}][]`, v);
- });
- } else if (value !== null && value !== undefined) {
- params.append(`filters[${key}]`, value);
- }
- });
- }
-
- return params;
- }
-
- /**
- * Update URL based on current filters
- * @param {Object} filters - Current filters
- */
- updateURL(filters) {
- const params = new URLSearchParams();
-
- // Add content type
- if (filters.content && filters.content !== 'all') {
- params.set('type', filters.content);
- }
-
- // Add order and direction
- if (filters.order) {
- params.set('order', filters.order);
- if (filters.direction) {
- params.set('direction', filters.direction);
- }
- }
-
- // Add taxonomy filters
- Object.entries(filters).forEach(([key, value]) => {
- if (Array.isArray(value) && value.length > 0) {
- value.forEach(v => {
- params.append(key, v);
- });
- }
- });
-
- // Add favourites filter
- if (filters.favouritesOnly) {
- params.set('favourites', '1');
- }
-
- // Update URL without reloading page
- const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
- history.pushState({ filters }, '', newUrl);
- }
-
- /**
- * Parse filters from URL parameters
- * @returns {Object} - Parsed filters
- */
- parseFiltersFromURL() {
- const params = new URLSearchParams(window.location.search);
- const filters = {};
-
- // Get content type
- const type = params.get('f_content');
- if (type) {
- filters.content = type;
- }
-
- // Get order and direction
- const order = params.get('f_order');
- if (order) {
- filters.order = order;
- }
-
- const direction = params.get('f_direction');
- if (direction) {
- filters.direction = direction;
- }
-
- // Get favourites flag
- if (params.get('f_favourites') === '1') {
- filters.favouritesOnly = true;
- }
-
- // Process all potential taxonomy parameters
- // Look for parameters with f_ prefix
- for (const [key, value] of params.entries()) {
- if (key.startsWith('f_') && key !== 'f_content' &&
- key !== 'f_order' && key !== 'f_direction' &&
- key !== 'f_favourites') {
-
- const taxName = key.replace('f_', '');
-
- // Add to filters as array if not already
- if (!filters[taxName]) {
- filters[taxName] = [];
- }
-
- // Multiple values could exist for same taxonomy
- if (!filters[taxName].includes(value)) {
- filters[taxName].push(value);
- }
- }
- }
-
- return filters;
- }
-
- /**
- * Get applicable taxonomies for a content type
- * @param {string} contentType - Content type
- * @returns {Array} - Applicable taxonomies
- */
- getApplicableTaxonomies(contentType) {
- const taxonomies = [];
-
- Object.entries(this.options.taxonomyMap).forEach(([taxonomy, info]) => {
- if (info.types && info.types.includes(contentType)) {
- taxonomies.push(taxonomy);
- }
- });
-
- return taxonomies;
- }
-
- /**
- * Fetch taxonomy terms
- * @param {string} taxonomy - Taxonomy name
- * @param {string} search - Search query
- * @param {number} page - Page number
- * @returns {Promise<Object>} - Terms data
- */
- async fetchTerms(taxonomy, search = '', page = 1) {
- // Create cache key based on parameters
- const cacheKey = `terms_${taxonomy}_${search}_${page}`;
-
- // Try to get from cache first
- const cachedTerms = cache.get(cacheKey);
- if (cachedTerms) {
- return cachedTerms;
- }
-
- // If not in cache, fetch from API
- try {
- const params = new URLSearchParams({
- search,
- page
- });
-
- const response = await fetch(`${window.feedSettings?.apiUrl}terms/${taxonomy}?${params.toString()}`, {
- headers: {
- 'X-WP-Nonce': window.feedSettings?.nonce || ''
- }
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch terms: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Cache the result
- cache.set(cacheKey, data, 3600000); // Cache for 1 hour
-
- // Update term cache
- if (!this.termCache.terms.has(taxonomy)) {
- this.termCache.terms.set(taxonomy, new Map());
- }
-
- Object.entries(data.terms).forEach(([id, term]) => {
- this.termCache.terms.get(taxonomy).set(parseInt(id), term);
- });
-
- return data;
-
- } catch (error) {
- console.error(`Error fetching terms for ${taxonomy}:`, error);
- throw error;
- }
- }
-
- /**
- * Get term details from cache
- * @param {string} taxonomy - Taxonomy name
- * @param {number} id - Term ID
- * @returns {Object|null} - Term details or null if not found
- */
- getTermDetails(taxonomy, id) {
- if (!this.termCache.terms.has(taxonomy)) {
- return null;
- }
-
- return this.termCache.terms.get(taxonomy).get(parseInt(id)) || null;
- }
-
- /**
- * Get a formatted term path
- * @param {number} termId - Term ID
- * @param {string} taxonomy - Taxonomy name
- * @returns {string} - Formatted path
- */
- async getTermPath(termId, taxonomy) {
- // Try to get from term cache first
- const termDetails = this.getTermDetails(taxonomy, termId);
- if (termDetails && termDetails.path) {
- return termDetails.path;
- }
-
- // If not in cache, fetch from server
- try {
- const response = await fetch(`${window.feedSettings?.apiUrl}terms/${taxonomy}/${termId}`, {
- headers: {
- 'X-WP-Nonce': window.feedSettings?.nonce || ''
- }
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch term: ${response.status}`);
- }
-
- const data = await response.json();
-
- // Update term cache
- if (!this.termCache.terms.has(taxonomy)) {
- this.termCache.terms.set(taxonomy, new Map());
- }
-
- this.termCache.terms.get(taxonomy).set(termId, data);
-
- return data.path || data.name;
-
- } catch (error) {
- console.error(`Error fetching term path for ${termId} in ${taxonomy}:`, error);
- return '';
- }
- }
-
- /**
- * Pre-load common terms for a taxonomy
- * @param {string} taxonomy - Taxonomy name
- * @param {Object} terms - Terms data
- */
- preloadTerms(taxonomy, terms) {
- if (!this.termCache.terms.has(taxonomy)) {
- this.termCache.terms.set(taxonomy, new Map());
- }
-
- Object.entries(terms).forEach(([id, term]) => {
- this.termCache.terms.get(taxonomy).set(
- parseInt(id),
- typeof term === 'string' ? { id, name: term } : term
- );
- });
- }
-
- /**
- * Get highlighted item from URL
- * @returns {Object|null} - Highlight object or null if none
- */
- getHighlightFromURL() {
- const params = new URLSearchParams(window.location.search);
- const contentTypes = ['tattoo', 'piercing', 'artwork'];
-
- for (const type of contentTypes) {
- if (params.has(type)) {
- return { [type]: params.get(type) };
- }
- }
-
- return null;
- }
-}
-
-export default FilterService;
diff --git a/src/feed/feed/services/StateManager.js b/src/feed/feed/services/StateManager.js
deleted file mode 100644
index 63c2264..0000000
--- a/src/feed/feed/services/StateManager.js
+++ /dev/null
@@ -1,117 +0,0 @@
-class StateManager {
- constructor(initialState = {}) {
- this.state = {
- page: 1,
- loading: false,
- hasMore: true,
- retries: {
- count: 0,
- maxRetries: 3,
- delay: 1000
- },
- filters: {
- content: initialState.content || 'tattoo',
- style: [],
- theme: [],
- type: [],
- city: [],
- artstyle: [],
- arttheme: [],
- artmedium: [],
- pstyle: [],
- favouritesOnly: false,
- order: initialState.defaultOrder || 'date',
- direction: 'desc'
- },
- ...initialState
- };
-
- this.listeners = [];
- }
-
- setState(newState) {
- // Update state with new values
- if (typeof newState === 'function') {
- this.state = { ...this.state, ...newState(this.state) };
- } else {
- this.state = { ...this.state, ...newState };
- }
-
- // Notify listeners of state change
- this.notifyListeners();
-
- return this.state;
- }
-
- getState() {
- return this.state;
- }
-
- subscribe(listener) {
- this.listeners.push(listener);
- return () => {
- this.listeners = this.listeners.filter(l => l !== listener);
- };
- }
-
- notifyListeners() {
- this.listeners.forEach(listener => listener(this.state));
- }
-
- // Common state updates
- setLoading(loading) {
- return this.setState({ loading });
- }
-
- setPage(page) {
- return this.setState({ page });
- }
-
- nextPage() {
- return this.setState(state => ({
- page: state.page + 1
- }));
- }
-
- resetPagination() {
- return this.setState({
- page: 1,
- hasMore: true
- });
- }
-
- updateFilters(filters) {
- return this.setState(state => ({
- filters: filters,
- page: 1,
- hasMore: true
- }));
- }
-
- updateContentType(contentType) {
- return this.setState(state => ({
- filters: {
- ...state.filters,
- content: contentType
- },
- page: 1,
- hasMore: true
- }));
- }
-
- // Reset state to initial values
- reset() {
- return this.setState({
- page: 1,
- loading: false,
- hasMore: true,
- retries: {
- count: 0,
- maxRetries: 3,
- delay: 1000
- }
- });
- }
-}
-
-export default StateManager;
diff --git a/src/feed/feed/services/index.php b/src/feed/feed/services/index.php
deleted file mode 100644
index e69de29..0000000
--- a/src/feed/feed/services/index.php
+++ /dev/null
diff --git a/src/feed/feed/utils/cache.js b/src/feed/feed/utils/cache.js
deleted file mode 100644
index a8cfab6..0000000
--- a/src/feed/feed/utils/cache.js
+++ /dev/null
@@ -1,212 +0,0 @@
-// cache.js - Client-side caching utilities
-
-/**
- * Simple cache implementation with time-based expiration
- */
-class Cache {
- constructor(options = {}) {
- this.storage = {};
- this.options = {
- defaultTTL: 3600000, // 1 hour in milliseconds
- namespace: 'jvb_cache_',
- useLocalStorage: true,
- ...options
- };
-
- // Load persisted data from localStorage if enabled
- if (this.options.useLocalStorage) {
- this.loadFromStorage();
- }
- }
-
- /**
- * Set a cache item
- * @param {string} key - Cache key
- * @param {*} value - Value to store
- * @param {number} ttl - Time to live in milliseconds (optional)
- */
- set(key, value, ttl = this.options.defaultTTL) {
- const cacheKey = this.formatKey(key);
- const expiry = Date.now() + ttl;
-
- this.storage[cacheKey] = {
- value,
- expiry
- };
-
- // Persist to localStorage if enabled
- if (this.options.useLocalStorage) {
- this.saveToStorage();
- }
-
- return value;
- }
-
- /**
- * Get a cache item
- * @param {string} key - Cache key
- * @param {*} defaultValue - Default value if item not found or expired
- * @returns {*} Cached value or default
- */
- get(key, defaultValue = null) {
- const cacheKey = this.formatKey(key);
- const item = this.storage[cacheKey];
-
- // Return default if item doesn't exist
- if (!item) {
- return defaultValue;
- }
-
- // Check if item has expired
- if (item.expiry < Date.now()) {
- this.remove(key);
- return defaultValue;
- }
-
- return item.value;
- }
-
- /**
- * Get or set a cache item
- * @param {string} key - Cache key
- * @param {Function} callback - Function to generate value if not cached
- * @param {number} ttl - Time to live in milliseconds (optional)
- * @returns {*} Cached or generated value
- */
- getOrSet(key, callback, ttl = this.options.defaultTTL) {
- const value = this.get(key);
-
- if (value !== null) {
- return value;
- }
-
- // Generate new value
- const newValue = callback();
-
- // Store in cache
- return this.set(key, newValue, ttl);
- }
-
- /**
- * Remove a cache item
- * @param {string} key - Cache key
- */
- remove(key) {
- const cacheKey = this.formatKey(key);
- delete this.storage[cacheKey];
-
- // Update localStorage if enabled
- if (this.options.useLocalStorage) {
- this.saveToStorage();
- }
- }
-
- /**
- * Clear all cache items
- */
- clear() {
- this.storage = {};
-
- // Clear from localStorage if enabled
- if (this.options.useLocalStorage) {
- this.clearFromStorage();
- }
- }
-
- /**
- * Check if a cache item exists and is not expired
- * @param {string} key - Cache key
- * @returns {boolean} True if item exists and is valid
- */
- has(key) {
- const cacheKey = this.formatKey(key);
- const item = this.storage[cacheKey];
-
- if (!item) {
- return false;
- }
-
- return item.expiry >= Date.now();
- }
-
- /**
- * Format a key with namespace
- * @param {string} key - Original key
- * @returns {string} Namespaced key
- */
- formatKey(key) {
- return `${this.options.namespace}${key}`;
- }
-
- /**
- * Save cache to localStorage
- */
- saveToStorage() {
- try {
- localStorage.setItem('jvb_cache', JSON.stringify({
- timestamp: Date.now(),
- data: this.storage
- }));
- } catch (e) {
- console.warn('Failed to save cache to localStorage:', e);
- }
- }
-
- /**
- * Load cache from localStorage
- */
- loadFromStorage() {
- try {
- const stored = localStorage.getItem('jvb_cache');
-
- if (stored) {
- const parsed = JSON.parse(stored);
-
- // Only use if not too old (24 hours max)
- const maxAge = 24 * 60 * 60 * 1000; // 24 hours
- if (parsed.timestamp && (Date.now() - parsed.timestamp) < maxAge) {
- this.storage = parsed.data || {};
-
- // Clean expired items
- this.cleanExpired();
- }
- }
- } catch (e) {
- console.warn('Failed to load cache from localStorage:', e);
- this.storage = {};
- }
- }
-
- /**
- * Clear cache from localStorage
- */
- clearFromStorage() {
- try {
- localStorage.removeItem('jvb_cache');
- } catch (e) {
- console.warn('Failed to clear cache from localStorage:', e);
- }
- }
-
- /**
- * Remove expired items
- */
- cleanExpired() {
- const now = Date.now();
-
- Object.keys(this.storage).forEach(key => {
- const item = this.storage[key];
- if (item.expiry < now) {
- delete this.storage[key];
- }
- });
- }
-}
-
-// Create and export a singleton instance
-const cache = new Cache();
-
-export default cache;
-
-// Also export the class for custom instances
-export { Cache };
diff --git a/src/feed/feed/utils/formatters.js b/src/feed/feed/utils/formatters.js
deleted file mode 100644
index 6fb8f54..0000000
--- a/src/feed/feed/utils/formatters.js
+++ /dev/null
@@ -1,161 +0,0 @@
-// formatters.js - Utility functions for formatting data
-
-/**
- * Format a date in relative time (e.g., "2 days ago")
- * @param {string} dateStr - ISO date string
- * @returns {string} - Formatted relative time
- */
-export function formatTimeAgo(dateStr) {
- const date = new Date(dateStr);
- const now = new Date();
- const seconds = Math.floor((now - date) / 1000);
- const minutes = Math.floor(seconds / 60);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
- const weeks = Math.floor(days / 7);
- const months = Math.floor(days / 30);
-
- // Within the last 24 hours - show hours ago
- if (hours < 24) {
- if (hours === 0) {
- return minutes === 0 ? 'Just now' : `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
- }
- return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
- }
-
- // Within the last 7 days - show days ago
- if (days < 7) {
- return `${days} ${days === 1 ? 'day' : 'days'} ago`;
- }
-
- // Within the last 4 weeks - show week-based format
- if (weeks < 4) {
- if (weeks === 1) return 'Last week';
- return 'A few weeks ago';
- }
-
- // Within the last 2 months - show "Last month"
- if (months < 2) {
- return 'Last month';
- }
-
- // For anything older, show the full date
- return date.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
-}
-
-/**
- * Format a number with comma separator (e.g., 1,234)
- * @param {number} num - Number to format
- * @returns {string} - Formatted number
- */
-export function formatNumber(num) {
- return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
-}
-
-/**
- * Format a price with currency symbol
- * @param {number} price - Price to format
- * @param {string} currency - Currency code (default: 'CAD')
- * @returns {string} - Formatted price
- */
-export function formatPrice(price, currency = 'CAD') {
- return new Intl.NumberFormat('en-CA', {
- style: 'currency',
- currency: currency
- }).format(price);
-}
-
-/**
- * Escape HTML special characters to prevent XSS
- * @param {string} text - Text to escape
- * @returns {string} - Escaped text
- */
-export function escapeHtml(text) {
- if (!text) return '';
-
- return text
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
-
-/**
- * Truncate text to a specific length with ellipsis
- * @param {string} text - Text to truncate
- * @param {number} length - Maximum length
- * @returns {string} - Truncated text
- */
-export function truncateText(text, length = 100) {
- if (!text || text.length <= length) return text;
- return text.substring(0, length) + '...';
-}
-
-/**
- * Format a date range (e.g., "Jan 1 - Jan 5, 2023")
- * @param {string} startDate - Start date ISO string
- * @param {string} endDate - End date ISO string
- * @returns {string} - Formatted date range
- */
-export function formatDateRange(startDate, endDate) {
- const start = new Date(startDate);
- const end = new Date(endDate);
-
- // If same day, just show one date
- if (start.toDateString() === end.toDateString()) {
- return start.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric'
- });
- }
-
- // If same month and year, show range with month once
- if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
- return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${end.getFullYear()}`;
- }
-
- // If same year, show full range with year once
- if (start.getFullYear() === end.getFullYear()) {
- return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`;
- }
-
- // Different years, show full dates
- return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
-}
-
-/**
- * Debounce function to limit frequent calls
- * @param {Function} func - Function to debounce
- * @param {number} wait - Wait time in milliseconds
- * @returns {Function} - Debounced function
- */
-export function debounce(func, wait = 300) {
- let timeout;
- return function(...args) {
- clearTimeout(timeout);
- timeout = setTimeout(() => func.apply(this, args), wait);
- };
-}
-
-/**
- * Throttle function to limit call frequency
- * @param {Function} func - Function to throttle
- * @param {number} limit - Time limit in milliseconds
- * @returns {Function} - Throttled function
- */
-export function throttle(func, limit = 300) {
- let inThrottle;
- return function(...args) {
- if (!inThrottle) {
- func.apply(this, args);
- inThrottle = true;
- setTimeout(() => inThrottle = false, limit);
- }
- };
-}
diff --git a/src/feed/feed/utils/index.php b/src/feed/feed/utils/index.php
deleted file mode 100644
index e69de29..0000000
--- a/src/feed/feed/utils/index.php
+++ /dev/null
diff --git a/src/feed/style.scss b/src/feed/style.scss
index 937092f..583aa82 100644
--- a/src/feed/style.scss
+++ b/src/feed/style.scss
@@ -931,3 +931,32 @@
transform-origin: top center;
will-change: transform;
}
+
+.feed-block + footer {
+ grid-column: full;
+ padding: 0!important;
+ margin: 0;
+ background-color: var(--base-50);
+ z-index: 0;
+ display: flex;
+ justify-content: flex-end;
+ button {
+ width: max-content;
+ margin-left: auto;
+ padding: .35rem .25rem;
+ --w: 1.3em!important;
+ flex-wrap: nowrap;
+ justify-content:flex-start;
+ transition: var(--trans-size);
+ font-size: var(--txt-x-small);
+ min-height: 0;
+ span {
+ display: none;
+ white-space: nowrap;
+ }
+ &:focus span,
+ &:hover span {
+ display: block;
+ }
+ }
+}
diff --git a/src/feed/view.js b/src/feed/view.js
index 3590a17..103423b 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -66,10 +66,12 @@
buttons: {
loadMore: 'button.load-more',
remove: '.remove-term',
- clearFilters: 'button.clear-filters'
+ clearFilters: 'button.clear-filters',
+ refresh: 'button[data-action="refresh"]'
}
};
this.ui = window.uiFromSelectors(this.selectors, this.container);
+ this.ui.buttons.refresh = document.querySelector(this.selectors.buttons.refresh);
//Add content and taxonomies
this.ui.content = this.ui.filters.container.querySelectorAll('[name="content"]');
@@ -171,6 +173,12 @@
if (remove) {
this.removeSelectedTerm(remove);
}
+
+ let refresh = window.targetCheck(e, this.selectors.buttons.refresh);
+ if (refresh) {
+ this.store.clearCache();
+ this.store.fetch();
+ }
}
nextPage() {
@@ -608,7 +616,7 @@
];
if (afterEl) {
- afterEl.textContent = `After ${item.fields.number} Tx`;
+ afterEl.textContent = `After ${item.fields.number - 1} Tx`;
}
if (number) {
number.textContent = item.fields.number;
diff --git a/src/forms/view.js b/src/forms/view.js
index 5b58641..788e929 100644
--- a/src/forms/view.js
+++ b/src/forms/view.js
@@ -13,9 +13,9 @@
document.querySelectorAll('.jvb-form-block form').forEach(form => {
this.controller.registerForm(form, {
- autosave: true,
+ cache: true,
autoUpload: false,
- showSummary: true,
+ imageMeta: false,
});
});
@@ -34,8 +34,6 @@
// Add regular form fields
for (const [key, value] of Object.entries(data)) {
- if (key === '_wpnonce' || key === '_wp_http_referer') continue;
-
if (Array.isArray(value)) {
value.forEach(v => submitData.append(`${key}[]`, v));
} else if (typeof value === 'object' && value !== null) {
@@ -44,11 +42,15 @@
submitData.append(key, value);
}
}
+ config.element.querySelectorAll('[name="form_id"],[name="form_type"],[name="timestamp"],[name="cf-turnstile-response"]').forEach(input => {
+ submitData.append(input.name, input.value);
+ });
// Add uploaded files
if (window.jvbUploads) {
try {
const files = await window.jvbUploads.getFilesForForm(form);
+
files.forEach(({ file, fieldName }) => {
submitData.append(`${fieldName}[]`, file);
});
@@ -75,7 +77,9 @@
}
this.controller.showFormStatus(config.id, 'submitted');
- this.controller.showSummary(config.id, '.jvb-form-block');
+ // this.controller.handleFormSuccess(form, result);
+ this.controller.showSummary({ changes: data, config: config });
+ window.jvbA11y.announce('Form successfully submitted!');
// Clean up uploaded files
if (window.jvbUploads) {
diff --git a/src/index.php b/src/index.php
new file mode 100644
index 0000000..4f2541d
--- /dev/null
+++ b/src/index.php
@@ -0,0 +1,3 @@
+<?php
+//Nothing to see here
+
diff --git a/src/timeline/style.scss b/src/timeline/style.scss
index 8545b52..c94ed2b 100644
--- a/src/timeline/style.scss
+++ b/src/timeline/style.scss
@@ -106,7 +106,7 @@
h2 {
.icon {
--w: 4rem;
- left: -4.15rem;
+ left: -6.15rem;
top: 0;
}
}
--
Gitblit v1.10.0