Files
wizarr/app/templates/wizard/steps.html
Mully 53c6c3639c fix: wizard steps localization
- Changed "Steps of" updates to preserve localization (by targeting exact child elements)

- Fixes issue of Wizard steps staying at 1 when user's locale is not English
2025-10-07 10:56:07 -04:00

371 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# templates/wizard/steps.html #}
<div id="wizard-wrapper"
x-data="wizardSwipe()"
@touchstart="onTouchStart($event)"
@touchmove="onTouchMove($event)"
@touchend="onTouchEnd($event)"
class="wizard-container md:justify-center md:items-center md:px-4 md:py-8"
data-current-idx="{{ idx }}"
data-max-idx="{{ max_idx }}"
data-server-type="{{ server_type }}">
<!-- FLOATING PROGRESS BAR (mobile - floating at top) -->
{% if max_idx > 0 %}
<div id="wizard-progress-mobile" class="wizard-progress-mobile md:hidden">
<div class="wizard-glass-container">
<div class="flex justify-between items-center mb-2 text-xs text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span class="font-semibold">{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200/50 dark:bg-gray-700/50 rounded-full h-1.5 overflow-hidden">
<div id="progress-bar-mobile"
class="h-1.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, #fe4155 0%, #fe4155 100%);
box-shadow: 0 0 8px rgba(254, 65, 85, 0.3)"></div>
</div>
</div>
</div>
<!-- DESKTOP PROGRESS BAR (centered) -->
<div id="wizard-progress-desktop"
class="hidden md:block w-full max-w-md mb-6">
<div class="flex justify-between items-center mb-2 text-sm text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span>{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 overflow-hidden">
<div id="progress-bar-desktop"
class="h-2.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, #fe4155 0%, #fe4155 100%);
box-shadow: 0 0 10px rgba(254, 65, 85, 0.3)"></div>
</div>
</div>
{% endif %}
<!-- CARD (full height on mobile, centered on desktop) -->
<div class="htmx-indicator absolute inset-0 bg-white/80 dark:bg-gray-900/80 flex items-center justify-center z-50">
<div class="flex flex-col items-center gap-3">
<svg class="animate-spin h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading...</span>
</div>
</div>
{% include "wizard/_content.html" %}
<!-- FLOATING NAV BUTTONS (mobile - fixed at bottom) -->
<div class="wizard-nav-mobile md:hidden">
<a id="wizard-prev-btn"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="wizard-btn-mobile wizard-btn-prev"
{% if idx == 0 %}style="display:none"{% endif %}
aria-label="{{ _('Previous') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</a>
<a id="wizard-next-btn"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="wizard-btn-mobile wizard-btn-next"
{% if idx == max_idx %}style="display:none"{% endif %}
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}
aria-label="{{ _('Next') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<!-- DESKTOP NAV BUTTONS (centered below card) -->
<div class="hidden md:flex justify-center space-x-4 mt-6">
<a id="wizard-prev-btn-desktop"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="btn-nav"
{% if idx == 0 %}style="display:none"{% endif %}> {{ _("Previous") }}</a>
<a id="wizard-next-btn-desktop"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="btn-nav"
{% if idx == max_idx %}style="display:none"{% endif %}
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}>{{ _('Next') }} </a>
</div>
</div>
<script>
/**
* WIZARR WIZARD ARCHITECTURE
* ==========================
*
* A modular, app-like wizard system with fixed UI chrome and smooth content transitions.
*
* STRUCTURE:
* ----------
* 1. Templates:
* - wizard/frame.html → Full page wrapper (initial load only)
* - wizard/steps.html → Wrapper with UI chrome (progress, buttons)
* - wizard/_content.html → Content-only partial (HTMX swaps)
*
* 2. Request Flow:
* - Initial load: Returns frame.html → includes steps.html → includes _content.html
* - HTMX request: Returns _content.html ONLY (no UI chrome duplication)
*
* 3. State Management:
* - Server sends headers: X-Wizard-Idx, X-Require-Interaction
* - Client updates: Progress bars, button URLs, button visibility
* - Wrapper stores: data-current-idx, data-max-idx, data-server-type
*
* 4. Mobile Features:
* - Fixed progress bar at top (no scroll)
* - Fixed navigation buttons at bottom (circular, gradient)
* - Swipe gestures (left=next, right=prev)
* - Content-only scrolling with smooth animations
*
* IMPORTANT:
* ----------
* - NEVER swap the wrapper (causes UI duplication)
* - ONLY swap #wizard-content
* - Progress/buttons update via JavaScript, not DOM replacement
*/
// Modern wizard controller
const WizardController = {
wrapper: null,
isInitialized: false,
init() {
// Prevent double initialization
if (this.isInitialized) return;
this.wrapper = document.getElementById('wizard-wrapper');
if (!this.wrapper) {
console.error('Wizard wrapper not found');
return;
}
// Listen for content swaps
document.body.addEventListener('htmx:afterSwap', (evt) => {
if (evt.detail.target.id === 'wizard-content') {
this.updateUI(evt.detail.xhr);
}
});
this.isInitialized = true;
console.log('WizardController initialized');
},
getCurrentIdx() {
return parseInt(this.wrapper.dataset.currentIdx);
},
getMaxIdx() {
return parseInt(this.wrapper.dataset.maxIdx);
},
getServerType() {
return this.wrapper.dataset.serverType;
},
updateUI(xhr) {
if (!xhr) {
console.error('No XHR object provided');
return;
}
const newIdx = parseInt(xhr.getResponseHeader('X-Wizard-Idx'));
const requireInteraction = xhr.getResponseHeader('X-Require-Interaction') === 'true';
const maxIdx = this.getMaxIdx();
const serverType = this.getServerType();
// Validation
if (isNaN(newIdx) || isNaN(maxIdx)) {
console.error('Invalid index values:', {
newIdx,
maxIdx
});
return;
}
if (newIdx < 0 || newIdx > maxIdx) {
console.error('Index out of bounds:', {
newIdx,
maxIdx
});
return;
}
console.log('✅ Wizard update:', {
newIdx,
maxIdx,
serverType,
requireInteraction
});
// Update wrapper state
this.wrapper.dataset.currentIdx = newIdx;
// Update progress
this.updateProgress(newIdx, maxIdx);
// Update navigation buttons
this.updateButtons(newIdx, maxIdx, serverType, requireInteraction);
},
updateProgress(idx, maxIdx) {
const percentage = ((idx + 1) / (maxIdx + 1)) * 100;
// Update progress bars
['progress-bar-mobile', 'progress-bar-desktop'].forEach(id => {
const bar = document.getElementById(id);
if (bar) bar.style.width = `${percentage}%`;
});
// Update text using direct child access
['wizard-progress-mobile', 'wizard-progress-desktop'].forEach(id => {
const container = document.getElementById(id);
if (container) {
const flexDiv = container.querySelector('.flex');
if (flexDiv) {
const stepSpan = flexDiv.children[0];
const percentageSpan = flexDiv.children[1];
if (stepSpan) {
// Preserve the localized text pattern, just update the numbers
const currentText = stepSpan.textContent || '';
// Replace numbers: first occurrence is current step, second is total steps
let numberCount = 0;
const updatedText = currentText.replace(/\d+/g, () => {
numberCount++;
return numberCount === 1 ? `${idx + 1}` : `${maxIdx + 1}`;
});
stepSpan.textContent = updatedText;
}
if (percentageSpan) {
percentageSpan.textContent = `${Math.round(percentage)}%`;
}
}
}
});
},
updateButtons(idx, maxIdx, serverType, requireInteraction) {
const buttonConfigs = [{
id: 'wizard-prev-btn',
isPrev: true
}, {
id: 'wizard-prev-btn-desktop',
isPrev: true
}, {
id: 'wizard-next-btn',
isPrev: false
}, {
id: 'wizard-next-btn-desktop',
isPrev: false
}];
buttonConfigs.forEach(({
id,
isPrev
}) => {
const btn = document.getElementById(id);
if (!btn) return;
const targetIdx = isPrev ? idx - 1 : idx + 1;
const newUrl = `/wizard/${serverType}/${targetIdx}`;
// Update URL
btn.setAttribute('hx-get', newUrl);
// Update visibility and state
if (isPrev) {
btn.style.display = idx === 0 ? 'none' : '';
} else {
btn.style.display = idx === maxIdx ? 'none' : '';
// Handle interaction requirement
if (requireInteraction) {
btn.setAttribute('aria-disabled', 'true');
btn.dataset.disabled = '1';
} else {
btn.removeAttribute('aria-disabled');
delete btn.dataset.disabled;
}
}
console.log(`Button ${id}: URL=${newUrl}, visible=${btn.style.display !== 'none'}`);
});
// Reinitialize HTMX
htmx.process(this.wrapper);
}
};
// Swipe gesture handler
function wizardSwipe() {
return {
startX: 0,
currentX: 0,
threshold: 75,
onTouchStart(e) {
if (!e.touches || !e.touches.length) return;
const x = e.touches[0].clientX;
this.startX = x;
this.currentX = x; // tap without move should not trigger swipe
},
onTouchMove(e) {
if (!e.touches || !e.touches.length) return;
this.currentX = e.touches[0].clientX;
},
onTouchEnd(e) {
// Safari may skip touchmove on quick taps; use last changed touch as fallback
if (e.changedTouches && e.changedTouches.length) {
this.currentX = e.changedTouches[0].clientX;
}
const diff = this.startX - this.currentX;
if (Math.abs(diff) > this.threshold) {
const btnId = diff > 0 ? 'wizard-next-btn' : 'wizard-prev-btn';
const btn = document.getElementById(btnId);
if (btn && btn.style.display !== 'none' && !btn.hasAttribute('aria-disabled')) {
btn.click();
}
}
this.startX = 0;
this.currentX = 0;
}
};
}
// Initialize controller when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => WizardController.init());
} else {
WizardController.init();
}
</script>