mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-31 15:27:35 -04:00
- Updated the sub-navigation for the wizard to conditionally display step creation options based on the default state and available bundles. - Improved the bundles page layout by categorizing steps into pre-invite, post-invite, and additional categories, enhancing user experience. - Added orphaned step handling to allow users to remove steps that no longer have an associated entry. - Displayed the associated wizard bundle name in the invite card if applicable. - Adjusted JavaScript routing for bundle steps in the wizard. - Modified the development script to set the NODE_ENV to development when installing npm dependencies, ensuring proper handling of devDependencies. - Updated the subproject reference in the plus directory to indicate a dirty state.
387 lines
15 KiB
JavaScript
387 lines
15 KiB
JavaScript
/* wizard.js */
|
|
|
|
// Helper function to show toast notifications
|
|
function showToast(message, type = 'info') {
|
|
// Check if there's a toast container, create if not
|
|
let toastContainer = document.getElementById('toast-container');
|
|
if (!toastContainer) {
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.id = 'toast-container';
|
|
toastContainer.className = 'fixed top-4 right-4 z-50 space-y-2';
|
|
document.body.appendChild(toastContainer);
|
|
}
|
|
|
|
// Create toast element
|
|
const toast = document.createElement('div');
|
|
const bgColor = type === 'error' ? 'bg-red-500' : type === 'success' ? 'bg-green-500' : 'bg-blue-500';
|
|
toast.className = `${bgColor} text-white px-6 py-3 rounded-lg shadow-lg transition-opacity duration-300`;
|
|
toast.textContent = message;
|
|
|
|
toastContainer.appendChild(toast);
|
|
|
|
// Auto-remove after 3 seconds
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
function attachSortableLists(root = document) {
|
|
root.querySelectorAll('.wizard-steps, .bundle-steps').forEach(container => {
|
|
if (container.dataset.sortableAttached) return;
|
|
|
|
// Check if this is a category-aware wizard steps list
|
|
const isCategoryAware = container.classList.contains('wizard-steps') && container.dataset.category;
|
|
const isBundleList = container.classList.contains('bundle-steps');
|
|
|
|
const sortableConfig = {
|
|
animation: 150,
|
|
handle: '.drag',
|
|
ghostClass: 'sortable-ghost',
|
|
chosenClass: 'sortable-chosen',
|
|
dragClass: 'sortable-drag',
|
|
// Prevent empty state placeholder from being draggable
|
|
filter: '.empty-state-item',
|
|
// Add visual feedback for valid/invalid drop zones
|
|
onStart(evt) {
|
|
// Remove empty state placeholder from all containers when drag starts
|
|
document.querySelectorAll('.wizard-steps .empty-state-item, .bundle-steps .empty-state-item').forEach(placeholder => {
|
|
placeholder.style.display = 'none';
|
|
});
|
|
},
|
|
onMove(evt) {
|
|
if (isCategoryAware) {
|
|
const fromServer = evt.from.dataset.server;
|
|
const toServer = evt.to.dataset.server;
|
|
const isValid = fromServer === toServer;
|
|
|
|
// Add visual feedback to drop zone
|
|
if (isValid) {
|
|
evt.to.classList.add('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
evt.to.classList.remove('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
} else {
|
|
evt.to.classList.add('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
evt.to.classList.remove('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
if (isBundleList) {
|
|
const fromBundle = evt.from.dataset.bundle;
|
|
const toBundle = evt.to.dataset.bundle;
|
|
const sameBundle = fromBundle === toBundle;
|
|
const isValid = sameBundle;
|
|
|
|
if (isValid) {
|
|
evt.to.classList.add('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
evt.to.classList.remove('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
} else {
|
|
evt.to.classList.add('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
evt.to.classList.remove('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
return true;
|
|
},
|
|
onEnd(evt) {
|
|
const { to, from, item } = evt;
|
|
// Remove visual feedback classes
|
|
to.classList.remove('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
to.classList.remove('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
from.classList.remove('bg-green-50', 'dark:bg-green-900/20', 'border-green-300', 'dark:border-green-700');
|
|
from.classList.remove('bg-red-50', 'dark:bg-red-900/20', 'border-red-300', 'dark:border-red-700');
|
|
|
|
// Manage empty state placeholders
|
|
// Remove empty state from target container if it now has real steps
|
|
const toPlaceholder = to.querySelector('.empty-state-item');
|
|
const toRealSteps = [...to.children].filter(child => !child.classList.contains('empty-state-item'));
|
|
if (toPlaceholder && toRealSteps.length > 0) {
|
|
toPlaceholder.remove();
|
|
to.classList.remove('min-h-[120px]', 'flex', 'items-center', 'justify-center');
|
|
}
|
|
|
|
// Show empty state in source container if it's now empty
|
|
const fromPlaceholder = from.querySelector('.empty-state-item');
|
|
const fromRealSteps = [...from.children].filter(child => !child.classList.contains('empty-state-item'));
|
|
if (fromRealSteps.length === 0 && !fromPlaceholder) {
|
|
// Create and add empty state placeholder
|
|
const emptyState = document.createElement('li');
|
|
emptyState.className = 'empty-state-item flex flex-col items-center justify-center text-center py-8 text-gray-400 dark:text-gray-500 w-full list-none';
|
|
const category = from.dataset.category || '';
|
|
let emptyMessage = 'No steps configured';
|
|
if (category === 'pre_invite') {
|
|
emptyMessage = 'No pre-invite steps configured';
|
|
} else if (category === 'post_invite') {
|
|
emptyMessage = 'No post-invite steps configured';
|
|
}
|
|
emptyState.innerHTML = `
|
|
<svg class="w-12 h-12 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
<p class="text-sm">${emptyMessage}</p>
|
|
`;
|
|
from.appendChild(emptyState);
|
|
from.classList.add('min-h-[120px]', 'flex', 'items-center', 'justify-center');
|
|
} else if (fromPlaceholder && fromRealSteps.length === 0) {
|
|
// Show existing placeholder if container is now empty
|
|
fromPlaceholder.style.display = '';
|
|
from.classList.add('min-h-[120px]', 'flex', 'items-center', 'justify-center');
|
|
}
|
|
|
|
if (isCategoryAware) {
|
|
// Category-aware reordering (for Settings → Wizard page)
|
|
const serverType = to.dataset.server;
|
|
const category = to.dataset.category;
|
|
// Filter out empty state placeholder when collecting step IDs
|
|
const ids = [...to.children]
|
|
.filter(li => !li.classList.contains('empty-state-item'))
|
|
.map(li => Number(li.dataset.id));
|
|
|
|
fetch(to.dataset.reorderUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
server_type: serverType,
|
|
category: category,
|
|
order: ids
|
|
})
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`Server returned ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'ok') {
|
|
} else {
|
|
throw new Error(data.message || 'Failed to reorder steps');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to reorder steps:', error);
|
|
showToast('Failed to reorder steps. Refreshing page...', 'error');
|
|
// Reload page to revert UI on error
|
|
setTimeout(() => location.reload(), 2000);
|
|
});
|
|
} else if (isBundleList) {
|
|
const bundleId = to.dataset.bundle || from.dataset.bundle;
|
|
const reorderUrl = to.dataset.reorderUrl || from.dataset.reorderUrl;
|
|
|
|
if (!bundleId || !reorderUrl) {
|
|
return;
|
|
}
|
|
|
|
const relatedLists = [...document.querySelectorAll(`.bundle-steps[data-bundle="${bundleId}"]`)]
|
|
.sort((a, b) => {
|
|
const aIndex = Number(a.dataset.bundleColumn || 0);
|
|
const bIndex = Number(b.dataset.bundleColumn || 0);
|
|
return aIndex - bIndex;
|
|
});
|
|
|
|
const orderedItems = [];
|
|
relatedLists.forEach(list => {
|
|
const targetCategory = list.dataset.category || '';
|
|
[...list.children]
|
|
.filter(li => !li.classList.contains('empty-state-item'))
|
|
.forEach(li => {
|
|
const id = Number(li.dataset.id);
|
|
if (!Number.isFinite(id)) {
|
|
return;
|
|
}
|
|
|
|
let category = targetCategory;
|
|
if (!category || category === 'other') {
|
|
category = li.dataset.category || null;
|
|
}
|
|
|
|
if (list === to && item === li && targetCategory && targetCategory !== 'other') {
|
|
category = targetCategory;
|
|
}
|
|
|
|
const normalisedCategory = category || null;
|
|
const originalCategory = li.dataset.originalCategory || null;
|
|
const categoryPayload =
|
|
!normalisedCategory || (originalCategory && normalisedCategory === originalCategory)
|
|
? null
|
|
: normalisedCategory;
|
|
|
|
li.dataset.category = normalisedCategory || '';
|
|
li.dataset.originalCategory = li.dataset.category;
|
|
|
|
orderedItems.push({
|
|
id,
|
|
category: categoryPayload
|
|
});
|
|
});
|
|
});
|
|
|
|
fetch(reorderUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ order: orderedItems })
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to reorder bundle steps:', error);
|
|
showToast('Failed to reorder steps', 'error');
|
|
});
|
|
} else {
|
|
// Legacy reordering for single bundle list instances
|
|
const ids = [...to.children]
|
|
.filter(li => !li.classList.contains('empty-state-item'))
|
|
.map(li => Number(li.dataset.id));
|
|
fetch(to.dataset.reorderUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(ids)
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to reorder bundle steps:', error);
|
|
showToast('Failed to reorder steps', 'error');
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// For category-aware lists, enable dragging between categories of the same server
|
|
if (isCategoryAware) {
|
|
sortableConfig.group = {
|
|
name: 'wizard-steps-' + container.dataset.server,
|
|
pull: true,
|
|
put: function(to, from, dragEl) {
|
|
// Only allow drops within the same server type
|
|
return to.el.dataset.server === from.el.dataset.server;
|
|
}
|
|
};
|
|
}
|
|
|
|
if (isBundleList) {
|
|
sortableConfig.group = {
|
|
name: 'bundle-steps-' + (container.dataset.bundle || ''),
|
|
pull: true,
|
|
put: function(to, from) {
|
|
return to.el.dataset.bundle === from.el.dataset.bundle;
|
|
}
|
|
};
|
|
}
|
|
|
|
new Sortable(container, sortableConfig);
|
|
container.dataset.sortableAttached = '1';
|
|
});
|
|
}
|
|
|
|
// Attach Next-button gating that requires an interaction inside step content
|
|
function attachInteractionGating(root = document) {
|
|
// Handle both mobile and desktop next buttons (updated after button refactoring)
|
|
const mobileNext = root.querySelector('#wizard-next-btn');
|
|
const desktopNext = root.querySelector('#wizard-next-btn-desktop');
|
|
|
|
// Get all next buttons that exist
|
|
const nextButtons = [mobileNext, desktopNext].filter(btn => btn !== null);
|
|
|
|
if (nextButtons.length === 0) return;
|
|
|
|
// Check if any button requires interaction (they should all have same state)
|
|
const requiresInteraction = nextButtons.some(btn => btn.dataset.disabled === '1');
|
|
if (!requiresInteraction) return;
|
|
|
|
// Process each button
|
|
nextButtons.forEach(next => {
|
|
if (next.dataset.interactionGatingAttached === '1') return;
|
|
next.dataset.interactionGatingAttached = '1';
|
|
|
|
const content = root.querySelector('#wizard-wrapper .prose') || root.querySelector('#wizard-content .prose');
|
|
|
|
// 1) Hard-disable HTMX by removing hx-get temporarily
|
|
const savedHxGet = next.getAttribute('hx-get');
|
|
if (savedHxGet != null) {
|
|
next.dataset.savedHxGet = savedHxGet;
|
|
next.removeAttribute('hx-get');
|
|
}
|
|
|
|
// 2) Block clicks & keyboard activation at capture phase (before htmx)
|
|
function clickBlocker(e) {
|
|
if (next.dataset.disabled === '1') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
function keyBlocker(e) {
|
|
if (next.dataset.disabled === '1' && (e.key === 'Enter' || e.key === ' ')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
next.addEventListener('click', clickBlocker, true); // capture
|
|
next.addEventListener('keydown', keyBlocker, true); // capture
|
|
|
|
// 3) Apply disabled affordance & interaction lock
|
|
next.setAttribute('tabindex', '-1');
|
|
next.style.opacity = '0.6';
|
|
next.style.cursor = 'not-allowed';
|
|
|
|
// Store button-specific handlers for cleanup
|
|
next._clickBlocker = clickBlocker;
|
|
next._keyBlocker = keyBlocker;
|
|
});
|
|
|
|
// Shared enable function that enables ALL next buttons at once
|
|
function enableAllButtons() {
|
|
nextButtons.forEach(next => {
|
|
// Restore HTMX capability first
|
|
if (next.dataset.savedHxGet != null) {
|
|
next.setAttribute('hx-get', next.dataset.savedHxGet);
|
|
delete next.dataset.savedHxGet;
|
|
}
|
|
|
|
next.dataset.disabled = '0';
|
|
next.removeAttribute('aria-disabled');
|
|
next.removeAttribute('tabindex');
|
|
next.style.pointerEvents = '';
|
|
next.style.opacity = '';
|
|
next.style.cursor = '';
|
|
|
|
// Remove blockers using stored references
|
|
if (next._clickBlocker) {
|
|
next.removeEventListener('click', next._clickBlocker, true);
|
|
delete next._clickBlocker;
|
|
}
|
|
if (next._keyBlocker) {
|
|
next.removeEventListener('keydown', next._keyBlocker, true);
|
|
delete next._keyBlocker;
|
|
}
|
|
});
|
|
|
|
// Remove content listener
|
|
const content = root.querySelector('#wizard-wrapper .prose') || root.querySelector('#wizard-content .prose');
|
|
if (content && content._interactionHandler) {
|
|
content.removeEventListener('click', content._interactionHandler, true);
|
|
delete content._interactionHandler;
|
|
}
|
|
}
|
|
|
|
// Single interaction handler for the content area
|
|
function handler(ev) {
|
|
const t = ev.target;
|
|
if (!t) return;
|
|
if (t.closest && t.closest('a,button') !== null) enableAllButtons();
|
|
}
|
|
|
|
// Listen for any click within the content that bubbles/captures from links/buttons
|
|
const content = root.querySelector('#wizard-wrapper .prose') || root.querySelector('#wizard-content .prose');
|
|
if (content) {
|
|
content.addEventListener('click', handler, true);
|
|
content._interactionHandler = handler; // Store for cleanup
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
attachSortableLists();
|
|
attachInteractionGating();
|
|
});
|
|
document.body.addEventListener('htmx:load', e => {
|
|
attachSortableLists(e.target);
|
|
attachInteractionGating(e.target);
|
|
});
|