${template.description}
-${template.description}
+- The interactive tour is unavailable, but you can explore the dashboard freely. - Check the documentation for help getting started. -
- `; - - document.body.appendChild(fallbackMessage); - - // Auto-remove after 10 seconds - setTimeout(() => { - if (fallbackMessage.parentNode) { - fallbackMessage.parentNode.removeChild(fallbackMessage); - } - }, 10000); - - return true; - } - - /** - * Handle storage unavailable scenario - * @returns {Object} In-memory storage fallback - */ - handleStorageUnavailable() { - this.logError('Storage Unavailable', 'Local storage is not available'); - - // Create in-memory storage - const memoryStorage = { - data: {}, - getItem(key) { - return this.data[key] || null; - }, - setItem(key, value) { - this.data[key] = value; - }, - removeItem(key) { - delete this.data[key]; - }, - clear() { - this.data = {}; - } - }; - - console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); - return memoryStorage; - } - - /** - * Send error to tracking service (placeholder) - * @private - * @param {Object} errorEntry - Error entry to send - */ - sendToErrorTracking(errorEntry) { - // Placeholder for error tracking integration - // Could integrate with Sentry, LogRocket, etc. - // Example: - // if (window.Sentry) { - // Sentry.captureException(new Error(errorEntry.message), { - // extra: errorEntry.metadata - // }); - // } - } - } - - window.ErrorHandler = ErrorHandler; - console.log('[ErrorHandler] Module loaded'); - -})(window); +/** + * Error Handler + * Handles errors gracefully without breaking the onboarding tour + */ + +(function(window) { + 'use strict'; + + class ErrorHandler { + constructor() { + this.errors = []; + this.maxErrors = 50; // Keep last 50 errors + } + + /** + * Log an error without breaking the tour + * @param {string} context - Context where error occurred + * @param {Error|string} error - The error object or message + * @param {Object} metadata - Additional metadata + */ + logError(context, error, metadata = {}) { + const errorEntry = { + timestamp: new Date().toISOString(), + context, + message: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : null, + metadata + }; + + // Add to errors array + this.errors.push(errorEntry); + + // Keep only last maxErrors + if (this.errors.length > this.maxErrors) { + this.errors.shift(); + } + + // Log to console + console.error(`[Onboarding Error] ${context}:`, error, metadata); + + // Optionally send to error tracking service + // this.sendToErrorTracking(errorEntry); + } + + /** + * Attempt to recover from an error and continue tour + * @param {Error} error - The error object + * @param {number} currentStep - Current step index + * @returns {Object} Recovery action + */ + recoverFromError(error, currentStep) { + const errorType = this.classifyError(error); + + switch (errorType) { + case 'ELEMENT_NOT_FOUND': + this.logError('Element Not Found', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Target element not found, skipping to next step' + }; + + case 'STORAGE_UNAVAILABLE': + this.logError('Storage Unavailable', error); + return { + action: 'USE_MEMORY_STORAGE', + message: 'Local storage unavailable, using in-memory storage' + }; + + case 'DRIVER_NOT_LOADED': + this.logError('Driver.js Not Loaded', error); + return { + action: 'ABORT_TOUR', + message: 'Driver.js library not loaded, cannot start tour' + }; + + case 'INVALID_TOOLTIP': + this.logError('Invalid Tooltip Configuration', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Invalid tooltip configuration, skipping' + }; + + case 'THEME_DETECTION_FAILED': + this.logError('Theme Detection Failed', error); + return { + action: 'USE_DEFAULT_THEME', + message: 'Using default dark theme' + }; + + default: + this.logError('Unknown Error', error, { currentStep }); + return { + action: 'ABORT_TOUR', + message: 'Unexpected error occurred, aborting tour' + }; + } + } + + /** + * Classify error type + * @private + * @param {Error} error - The error object + * @returns {string} Error type + */ + classifyError(error) { + const message = error.message || error.toString(); + + if (message.includes('element') && message.includes('not found')) { + return 'ELEMENT_NOT_FOUND'; + } + if (message.includes('storage') || message.includes('quota')) { + return 'STORAGE_UNAVAILABLE'; + } + if (message.includes('driver') || message.includes('undefined')) { + return 'DRIVER_NOT_LOADED'; + } + if (message.includes('invalid') || message.includes('validation')) { + return 'INVALID_TOOLTIP'; + } + if (message.includes('theme')) { + return 'THEME_DETECTION_FAILED'; + } + + return 'UNKNOWN'; + } + + /** + * Get all logged errors + * @returns {Array} Array of error entries + */ + getErrors() { + return [...this.errors]; + } + + /** + * Clear all logged errors + */ + clearErrors() { + this.errors = []; + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getStatistics() { + const stats = { + total: this.errors.length, + byContext: {}, + byType: {}, + recent: this.errors.slice(-10) + }; + + this.errors.forEach(error => { + // Count by context + stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1; + + // Count by type + const type = this.classifyError({ message: error.message }); + stats.byType[type] = (stats.byType[type] || 0) + 1; + }); + + return stats; + } + + /** + * Handle graceful degradation when Driver.js fails to load + * @returns {boolean} Whether fallback was successful + */ + handleDriverLoadFailure() { + this.logError('Driver.js Load Failure', 'Driver.js library failed to load'); + + // Show fallback message + const fallbackMessage = document.createElement('div'); + fallbackMessage.id = 'onboarding-fallback'; + fallbackMessage.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--card-base, #2a2a2a); + color: var(--fg, #ffffff); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9999; + max-width: 300px; + font-size: 14px; + `; + fallbackMessage.innerHTML = ` + Welcome to DashCaddy!+ The interactive tour is unavailable, but you can explore the dashboard freely. + Check the documentation for help getting started. +
+ `; + + document.body.appendChild(fallbackMessage); + + // Auto-remove after 10 seconds + setTimeout(() => { + if (fallbackMessage.parentNode) { + fallbackMessage.parentNode.removeChild(fallbackMessage); + } + }, 10000); + + return true; + } + + /** + * Handle storage unavailable scenario + * @returns {Object} In-memory storage fallback + */ + handleStorageUnavailable() { + this.logError('Storage Unavailable', 'Local storage is not available'); + + // Create in-memory storage + const memoryStorage = { + data: {}, + getItem(key) { + return this.data[key] || null; + }, + setItem(key, value) { + this.data[key] = value; + }, + removeItem(key) { + delete this.data[key]; + }, + clear() { + this.data = {}; + } + }; + + console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); + return memoryStorage; + } + + /** + * Send error to tracking service (placeholder) + * @private + * @param {Object} errorEntry - Error entry to send + */ + sendToErrorTracking(errorEntry) { + // Placeholder for error tracking integration + // Could integrate with Sentry, LogRocket, etc. + // Example: + // if (window.Sentry) { + // Sentry.captureException(new Error(errorEntry.message), { + // extra: errorEntry.metadata + // }); + // } + } + } + + window.ErrorHandler = ErrorHandler; + console.log('[ErrorHandler] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/fonts.css b/dashcaddy-api/assets/fonts.css index f5e3463..729c28c 100644 --- a/dashcaddy-api/assets/fonts.css +++ b/dashcaddy-api/assets/fonts.css @@ -1,91 +1,91 @@ -/* Sami Sans Font Family - External CSS */ - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Regular.woff2') format('woff2'), - url('fonts/SamiSans-Regular.ttf') format('truetype'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Regular.woff2') format('woff2'), - url('fonts/SamiSans-Italic.ttf') format('truetype'); - font-weight: 400; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Medium.woff2') format('woff2'), - url('fonts/SamiSans-Medium.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), - url('fonts/SamiSans-SemiBold.ttf') format('truetype'); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Bold.woff2') format('woff2'), - url('fonts/SamiSans-Bold.ttf') format('truetype'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), - url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); - font-weight: 800; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Black.woff2') format('woff2'), - url('fonts/SamiSans-Black.ttf') format('truetype'); - font-weight: 900; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Light.woff2') format('woff2'), - url('fonts/SamiSans-Light.ttf') format('truetype'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), - url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); - font-weight: 200; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Sami Sans'; - src: url('fonts/SamiSans-Thin.woff2') format('woff2'), - url('fonts/SamiSans-Thin.ttf') format('truetype'); - font-weight: 100; - font-style: normal; - font-display: swap; -} +/* Sami Sans Font Family - External CSS */ + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Medium.woff2') format('woff2'), + url('fonts/SamiSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), + url('fonts/SamiSans-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Bold.woff2') format('woff2'), + url('fonts/SamiSans-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), + url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Black.woff2') format('woff2'), + url('fonts/SamiSans-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Light.woff2') format('woff2'), + url('fonts/SamiSans-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), + url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); + font-weight: 200; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Thin.woff2') format('woff2'), + url('fonts/SamiSans-Thin.ttf') format('truetype'); + font-weight: 100; + font-style: normal; + font-display: swap; +} diff --git a/dashcaddy-api/assets/onboarding.css b/dashcaddy-api/assets/onboarding.css index f8dc06b..6c7b7c9 100644 --- a/dashcaddy-api/assets/onboarding.css +++ b/dashcaddy-api/assets/onboarding.css @@ -1,354 +1,354 @@ -/** - * Onboarding Tooltip Styles - * Custom styling for Driver.js tooltips to match DashCaddy theme - */ - -/* Driver.js overrides are injected dynamically by ThemeAdapter */ -/* This file contains additional custom styles */ - -.driver-popover { - max-width: 500px !important; - z-index: 10000 !important; -} - -.driver-popover-title { - font-size: 1.2rem !important; - margin-bottom: 12px !important; -} - -.driver-popover-description { - font-size: 0.95rem !important; - line-height: 1.6 !important; -} - -.driver-popover-description p { - margin: 8px 0 !important; -} - -.driver-popover-description ul { - margin: 8px 0 !important; - padding-left: 20px !important; -} - -.driver-popover-description li { - margin: 4px 0 !important; -} - -.driver-popover-description code { - background: rgba(0, 0, 0, 0.1) !important; - padding: 2px 6px !important; - border-radius: 3px !important; - font-family: 'Courier New', monospace !important; - font-size: 0.9em !important; -} - -.driver-popover-footer { - margin-top: 16px !important; - display: flex !important; - gap: 8px !important; - justify-content: flex-end !important; -} - -.driver-popover-footer button { - padding: 8px 16px !important; - border-radius: 8px !important; - font-size: 0.9rem !important; - cursor: pointer !important; - transition: all 0.2s ease !important; -} - -.driver-popover-footer button:hover { - transform: translateY(-1px) !important; -} - -.driver-popover-close-btn { - position: absolute !important; - top: 12px !important; - right: 12px !important; - width: 24px !important; - height: 24px !important; - border-radius: 50% !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - cursor: pointer !important; - opacity: 0.6 !important; - transition: opacity 0.2s ease !important; -} - -.driver-popover-close-btn:hover { - opacity: 1 !important; -} - -.driver-popover-arrow { - border-width: 8px !important; -} - -/* Progress indicator */ -.driver-popover-progress-text { - font-size: 0.85rem !important; - margin-bottom: 8px !important; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .driver-popover { - max-width: calc(100vw - 32px) !important; - } - - .driver-popover-title { - font-size: 1.1rem !important; - } - - .driver-popover-description { - font-size: 0.9rem !important; - } - - .driver-popover-footer button { - padding: 6px 12px !important; - font-size: 0.85rem !important; - } -} - -/* Restart tour button in dashboard */ -#restart-tour-btn { - display: inline-flex; - align-items: center; - gap: 6px; -} - -#restart-tour-btn::before { - content: "🎓"; - font-size: 1.1em; -} - - -/* DNS Template Selector Modal */ -.dns-template-modal { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - z-index: 10000; - align-items: center; - justify-content: center; - padding: 20px; -} - -.dns-template-modal-content { - background: var(--card-base); - border-radius: 12px; - max-width: 900px; - width: 100%; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); -} - -.dns-template-header { - padding: 30px; - border-bottom: 1px solid var(--border); - position: relative; -} - -.dns-template-header h2 { - margin: 0 0 10px 0; - color: var(--fg); - font-size: 28px; -} - -.dns-template-header p { - margin: 0; - color: var(--fg-muted); - font-size: 14px; -} - -.dns-template-close { - position: absolute; - top: 20px; - right: 20px; - background: none; - border: none; - font-size: 32px; - color: var(--fg-muted); - cursor: pointer; - padding: 0; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s; -} - -.dns-template-close:hover { - background: var(--hover); - color: var(--fg); -} - -.dns-template-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; - padding: 30px; -} - -.dns-template-card { - background: var(--card-hover); - border: 2px solid var(--border); - border-radius: 12px; - padding: 20px; - transition: all 0.3s; - position: relative; - display: flex; - flex-direction: column; -} - -.dns-template-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); - border-color: var(--accent); -} - -.dns-template-card.recommended { - border-color: var(--accent); - background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); -} - -.recommended-badge { - position: absolute; - top: -10px; - right: 20px; - background: var(--accent); - color: white; - padding: 4px 12px; - border-radius: 12px; - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.dns-template-icon { - font-size: 48px; - margin-bottom: 15px; - text-align: center; -} - -.dns-template-card h3 { - margin: 0 0 10px 0; - color: var(--fg); - font-size: 18px; - text-align: center; -} - -.dns-template-description { - color: var(--fg-muted); - font-size: 13px; - margin: 0 0 15px 0; - text-align: center; - flex-grow: 1; -} - -.dns-template-difficulty { - display: inline-block; - padding: 4px 12px; - border-radius: 12px; - font-size: 11px; - font-weight: bold; - text-align: center; - margin: 0 auto 15px auto; -} - -.difficulty-easy { - background: #2ecc71; - color: white; -} - -.difficulty-intermediate { - background: #f39c12; - color: white; -} - -.difficulty-advanced { - background: #e74c3c; - color: white; -} - -.dns-template-features { - list-style: none; - padding: 0; - margin: 0 0 20px 0; - font-size: 12px; - color: var(--fg-muted); -} - -.dns-template-features li { - padding: 6px 0; - padding-left: 20px; - position: relative; -} - -.dns-template-features li:before { - content: "✓"; - position: absolute; - left: 0; - color: var(--accent); - font-weight: bold; -} - -.dns-template-select-btn { - background: var(--accent); - color: white; - border: none; - padding: 12px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: bold; - cursor: pointer; - transition: all 0.2s; - width: 100%; -} - -.dns-template-select-btn:hover { - background: var(--accent-strong); - transform: scale(1.02); -} - -.dns-template-footer { - padding: 20px 30px; - border-top: 1px solid var(--border); - text-align: center; -} - -.dns-template-later-btn { - background: transparent; - color: var(--fg-muted); - border: 1px solid var(--border); - padding: 10px 24px; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - transition: all 0.2s; -} - -.dns-template-later-btn:hover { - background: var(--hover); - color: var(--fg); - border-color: var(--fg-muted); -} - -/* Responsive design */ -@media (max-width: 768px) { - .dns-template-grid { - grid-template-columns: 1fr; - } - - .dns-template-modal-content { - max-height: 95vh; - } -} +/** + * Onboarding Tooltip Styles + * Custom styling for Driver.js tooltips to match DashCaddy theme + */ + +/* Driver.js overrides are injected dynamically by ThemeAdapter */ +/* This file contains additional custom styles */ + +.driver-popover { + max-width: 500px !important; + z-index: 10000 !important; +} + +.driver-popover-title { + font-size: 1.2rem !important; + margin-bottom: 12px !important; +} + +.driver-popover-description { + font-size: 0.95rem !important; + line-height: 1.6 !important; +} + +.driver-popover-description p { + margin: 8px 0 !important; +} + +.driver-popover-description ul { + margin: 8px 0 !important; + padding-left: 20px !important; +} + +.driver-popover-description li { + margin: 4px 0 !important; +} + +.driver-popover-description code { + background: rgba(0, 0, 0, 0.1) !important; + padding: 2px 6px !important; + border-radius: 3px !important; + font-family: 'Courier New', monospace !important; + font-size: 0.9em !important; +} + +.driver-popover-footer { + margin-top: 16px !important; + display: flex !important; + gap: 8px !important; + justify-content: flex-end !important; +} + +.driver-popover-footer button { + padding: 8px 16px !important; + border-radius: 8px !important; + font-size: 0.9rem !important; + cursor: pointer !important; + transition: all 0.2s ease !important; +} + +.driver-popover-footer button:hover { + transform: translateY(-1px) !important; +} + +.driver-popover-close-btn { + position: absolute !important; + top: 12px !important; + right: 12px !important; + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + opacity: 0.6 !important; + transition: opacity 0.2s ease !important; +} + +.driver-popover-close-btn:hover { + opacity: 1 !important; +} + +.driver-popover-arrow { + border-width: 8px !important; +} + +/* Progress indicator */ +.driver-popover-progress-text { + font-size: 0.85rem !important; + margin-bottom: 8px !important; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .driver-popover { + max-width: calc(100vw - 32px) !important; + } + + .driver-popover-title { + font-size: 1.1rem !important; + } + + .driver-popover-description { + font-size: 0.9rem !important; + } + + .driver-popover-footer button { + padding: 6px 12px !important; + font-size: 0.85rem !important; + } +} + +/* Restart tour button in dashboard */ +#restart-tour-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +#restart-tour-btn::before { + content: "🎓"; + font-size: 1.1em; +} + + +/* DNS Template Selector Modal */ +.dns-template-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.dns-template-modal-content { + background: var(--card-base); + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.dns-template-header { + padding: 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.dns-template-header h2 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 28px; +} + +.dns-template-header p { + margin: 0; + color: var(--fg-muted); + font-size: 14px; +} + +.dns-template-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + font-size: 32px; + color: var(--fg-muted); + cursor: pointer; + padding: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} + +.dns-template-close:hover { + background: var(--hover); + color: var(--fg); +} + +.dns-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + padding: 30px; +} + +.dns-template-card { + background: var(--card-hover); + border: 2px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: all 0.3s; + position: relative; + display: flex; + flex-direction: column; +} + +.dns-template-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--accent); +} + +.dns-template-card.recommended { + border-color: var(--accent); + background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); +} + +.recommended-badge { + position: absolute; + top: -10px; + right: 20px; + background: var(--accent); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dns-template-icon { + font-size: 48px; + margin-bottom: 15px; + text-align: center; +} + +.dns-template-card h3 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 18px; + text-align: center; +} + +.dns-template-description { + color: var(--fg-muted); + font-size: 13px; + margin: 0 0 15px 0; + text-align: center; + flex-grow: 1; +} + +.dns-template-difficulty { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-align: center; + margin: 0 auto 15px auto; +} + +.difficulty-easy { + background: #2ecc71; + color: white; +} + +.difficulty-intermediate { + background: #f39c12; + color: white; +} + +.difficulty-advanced { + background: #e74c3c; + color: white; +} + +.dns-template-features { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + font-size: 12px; + color: var(--fg-muted); +} + +.dns-template-features li { + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.dns-template-features li:before { + content: "✓"; + position: absolute; + left: 0; + color: var(--accent); + font-weight: bold; +} + +.dns-template-select-btn { + background: var(--accent); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + width: 100%; +} + +.dns-template-select-btn:hover { + background: var(--accent-strong); + transform: scale(1.02); +} + +.dns-template-footer { + padding: 20px 30px; + border-top: 1px solid var(--border); + text-align: center; +} + +.dns-template-later-btn { + background: transparent; + color: var(--fg-muted); + border: 1px solid var(--border); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.dns-template-later-btn:hover { + background: var(--hover); + color: var(--fg); + border-color: var(--fg-muted); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dns-template-grid { + grid-template-columns: 1fr; + } + + .dns-template-modal-content { + max-height: 95vh; + } +} diff --git a/dashcaddy-api/assets/onboarding.js b/dashcaddy-api/assets/onboarding.js index 98949ac..4649b82 100644 --- a/dashcaddy-api/assets/onboarding.js +++ b/dashcaddy-api/assets/onboarding.js @@ -1,177 +1,177 @@ -/** - * DashCaddy User Onboarding System - * Main entry point for the tooltip-based onboarding experience - * - * This file initializes the onboarding system and coordinates between - * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) - */ - -(function() { - 'use strict'; - - let progressTracker; - let themeAdapter; - let tourManager; - let dnsTemplateSelector; - let errorHandler; - - /** - * Initialize the onboarding system - */ - async function initializeOnboarding() { - try { - console.log('[Onboarding] Initializing system...'); - - // Initialize Error Handler first - errorHandler = new ErrorHandler(); - console.log('[Onboarding] Error Handler initialized'); - - // Initialize Progress Tracker - progressTracker = new ProgressTracker('dashcaddy_onboarding'); - console.log('[Onboarding] Progress Tracker initialized'); - - // Initialize Theme Adapter - themeAdapter = new ThemeAdapter(); - console.log('[Onboarding] Theme Adapter initialized'); - - // Initialize DNS Template Selector - dnsTemplateSelector = new DnsTemplateSelector(progressTracker); - console.log('[Onboarding] DNS Template Selector initialized'); - - // Initialize Tour Manager - tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); - console.log('[Onboarding] Tour Manager initialized'); - - // Check if tour should auto-start - if (tourManager.shouldAutoStart()) { - console.log('[Onboarding] Auto-starting tour for first-time user'); - // Wait a bit for page to fully load - setTimeout(() => { - tourManager.startTour(); - }, 1000); - } else { - const tourCompleted = progressTracker.isTourCompleted(); - const currentStep = progressTracker.getCurrentStep(); - console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); - - // If tour is in progress, offer to resume - if (!tourCompleted && currentStep > 0) { - console.log('[Onboarding] Tour in progress, can be resumed manually'); - } - } - - // Add restart tour button to tools row - addRestartTourButton(); - - // Expose to global scope for manual triggering - window.DashCaddyOnboarding = { - startTour: () => tourManager.startTour(), - restartTour: () => tourManager.restartTour(), - showTooltip: (id) => tourManager.showTooltip(id), - showWhatsNew: () => tourManager.showWhatsNew(), - resetProgress: () => progressTracker.resetProgress(), - getErrors: () => errorHandler.getErrors(), - getErrorStats: () => errorHandler.getStatistics() - }; - - console.log('[Onboarding] System initialized successfully'); - } catch (error) { - console.error('[Onboarding] Initialization error:', error); - - // Use error handler if available - if (errorHandler) { - errorHandler.logError('Initialization', error); - } - - // Graceful degradation - don't break the dashboard - console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); - } - } - - /** - * Add restart tour button to tools row - */ - function addRestartTourButton() { - const toolsRow = document.querySelector('.tools'); - if (!toolsRow) return; - - const clickHandler = () => { - if (tourManager) { - console.log('[Onboarding] Starting tour via button click'); - tourManager.restartTour(); - } else { - console.error('[Onboarding] Tour manager not initialized'); - alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); - } - }; - - // If button already exists in the HTML, just attach the handler - const existing = document.getElementById('restart-tour-btn'); - if (existing) { - existing.onclick = clickHandler; - return; - } - - const button = document.createElement('button'); - button.id = 'restart-tour-btn'; - button.textContent = 'Help Tour'; - button.title = 'Restart the onboarding tour'; - button.onclick = clickHandler; - toolsRow.appendChild(button); - } - - /** - * Check if Driver.js is loaded - */ - function checkDriverLoaded() { - // Driver.js v1.x IIFE: window.driver.js.driver is the factory function - const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; - if (typeof driverFactory !== 'function') { - console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); - return false; - } - return true; - } - - /** - * Wait for Driver.js to load, then initialize - */ - function waitForDriver() { - let retries = 0; - const maxRetries = 10; - - function attemptInit() { - if (checkDriverLoaded()) { - initializeOnboarding(); - } else { - retries++; - if (retries < maxRetries) { - // Retry after a short delay - setTimeout(attemptInit, 500); - } else { - // Max retries reached, show fallback - console.error('[Onboarding] Driver.js failed to load after multiple attempts'); - if (errorHandler) { - errorHandler.handleDriverLoadFailure(); - } else { - // Create temporary error handler for fallback - const tempHandler = new ErrorHandler(); - tempHandler.handleDriverLoadFailure(); - } - } - } - } - - attemptInit(); - } - - // Start initialization when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', waitForDriver); - } else { - waitForDriver(); - } - - console.log('[Onboarding] System loaded'); - -})(); +/** + * DashCaddy User Onboarding System + * Main entry point for the tooltip-based onboarding experience + * + * This file initializes the onboarding system and coordinates between + * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) + */ + +(function() { + 'use strict'; + + let progressTracker; + let themeAdapter; + let tourManager; + let dnsTemplateSelector; + let errorHandler; + + /** + * Initialize the onboarding system + */ + async function initializeOnboarding() { + try { + console.log('[Onboarding] Initializing system...'); + + // Initialize Error Handler first + errorHandler = new ErrorHandler(); + console.log('[Onboarding] Error Handler initialized'); + + // Initialize Progress Tracker + progressTracker = new ProgressTracker('dashcaddy_onboarding'); + console.log('[Onboarding] Progress Tracker initialized'); + + // Initialize Theme Adapter + themeAdapter = new ThemeAdapter(); + console.log('[Onboarding] Theme Adapter initialized'); + + // Initialize DNS Template Selector + dnsTemplateSelector = new DnsTemplateSelector(progressTracker); + console.log('[Onboarding] DNS Template Selector initialized'); + + // Initialize Tour Manager + tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); + console.log('[Onboarding] Tour Manager initialized'); + + // Check if tour should auto-start + if (tourManager.shouldAutoStart()) { + console.log('[Onboarding] Auto-starting tour for first-time user'); + // Wait a bit for page to fully load + setTimeout(() => { + tourManager.startTour(); + }, 1000); + } else { + const tourCompleted = progressTracker.isTourCompleted(); + const currentStep = progressTracker.getCurrentStep(); + console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + + // If tour is in progress, offer to resume + if (!tourCompleted && currentStep > 0) { + console.log('[Onboarding] Tour in progress, can be resumed manually'); + } + } + + // Add restart tour button to tools row + addRestartTourButton(); + + // Expose to global scope for manual triggering + window.DashCaddyOnboarding = { + startTour: () => tourManager.startTour(), + restartTour: () => tourManager.restartTour(), + showTooltip: (id) => tourManager.showTooltip(id), + showWhatsNew: () => tourManager.showWhatsNew(), + resetProgress: () => progressTracker.resetProgress(), + getErrors: () => errorHandler.getErrors(), + getErrorStats: () => errorHandler.getStatistics() + }; + + console.log('[Onboarding] System initialized successfully'); + } catch (error) { + console.error('[Onboarding] Initialization error:', error); + + // Use error handler if available + if (errorHandler) { + errorHandler.logError('Initialization', error); + } + + // Graceful degradation - don't break the dashboard + console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); + } + } + + /** + * Add restart tour button to tools row + */ + function addRestartTourButton() { + const toolsRow = document.querySelector('.tools'); + if (!toolsRow) return; + + const clickHandler = () => { + if (tourManager) { + console.log('[Onboarding] Starting tour via button click'); + tourManager.restartTour(); + } else { + console.error('[Onboarding] Tour manager not initialized'); + alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); + } + }; + + // If button already exists in the HTML, just attach the handler + const existing = document.getElementById('restart-tour-btn'); + if (existing) { + existing.onclick = clickHandler; + return; + } + + const button = document.createElement('button'); + button.id = 'restart-tour-btn'; + button.textContent = 'Help Tour'; + button.title = 'Restart the onboarding tour'; + button.onclick = clickHandler; + toolsRow.appendChild(button); + } + + /** + * Check if Driver.js is loaded + */ + function checkDriverLoaded() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + if (typeof driverFactory !== 'function') { + console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); + return false; + } + return true; + } + + /** + * Wait for Driver.js to load, then initialize + */ + function waitForDriver() { + let retries = 0; + const maxRetries = 10; + + function attemptInit() { + if (checkDriverLoaded()) { + initializeOnboarding(); + } else { + retries++; + if (retries < maxRetries) { + // Retry after a short delay + setTimeout(attemptInit, 500); + } else { + // Max retries reached, show fallback + console.error('[Onboarding] Driver.js failed to load after multiple attempts'); + if (errorHandler) { + errorHandler.handleDriverLoadFailure(); + } else { + // Create temporary error handler for fallback + const tempHandler = new ErrorHandler(); + tempHandler.handleDriverLoadFailure(); + } + } + } + } + + attemptInit(); + } + + // Start initialization when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitForDriver); + } else { + waitForDriver(); + } + + console.log('[Onboarding] System loaded'); + +})(); diff --git a/dashcaddy-api/assets/progress-tracker.js b/dashcaddy-api/assets/progress-tracker.js index cbfe3db..9ed9d8c 100644 --- a/dashcaddy-api/assets/progress-tracker.js +++ b/dashcaddy-api/assets/progress-tracker.js @@ -1,282 +1,282 @@ -/** - * Progress Tracker - * Manages persistent storage of user progress through the onboarding flow - * using browser local storage. - * - * Storage Schema: - * { - * "version": "1.0", - * "tourCompleted": false, - * "completedTooltips": ["welcome", "dns-priority", ...], - * "currentStep": 3, - * "completionTimestamp": "2024-01-15T10:30:00Z", - * "dnsSetupDeferred": false, - * "lastVisit": "2024-01-15T10:30:00Z" - * } - */ - -(function(window) { - 'use strict'; - - /** - * ProgressTracker class - * Manages persistent storage of onboarding progress - * - * @class - * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') - */ - class ProgressTracker { - constructor(storageKey = 'dashcaddy_onboarding') { - this.storageKey = storageKey; - this.storageVersion = '1.0'; - - // Initialize storage if it doesn't exist - this._initializeStorage(); - - // Update last visit timestamp - this._updateLastVisit(); - } - - /** - * Initialize storage with default values if it doesn't exist - * @private - */ - _initializeStorage() { - const existing = this._getStorage(); - if (!existing || existing.version !== this.storageVersion) { - const defaultState = { - version: this.storageVersion, - tourCompleted: false, - completedTooltips: [], - currentStep: 0, - completionTimestamp: null, - dnsSetupDeferred: false, - lastVisit: new Date().toISOString() - }; - this._setStorage(defaultState); - } - } - - /** - * Get the current storage state - * @private - * @returns {Object|null} The storage state or null if unavailable - */ - _getStorage() { - try { - const data = localStorage.getItem(this.storageKey); - return data ? JSON.parse(data) : null; - } catch (error) { - console.error('[ProgressTracker] Error reading from storage:', error); - return null; - } - } - - /** - * Set the storage state - * @private - * @param {Object} state - The state to save - */ - _setStorage(state) { - try { - localStorage.setItem(this.storageKey, JSON.stringify(state)); - } catch (error) { - console.error('[ProgressTracker] Error writing to storage:', error); - // Handle quota exceeded or storage unavailable - // Fall back to session storage or in-memory storage - this._handleStorageError(error); - } - } - - /** - * Handle storage errors (quota exceeded, unavailable, etc.) - * @private - * @param {Error} error - The error that occurred - */ - _handleStorageError(error) { - // Try session storage as fallback - try { - sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); - console.warn('[ProgressTracker] Falling back to session storage'); - } catch (sessionError) { - console.error('[ProgressTracker] Session storage also unavailable:', sessionError); - // Could implement in-memory fallback here if needed - } - } - - /** - * Update the last visit timestamp - * @private - */ - _updateLastVisit() { - const state = this._getStorage(); - if (state) { - state.lastVisit = new Date().toISOString(); - this._setStorage(state); - } - } - - /** - * Check if a specific tooltip has been completed - * @param {string} tooltipId - The ID of the tooltip to check - * @returns {boolean} True if the tooltip has been completed - */ - isTooltipCompleted(tooltipId) { - const state = this._getStorage(); - if (!state) return false; - return state.completedTooltips.includes(tooltipId); - } - - /** - * Mark a tooltip as completed with timestamp - * @param {string} tooltipId - The ID of the tooltip to mark as completed - */ - markTooltipCompleted(tooltipId) { - const state = this._getStorage(); - if (!state) return; - - // Add tooltip to completed list if not already there - if (!state.completedTooltips.includes(tooltipId)) { - state.completedTooltips.push(tooltipId); - - // Store timestamp for this specific tooltip - if (!state.tooltipTimestamps) { - state.tooltipTimestamps = {}; - } - state.tooltipTimestamps[tooltipId] = new Date().toISOString(); - - this._setStorage(state); - } - } - - /** - * Check if the entire tour has been completed - * @returns {boolean} True if the tour is completed - */ - isTourCompleted() { - const state = this._getStorage(); - if (!state) return false; - return state.tourCompleted === true; - } - - /** - * Mark the entire tour as completed - */ - markTourCompleted() { - const state = this._getStorage(); - if (!state) return; - - state.tourCompleted = true; - state.completionTimestamp = new Date().toISOString(); - this._setStorage(state); - } - - /** - * Get the current step index - * @returns {number} The current step index (0-based) - */ - getCurrentStep() { - const state = this._getStorage(); - if (!state) return 0; - return state.currentStep || 0; - } - - /** - * Set the current step index - * @param {number} stepIndex - The step index to set (0-based) - */ - setCurrentStep(stepIndex) { - const state = this._getStorage(); - if (!state) return; - - state.currentStep = stepIndex; - this._setStorage(state); - } - - /** - * Reset all progress and clear storage - */ - resetProgress() { - const defaultState = { - version: this.storageVersion, - tourCompleted: false, - completedTooltips: [], - currentStep: 0, - completionTimestamp: null, - dnsSetupDeferred: false, - lastVisit: new Date().toISOString() - }; - this._setStorage(defaultState); - } - - /** - * Get the completion timestamp - * @returns {Date|null} The completion timestamp or null if not completed - */ - getCompletionTimestamp() { - const state = this._getStorage(); - if (!state || !state.completionTimestamp) return null; - return new Date(state.completionTimestamp); - } - - /** - * Check if DNS setup was deferred - * @returns {boolean} True if DNS setup was deferred - */ - isDnsSetupDeferred() { - const state = this._getStorage(); - if (!state) return false; - return state.dnsSetupDeferred === true; - } - - /** - * Mark DNS setup as deferred - */ - markDnsSetupDeferred() { - const state = this._getStorage(); - if (!state) return; - - state.dnsSetupDeferred = true; - this._setStorage(state); - } - - /** - * Get the timestamp for a specific tooltip completion - * @param {string} tooltipId - The ID of the tooltip - * @returns {Date|null} The timestamp or null if not completed - */ - getTooltipTimestamp(tooltipId) { - const state = this._getStorage(); - if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { - return null; - } - return new Date(state.tooltipTimestamps[tooltipId]); - } - - /** - * Get all completed tooltip IDs - * @returns {string[]} Array of completed tooltip IDs - */ - getCompletedTooltips() { - const state = this._getStorage(); - if (!state) return []; - return state.completedTooltips || []; - } - - /** - * Get the last visit timestamp - * @returns {Date|null} The last visit timestamp - */ - getLastVisit() { - const state = this._getStorage(); - if (!state || !state.lastVisit) return null; - return new Date(state.lastVisit); - } - } - - // Export to global scope - window.ProgressTracker = ProgressTracker; - - console.log('[ProgressTracker] Module loaded'); - -})(window); +/** + * Progress Tracker + * Manages persistent storage of user progress through the onboarding flow + * using browser local storage. + * + * Storage Schema: + * { + * "version": "1.0", + * "tourCompleted": false, + * "completedTooltips": ["welcome", "dns-priority", ...], + * "currentStep": 3, + * "completionTimestamp": "2024-01-15T10:30:00Z", + * "dnsSetupDeferred": false, + * "lastVisit": "2024-01-15T10:30:00Z" + * } + */ + +(function(window) { + 'use strict'; + + /** + * ProgressTracker class + * Manages persistent storage of onboarding progress + * + * @class + * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') + */ + class ProgressTracker { + constructor(storageKey = 'dashcaddy_onboarding') { + this.storageKey = storageKey; + this.storageVersion = '1.0'; + + // Initialize storage if it doesn't exist + this._initializeStorage(); + + // Update last visit timestamp + this._updateLastVisit(); + } + + /** + * Initialize storage with default values if it doesn't exist + * @private + */ + _initializeStorage() { + const existing = this._getStorage(); + if (!existing || existing.version !== this.storageVersion) { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + } + + /** + * Get the current storage state + * @private + * @returns {Object|null} The storage state or null if unavailable + */ + _getStorage() { + try { + const data = localStorage.getItem(this.storageKey); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('[ProgressTracker] Error reading from storage:', error); + return null; + } + } + + /** + * Set the storage state + * @private + * @param {Object} state - The state to save + */ + _setStorage(state) { + try { + localStorage.setItem(this.storageKey, JSON.stringify(state)); + } catch (error) { + console.error('[ProgressTracker] Error writing to storage:', error); + // Handle quota exceeded or storage unavailable + // Fall back to session storage or in-memory storage + this._handleStorageError(error); + } + } + + /** + * Handle storage errors (quota exceeded, unavailable, etc.) + * @private + * @param {Error} error - The error that occurred + */ + _handleStorageError(error) { + // Try session storage as fallback + try { + sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); + console.warn('[ProgressTracker] Falling back to session storage'); + } catch (sessionError) { + console.error('[ProgressTracker] Session storage also unavailable:', sessionError); + // Could implement in-memory fallback here if needed + } + } + + /** + * Update the last visit timestamp + * @private + */ + _updateLastVisit() { + const state = this._getStorage(); + if (state) { + state.lastVisit = new Date().toISOString(); + this._setStorage(state); + } + } + + /** + * Check if a specific tooltip has been completed + * @param {string} tooltipId - The ID of the tooltip to check + * @returns {boolean} True if the tooltip has been completed + */ + isTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return false; + return state.completedTooltips.includes(tooltipId); + } + + /** + * Mark a tooltip as completed with timestamp + * @param {string} tooltipId - The ID of the tooltip to mark as completed + */ + markTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return; + + // Add tooltip to completed list if not already there + if (!state.completedTooltips.includes(tooltipId)) { + state.completedTooltips.push(tooltipId); + + // Store timestamp for this specific tooltip + if (!state.tooltipTimestamps) { + state.tooltipTimestamps = {}; + } + state.tooltipTimestamps[tooltipId] = new Date().toISOString(); + + this._setStorage(state); + } + } + + /** + * Check if the entire tour has been completed + * @returns {boolean} True if the tour is completed + */ + isTourCompleted() { + const state = this._getStorage(); + if (!state) return false; + return state.tourCompleted === true; + } + + /** + * Mark the entire tour as completed + */ + markTourCompleted() { + const state = this._getStorage(); + if (!state) return; + + state.tourCompleted = true; + state.completionTimestamp = new Date().toISOString(); + this._setStorage(state); + } + + /** + * Get the current step index + * @returns {number} The current step index (0-based) + */ + getCurrentStep() { + const state = this._getStorage(); + if (!state) return 0; + return state.currentStep || 0; + } + + /** + * Set the current step index + * @param {number} stepIndex - The step index to set (0-based) + */ + setCurrentStep(stepIndex) { + const state = this._getStorage(); + if (!state) return; + + state.currentStep = stepIndex; + this._setStorage(state); + } + + /** + * Reset all progress and clear storage + */ + resetProgress() { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + + /** + * Get the completion timestamp + * @returns {Date|null} The completion timestamp or null if not completed + */ + getCompletionTimestamp() { + const state = this._getStorage(); + if (!state || !state.completionTimestamp) return null; + return new Date(state.completionTimestamp); + } + + /** + * Check if DNS setup was deferred + * @returns {boolean} True if DNS setup was deferred + */ + isDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return false; + return state.dnsSetupDeferred === true; + } + + /** + * Mark DNS setup as deferred + */ + markDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return; + + state.dnsSetupDeferred = true; + this._setStorage(state); + } + + /** + * Get the timestamp for a specific tooltip completion + * @param {string} tooltipId - The ID of the tooltip + * @returns {Date|null} The timestamp or null if not completed + */ + getTooltipTimestamp(tooltipId) { + const state = this._getStorage(); + if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { + return null; + } + return new Date(state.tooltipTimestamps[tooltipId]); + } + + /** + * Get all completed tooltip IDs + * @returns {string[]} Array of completed tooltip IDs + */ + getCompletedTooltips() { + const state = this._getStorage(); + if (!state) return []; + return state.completedTooltips || []; + } + + /** + * Get the last visit timestamp + * @returns {Date|null} The last visit timestamp + */ + getLastVisit() { + const state = this._getStorage(); + if (!state || !state.lastVisit) return null; + return new Date(state.lastVisit); + } + } + + // Export to global scope + window.ProgressTracker = ProgressTracker; + + console.log('[ProgressTracker] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/tooltip-definitions.js b/dashcaddy-api/assets/tooltip-definitions.js index 8ef36d2..c4471da 100644 --- a/dashcaddy-api/assets/tooltip-definitions.js +++ b/dashcaddy-api/assets/tooltip-definitions.js @@ -1,337 +1,337 @@ -/** - * Tooltip Definitions - * Defines all tooltip content, positioning, and behavior for the onboarding system - */ - -(function(window) { - 'use strict'; - - /** - * Validate a tooltip definition - * @param {Object} tooltip - The tooltip definition to validate - * @returns {Object} { valid: boolean, errors: string[] } - */ - function validateTooltipDefinition(tooltip) { - const errors = []; - - // Required fields - if (!tooltip.id || typeof tooltip.id !== 'string') { - errors.push('Tooltip must have a valid string id'); - } - - if (!tooltip.element) { - errors.push('Tooltip must have an element selector or HTMLElement'); - } - - if (!tooltip.popover || typeof tooltip.popover !== 'object') { - errors.push('Tooltip must have a popover object'); - } else { - // Validate popover fields - if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') { - errors.push('Tooltip popover must have a valid string title'); - } - - if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') { - errors.push('Tooltip popover must have a valid string description'); - } - - // Validate position if provided - if (tooltip.popover.position) { - const validPositions = ['top', 'bottom', 'left', 'right', 'center']; - if (!validPositions.includes(tooltip.popover.position)) { - errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`); - } - } - - // Validate align if provided - if (tooltip.popover.align) { - const validAligns = ['start', 'center', 'end']; - if (!validAligns.includes(tooltip.popover.align)) { - errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`); - } - } - - // Validate showButtons if provided - if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) { - errors.push('showButtons must be an array'); - } - - // Validate callbacks if provided - const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater']; - callbacks.forEach(callback => { - if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') { - errors.push(`${callback} must be a function`); - } - }); - } - - // Validate condition if provided - if (tooltip.condition && typeof tooltip.condition !== 'function') { - errors.push('condition must be a function'); - } - - // Validate priority if provided - if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') { - errors.push('priority must be a number'); - } - - return { - valid: errors.length === 0, - errors - }; - } - - /** - * Validate an array of tooltip definitions - * @param {Array} tooltips - Array of tooltip definitions - * @returns {Object} { valid: boolean, errors: Object[] } - */ - function validateTooltipDefinitions(tooltips) { - if (!Array.isArray(tooltips)) { - return { - valid: false, - errors: [{ tooltip: null, errors: ['tooltips must be an array'] }] - }; - } - - const allErrors = []; - const ids = new Set(); - - tooltips.forEach((tooltip, index) => { - const validation = validateTooltipDefinition(tooltip); - - if (!validation.valid) { - allErrors.push({ - tooltip: tooltip.id || `index ${index}`, - errors: validation.errors - }); - } - - // Check for duplicate IDs - if (tooltip.id) { - if (ids.has(tooltip.id)) { - allErrors.push({ - tooltip: tooltip.id, - errors: [`Duplicate tooltip ID: ${tooltip.id}`] - }); - } - ids.add(tooltip.id); - } - }); - - return { - valid: allErrors.length === 0, - errors: allErrors - }; - } - - /** - * Error handler for tooltip system - */ - class TooltipError extends Error { - constructor(message, tooltipId = null) { - super(message); - this.name = 'TooltipError'; - this.tooltipId = tooltipId; - } - } - - /** - * Handle tooltip definition errors - * @param {Object} validation - Validation result - * @throws {TooltipError} If validation fails - */ - function handleValidationErrors(validation) { - if (!validation.valid) { - const errorMessages = validation.errors.map(e => - `${e.tooltip}: ${e.errors.join(', ')}` - ).join('\n'); - - console.error('[TooltipDefinitions] Validation errors:', errorMessages); - throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); - } - } - - // Export to global scope - window.TooltipValidation = { - validateTooltipDefinition, - validateTooltipDefinitions, - handleValidationErrors, - TooltipError - }; - - console.log('[TooltipDefinitions] Validation module loaded'); - -})(window); - - -/** - * Tooltip Definitions Array - * Defines all tooltips for the onboarding tour - */ -const TOOLTIP_DEFINITIONS = [ - // 1. Welcome tooltip pointing to logo - { - id: 'welcome', - element: '#brand', - popover: { - title: 'Welcome to DashCaddy!', - description: ` -Your personal dashboard for managing services with Caddy reverse proxy.
-Let's take a quick tour to help you get started.
-Tip: You can customize this logo in Settings.
- `, - position: 'bottom', - align: 'start', - showButtons: ['next'], - showProgress: true - }, - priority: 1, - isNewFeature: false - }, - - // 2. Add Service button - { - id: 'add-service', - element: '#add-service-btn', - popover: { - title: 'Adding New Services', - description: ` -Click + Add Service to deploy new apps or add existing services to your dashboard.
-Choose from 50+ templates including:
-This is your service grid where all your deployed applications appear.
-Each card shows:
-DashCaddy comes with 7 themes. Click here to switch between them.
-Your preference is saved automatically.
- `, - position: 'bottom', - showButtons: ['previous', 'close'], - showProgress: true - }, - priority: 4, - isNewFeature: false, - condition: () => { - return document.getElementById('theme') !== null; - } - } -]; - -/** - * Get tooltip definitions - * @returns {Array} Array of tooltip definitions - */ -function getTooltipDefinitions() { - return TOOLTIP_DEFINITIONS; -} - -/** - * Get a specific tooltip by ID - * @param {string} id - Tooltip ID - * @returns {Object|null} Tooltip definition or null if not found - */ -function getTooltipById(id) { - return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null; -} - -/** - * Get tooltips filtered by condition - * @returns {Array} Array of tooltips that pass their condition check - */ -function getActiveTooltips() { - return TOOLTIP_DEFINITIONS.filter(tooltip => { - if (tooltip.condition && typeof tooltip.condition === 'function') { - try { - return tooltip.condition(); - } catch (error) { - console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); - return false; - } - } - return true; - }); -} - -/** - * Get tooltips sorted by priority - * @returns {Array} Array of tooltips sorted by priority (ascending) - */ -function getSortedTooltips() { - const tooltips = getActiveTooltips(); - return tooltips.sort((a, b) => { - const priorityA = a.priority || 999; - const priorityB = b.priority || 999; - return priorityA - priorityB; - }); -} - -/** - * Get tooltips marked as new features - * @returns {Array} Array of tooltips marked with isNewFeature flag - */ -function getNewFeatureTooltips() { - const tooltips = getActiveTooltips(); - return tooltips.filter(tooltip => tooltip.isNewFeature === true) - .sort((a, b) => { - const priorityA = a.priority || 999; - const priorityB = b.priority || 999; - return priorityA - priorityB; - }); -} - -// Export to global scope -window.TooltipDefinitions = { - TOOLTIP_DEFINITIONS, - getTooltipDefinitions, - getTooltipById, - getActiveTooltips, - getSortedTooltips, - getNewFeatureTooltips -}; - -console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); - +/** + * Tooltip Definitions + * Defines all tooltip content, positioning, and behavior for the onboarding system + */ + +(function(window) { + 'use strict'; + + /** + * Validate a tooltip definition + * @param {Object} tooltip - The tooltip definition to validate + * @returns {Object} { valid: boolean, errors: string[] } + */ + function validateTooltipDefinition(tooltip) { + const errors = []; + + // Required fields + if (!tooltip.id || typeof tooltip.id !== 'string') { + errors.push('Tooltip must have a valid string id'); + } + + if (!tooltip.element) { + errors.push('Tooltip must have an element selector or HTMLElement'); + } + + if (!tooltip.popover || typeof tooltip.popover !== 'object') { + errors.push('Tooltip must have a popover object'); + } else { + // Validate popover fields + if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') { + errors.push('Tooltip popover must have a valid string title'); + } + + if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') { + errors.push('Tooltip popover must have a valid string description'); + } + + // Validate position if provided + if (tooltip.popover.position) { + const validPositions = ['top', 'bottom', 'left', 'right', 'center']; + if (!validPositions.includes(tooltip.popover.position)) { + errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`); + } + } + + // Validate align if provided + if (tooltip.popover.align) { + const validAligns = ['start', 'center', 'end']; + if (!validAligns.includes(tooltip.popover.align)) { + errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`); + } + } + + // Validate showButtons if provided + if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) { + errors.push('showButtons must be an array'); + } + + // Validate callbacks if provided + const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater']; + callbacks.forEach(callback => { + if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') { + errors.push(`${callback} must be a function`); + } + }); + } + + // Validate condition if provided + if (tooltip.condition && typeof tooltip.condition !== 'function') { + errors.push('condition must be a function'); + } + + // Validate priority if provided + if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') { + errors.push('priority must be a number'); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate an array of tooltip definitions + * @param {Array} tooltips - Array of tooltip definitions + * @returns {Object} { valid: boolean, errors: Object[] } + */ + function validateTooltipDefinitions(tooltips) { + if (!Array.isArray(tooltips)) { + return { + valid: false, + errors: [{ tooltip: null, errors: ['tooltips must be an array'] }] + }; + } + + const allErrors = []; + const ids = new Set(); + + tooltips.forEach((tooltip, index) => { + const validation = validateTooltipDefinition(tooltip); + + if (!validation.valid) { + allErrors.push({ + tooltip: tooltip.id || `index ${index}`, + errors: validation.errors + }); + } + + // Check for duplicate IDs + if (tooltip.id) { + if (ids.has(tooltip.id)) { + allErrors.push({ + tooltip: tooltip.id, + errors: [`Duplicate tooltip ID: ${tooltip.id}`] + }); + } + ids.add(tooltip.id); + } + }); + + return { + valid: allErrors.length === 0, + errors: allErrors + }; + } + + /** + * Error handler for tooltip system + */ + class TooltipError extends Error { + constructor(message, tooltipId = null) { + super(message); + this.name = 'TooltipError'; + this.tooltipId = tooltipId; + } + } + + /** + * Handle tooltip definition errors + * @param {Object} validation - Validation result + * @throws {TooltipError} If validation fails + */ + function handleValidationErrors(validation) { + if (!validation.valid) { + const errorMessages = validation.errors.map(e => + `${e.tooltip}: ${e.errors.join(', ')}` + ).join('\n'); + + console.error('[TooltipDefinitions] Validation errors:', errorMessages); + throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); + } + } + + // Export to global scope + window.TooltipValidation = { + validateTooltipDefinition, + validateTooltipDefinitions, + handleValidationErrors, + TooltipError + }; + + console.log('[TooltipDefinitions] Validation module loaded'); + +})(window); + + +/** + * Tooltip Definitions Array + * Defines all tooltips for the onboarding tour + */ +const TOOLTIP_DEFINITIONS = [ + // 1. Welcome tooltip pointing to logo + { + id: 'welcome', + element: '#brand', + popover: { + title: 'Welcome to DashCaddy!', + description: ` +Your personal dashboard for managing services with Caddy reverse proxy.
+Let's take a quick tour to help you get started.
+Tip: You can customize this logo in Settings.
+ `, + position: 'bottom', + align: 'start', + showButtons: ['next'], + showProgress: true + }, + priority: 1, + isNewFeature: false + }, + + // 2. Add Service button + { + id: 'add-service', + element: '#add-service-btn', + popover: { + title: 'Adding New Services', + description: ` +Click + Add Service to deploy new apps or add existing services to your dashboard.
+Choose from 50+ templates including:
+This is your service grid where all your deployed applications appear.
+Each card shows:
+DashCaddy comes with 7 themes. Click here to switch between them.
+Your preference is saved automatically.
+ `, + position: 'bottom', + showButtons: ['previous', 'close'], + showProgress: true + }, + priority: 4, + isNewFeature: false, + condition: () => { + return document.getElementById('theme') !== null; + } + } +]; + +/** + * Get tooltip definitions + * @returns {Array} Array of tooltip definitions + */ +function getTooltipDefinitions() { + return TOOLTIP_DEFINITIONS; +} + +/** + * Get a specific tooltip by ID + * @param {string} id - Tooltip ID + * @returns {Object|null} Tooltip definition or null if not found + */ +function getTooltipById(id) { + return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null; +} + +/** + * Get tooltips filtered by condition + * @returns {Array} Array of tooltips that pass their condition check + */ +function getActiveTooltips() { + return TOOLTIP_DEFINITIONS.filter(tooltip => { + if (tooltip.condition && typeof tooltip.condition === 'function') { + try { + return tooltip.condition(); + } catch (error) { + console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); + return false; + } + } + return true; + }); +} + +/** + * Get tooltips sorted by priority + * @returns {Array} Array of tooltips sorted by priority (ascending) + */ +function getSortedTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +/** + * Get tooltips marked as new features + * @returns {Array} Array of tooltips marked with isNewFeature flag + */ +function getNewFeatureTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.filter(tooltip => tooltip.isNewFeature === true) + .sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +// Export to global scope +window.TooltipDefinitions = { + TOOLTIP_DEFINITIONS, + getTooltipDefinitions, + getTooltipById, + getActiveTooltips, + getSortedTooltips, + getNewFeatureTooltips +}; + +console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); + diff --git a/dashcaddy-api/assets/tour-manager.js b/dashcaddy-api/assets/tour-manager.js index d972a7c..c1c17ac 100644 --- a/dashcaddy-api/assets/tour-manager.js +++ b/dashcaddy-api/assets/tour-manager.js @@ -1,363 +1,363 @@ -/** - * Tour Manager - * Orchestrates the onboarding tour using Driver.js - */ - -(function(window) { - 'use strict'; - - class TourManager { - constructor(progressTracker, themeAdapter, dnsTemplateSelector) { - this.progressTracker = progressTracker; - this.themeAdapter = themeAdapter; - this.dnsTemplateSelector = dnsTemplateSelector; - this.driver = null; - this.currentStepIndex = 0; - this.isActive = false; - this.resizeHandler = null; - this.layoutChangeHandler = null; - } - - /** - * Initialize Driver.js with theme-aware configuration - */ - async initializeDriver() { - // Driver.js v1.x IIFE: window.driver.js.driver is the factory function - const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; - - if (typeof driverFactory !== 'function') { - console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); - return false; - } - - const themeConfig = this.themeAdapter.getDriverTheme(); - - this.driver = driverFactory({ - showProgress: true, - showButtons: ['next', 'previous', 'close'], - allowClose: true, - overlayClickNext: false, - overlayOpacity: 0, - stagePadding: 0, - stageRadius: 0, - allowKeyboardControl: true, - popoverClass: 'dashcaddy-popover', - onDestroyed: () => this.onTourComplete(), - onDestroyStarted: () => { - if (!this.progressTracker.isTourCompleted()) { - this.onTourSkip(); - } - } - }); - - // Apply theme - this.themeAdapter.applyTheme(this.driver); - - // Listen for theme changes - this.themeAdapter.onThemeChange(() => { - this.themeAdapter.applyTheme(this.driver); - }); - - // Set up dynamic repositioning - this.setupDynamicRepositioning(); - - return true; - } - - /** - * Check if tour should auto-start - */ - shouldAutoStart() { - return !this.progressTracker.isTourCompleted() && - this.progressTracker.getCurrentStep() === 0; - } - - /** - * Start the onboarding tour - */ - async startTour() { - if (!this.driver) { - const initialized = await this.initializeDriver(); - if (!initialized) return; - } - - // Get active tooltips (filtered by conditions) - const allTooltips = window.TooltipDefinitions.getSortedTooltips(); - - // Filter out completed tooltips - const completedIds = this.progressTracker.getCompletedTooltips(); - const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); - - if (activeTooltips.length === 0) { - console.log('[TourManager] No tooltips to show'); - this.progressTracker.markTourCompleted(); - return; - } - - // Convert to Driver.js steps with navigation logic - const steps = activeTooltips.map((tooltip, index) => { - const isFirst = index === 0; - const isLast = index === activeTooltips.length - 1; - - const step = { - element: tooltip.element, - popover: { - title: tooltip.popover.title, - description: tooltip.popover.description, - side: tooltip.popover.position || 'bottom', - align: tooltip.popover.align || 'start', - showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), - showProgress: tooltip.popover.showProgress !== false, - onNextClick: () => { - this.progressTracker.markTooltipCompleted(tooltip.id); - this.progressTracker.setCurrentStep(index + 1); - this.currentStepIndex = index + 1; - this.driver.moveNext(); - }, - onPrevClick: () => { - this.progressTracker.setCurrentStep(Math.max(0, index - 1)); - this.currentStepIndex = Math.max(0, index - 1); - this.driver.movePrevious(); - }, - onCloseClick: () => { - this.skipTour(); - } - } - }; - - // Add custom handlers for DNS tooltip - if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { - step.popover.onSetupNowClick = () => { - console.log('[TourManager] Opening DNS template selector'); - this.dnsTemplateSelector.showTemplateSelector(); - // Mark tooltip as completed and move to next - this.progressTracker.markTooltipCompleted(tooltip.id); - this.progressTracker.setCurrentStep(index + 1); - this.currentStepIndex = index + 1; - this.driver.moveNext(); - }; - - step.popover.onLaterClick = () => { - console.log('[TourManager] DNS setup deferred'); - this.progressTracker.markDnsSetupDeferred(); - // Mark tooltip as completed and move to next - this.progressTracker.markTooltipCompleted(tooltip.id); - this.progressTracker.setCurrentStep(index + 1); - this.currentStepIndex = index + 1; - this.driver.moveNext(); - }; - } - - return step; - }); - - this.isActive = true; - this.driver.setSteps(steps); - this.driver.drive(); - } - - /** - * Resume tour from last step - */ - async resumeTour() { - const currentStep = this.progressTracker.getCurrentStep(); - if (currentStep > 0) { - await this.startTour(); - // Driver.js will start from beginning, we'd need to skip to current step - // This is a simplified implementation - } else { - await this.startTour(); - } - } - - /** - * Skip the entire tour - */ - skipTour() { - if (this.driver) { - this.driver.destroy(); - } - this.cleanupDynamicRepositioning(); - this.isActive = false; - } - - /** - * Restart tour from beginning - */ - async restartTour() { - this.progressTracker.resetProgress(); - await this.startTour(); - } - - /** - * Show a specific tooltip by ID - */ - async showTooltip(tooltipId) { - const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); - if (!tooltip) { - console.error(`[TourManager] Tooltip not found: ${tooltipId}`); - return; - } - - if (!this.driver) { - await this.initializeDriver(); - } - - const step = { - element: tooltip.element, - popover: { - title: tooltip.popover.title, - description: tooltip.popover.description, - side: tooltip.popover.position || 'bottom', - align: tooltip.popover.align || 'start' - } - }; - - this.driver.highlight(step); - } - - /** - * Show "What's New" tour - only tooltips marked as new features - */ - async showWhatsNew() { - if (!this.driver) { - const initialized = await this.initializeDriver(); - if (!initialized) return; - } - - // Get only new feature tooltips - const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); - - if (newFeatureTooltips.length === 0) { - console.log('[TourManager] No new features to show'); - return; - } - - console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); - - // Convert to Driver.js steps - const steps = newFeatureTooltips.map((tooltip, index) => { - const isFirst = index === 0; - const isLast = index === newFeatureTooltips.length - 1; - - return { - element: tooltip.element, - popover: { - title: `✨ NEW: ${tooltip.popover.title}`, - description: tooltip.popover.description, - side: tooltip.popover.position || 'bottom', - align: tooltip.popover.align || 'start', - showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), - showProgress: true, - onNextClick: () => { - this.driver.moveNext(); - }, - onPrevClick: () => { - this.driver.movePrevious(); - }, - onCloseClick: () => { - this.skipTour(); - } - } - }; - }); - - this.isActive = true; - this.driver.setSteps(steps); - this.driver.drive(); - } - - /** - * Set up dynamic repositioning for window resize and layout changes - */ - setupDynamicRepositioning() { - // Window resize handler with debouncing - let resizeTimeout; - this.resizeHandler = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - if (this.isActive && this.driver) { - console.log('[TourManager] Window resized, repositioning tooltip'); - this.driver.refresh(); - } - }, 150); // Debounce for 150ms - }; - - // Layout change handler (for theme changes, DOM mutations) - this.layoutChangeHandler = () => { - if (this.isActive && this.driver) { - console.log('[TourManager] Layout changed, repositioning tooltip'); - // Small delay to allow layout to settle - setTimeout(() => { - if (this.driver) { - this.driver.refresh(); - } - }, 100); - } - }; - - // Add event listeners - window.addEventListener('resize', this.resizeHandler); - - // Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition) - this.themeAdapter.onThemeChange(this.layoutChangeHandler); - } - - /** - * Clean up dynamic repositioning listeners - */ - cleanupDynamicRepositioning() { - if (this.resizeHandler) { - window.removeEventListener('resize', this.resizeHandler); - } - } - - /** - * Get buttons to show for a specific step - * @private - */ - _getButtonsForStep(tooltip, isFirst, isLast) { - // Check if tooltip has custom buttons defined - if (tooltip.popover.showButtons) { - return tooltip.popover.showButtons; - } - - // Default button configuration - const buttons = []; - - if (!isFirst) { - buttons.push('previous'); - } - - if (!isLast) { - buttons.push('next'); - } else { - buttons.push('close'); - } - - return buttons; - } - - /** - * Handle tour completion - */ - onTourComplete() { - this.progressTracker.markTourCompleted(); - this.isActive = false; - console.log('[TourManager] Tour completed'); - } - - /** - * Handle tour skip - */ - onTourSkip() { - // Save current progress but don't mark as completed - console.log('[TourManager] Tour skipped'); - this.isActive = false; - } - } - - window.TourManager = TourManager; - console.log('[TourManager] Module loaded'); - -})(window); +/** + * Tour Manager + * Orchestrates the onboarding tour using Driver.js + */ + +(function(window) { + 'use strict'; + + class TourManager { + constructor(progressTracker, themeAdapter, dnsTemplateSelector) { + this.progressTracker = progressTracker; + this.themeAdapter = themeAdapter; + this.dnsTemplateSelector = dnsTemplateSelector; + this.driver = null; + this.currentStepIndex = 0; + this.isActive = false; + this.resizeHandler = null; + this.layoutChangeHandler = null; + } + + /** + * Initialize Driver.js with theme-aware configuration + */ + async initializeDriver() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + + if (typeof driverFactory !== 'function') { + console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); + return false; + } + + const themeConfig = this.themeAdapter.getDriverTheme(); + + this.driver = driverFactory({ + showProgress: true, + showButtons: ['next', 'previous', 'close'], + allowClose: true, + overlayClickNext: false, + overlayOpacity: 0, + stagePadding: 0, + stageRadius: 0, + allowKeyboardControl: true, + popoverClass: 'dashcaddy-popover', + onDestroyed: () => this.onTourComplete(), + onDestroyStarted: () => { + if (!this.progressTracker.isTourCompleted()) { + this.onTourSkip(); + } + } + }); + + // Apply theme + this.themeAdapter.applyTheme(this.driver); + + // Listen for theme changes + this.themeAdapter.onThemeChange(() => { + this.themeAdapter.applyTheme(this.driver); + }); + + // Set up dynamic repositioning + this.setupDynamicRepositioning(); + + return true; + } + + /** + * Check if tour should auto-start + */ + shouldAutoStart() { + return !this.progressTracker.isTourCompleted() && + this.progressTracker.getCurrentStep() === 0; + } + + /** + * Start the onboarding tour + */ + async startTour() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get active tooltips (filtered by conditions) + const allTooltips = window.TooltipDefinitions.getSortedTooltips(); + + // Filter out completed tooltips + const completedIds = this.progressTracker.getCompletedTooltips(); + const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); + + if (activeTooltips.length === 0) { + console.log('[TourManager] No tooltips to show'); + this.progressTracker.markTourCompleted(); + return; + } + + // Convert to Driver.js steps with navigation logic + const steps = activeTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === activeTooltips.length - 1; + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: tooltip.popover.showProgress !== false, + onNextClick: () => { + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }, + onPrevClick: () => { + this.progressTracker.setCurrentStep(Math.max(0, index - 1)); + this.currentStepIndex = Math.max(0, index - 1); + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + + // Add custom handlers for DNS tooltip + if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { + step.popover.onSetupNowClick = () => { + console.log('[TourManager] Opening DNS template selector'); + this.dnsTemplateSelector.showTemplateSelector(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + + step.popover.onLaterClick = () => { + console.log('[TourManager] DNS setup deferred'); + this.progressTracker.markDnsSetupDeferred(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + } + + return step; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Resume tour from last step + */ + async resumeTour() { + const currentStep = this.progressTracker.getCurrentStep(); + if (currentStep > 0) { + await this.startTour(); + // Driver.js will start from beginning, we'd need to skip to current step + // This is a simplified implementation + } else { + await this.startTour(); + } + } + + /** + * Skip the entire tour + */ + skipTour() { + if (this.driver) { + this.driver.destroy(); + } + this.cleanupDynamicRepositioning(); + this.isActive = false; + } + + /** + * Restart tour from beginning + */ + async restartTour() { + this.progressTracker.resetProgress(); + await this.startTour(); + } + + /** + * Show a specific tooltip by ID + */ + async showTooltip(tooltipId) { + const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); + if (!tooltip) { + console.error(`[TourManager] Tooltip not found: ${tooltipId}`); + return; + } + + if (!this.driver) { + await this.initializeDriver(); + } + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start' + } + }; + + this.driver.highlight(step); + } + + /** + * Show "What's New" tour - only tooltips marked as new features + */ + async showWhatsNew() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get only new feature tooltips + const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); + + if (newFeatureTooltips.length === 0) { + console.log('[TourManager] No new features to show'); + return; + } + + console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); + + // Convert to Driver.js steps + const steps = newFeatureTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === newFeatureTooltips.length - 1; + + return { + element: tooltip.element, + popover: { + title: `✨ NEW: ${tooltip.popover.title}`, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: true, + onNextClick: () => { + this.driver.moveNext(); + }, + onPrevClick: () => { + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Set up dynamic repositioning for window resize and layout changes + */ + setupDynamicRepositioning() { + // Window resize handler with debouncing + let resizeTimeout; + this.resizeHandler = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (this.isActive && this.driver) { + console.log('[TourManager] Window resized, repositioning tooltip'); + this.driver.refresh(); + } + }, 150); // Debounce for 150ms + }; + + // Layout change handler (for theme changes, DOM mutations) + this.layoutChangeHandler = () => { + if (this.isActive && this.driver) { + console.log('[TourManager] Layout changed, repositioning tooltip'); + // Small delay to allow layout to settle + setTimeout(() => { + if (this.driver) { + this.driver.refresh(); + } + }, 100); + } + }; + + // Add event listeners + window.addEventListener('resize', this.resizeHandler); + + // Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition) + this.themeAdapter.onThemeChange(this.layoutChangeHandler); + } + + /** + * Clean up dynamic repositioning listeners + */ + cleanupDynamicRepositioning() { + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + } + + /** + * Get buttons to show for a specific step + * @private + */ + _getButtonsForStep(tooltip, isFirst, isLast) { + // Check if tooltip has custom buttons defined + if (tooltip.popover.showButtons) { + return tooltip.popover.showButtons; + } + + // Default button configuration + const buttons = []; + + if (!isFirst) { + buttons.push('previous'); + } + + if (!isLast) { + buttons.push('next'); + } else { + buttons.push('close'); + } + + return buttons; + } + + /** + * Handle tour completion + */ + onTourComplete() { + this.progressTracker.markTourCompleted(); + this.isActive = false; + console.log('[TourManager] Tour completed'); + } + + /** + * Handle tour skip + */ + onTourSkip() { + // Save current progress but don't mark as completed + console.log('[TourManager] Tour skipped'); + this.isActive = false; + } + } + + window.TourManager = TourManager; + console.log('[TourManager] Module loaded'); + +})(window); diff --git a/status/build.js b/status/build.js index 055c2cf..e805d40 100644 --- a/status/build.js +++ b/status/build.js @@ -3,6 +3,12 @@ const path = require('path'); const crypto = require('crypto'); const esbuild = require('esbuild'); +// DC-119: single source of truth for the CRLF->LF normalization applied to +// every source read before minification (see the long comment in build()). +// Exported so tests/build-determinism.test.js pins THE ACTUAL regex, not a +// re-implementation that would silently drift if this one changes. +const normalizeSource = (s) => s.replace(/\r\n/g, '\n'); + const JS = (...parts) => path.join(__dirname, 'js', ...parts); const DIST = path.join(__dirname, 'dist'); const INDEX_HTML = path.join(__dirname, 'index.html'); @@ -149,7 +155,20 @@ async function build() { console.warn(` WARN: ${path.relative(__dirname, file)} not found, skipping`); continue; } - parts.push(fs.readFileSync(file, 'utf8')); + // DC-119: normalize CRLF -> LF before minifying. Root cause (verified + // empirically with esbuild 0.25.12 probes): the production transform + // uses sourcemap:'both', which base64-embeds the RAW source bytes as + // sourcesContent in the inline map — CR bytes survive into dist, so a + // CRLF working copy (Windows dev tree, core.autocrlf=true) and an LF + // checkout (DNS2) of the same commit produce different dist bytes and + // a different sw.js cache tag. With this normalization both are + // byte-identical (sha256-verified). Before it, every Linux rebuild of + // a Windows-committed bundle showed phantom drift on `git pull` in + // /opt/dashcaddy (the recurring "pre-pull drift" stashes — note those + // also contained minified-identifier renames, a second vector from + // esbuild version drift across the ^0.25.0 caret range, already + // pinned by package-lock.json). + parts.push(normalizeSource(fs.readFileSync(file, 'utf8'))); } const concatenated = parts.join(';\n'); @@ -197,7 +216,11 @@ async function build() { // the SW's activate handler wipes all older caches, so users never get // stuck on stale precached bundles after a release. function updateServiceWorkerCache() { - const sw = fs.readFileSync(SW_JS, 'utf8'); + // DC-119: normalize on read — same rationale as the bundle sources above. + // A CRLF sw.js would otherwise keep its CR bytes through the regex + // replace, so the written sw.js (and its committed bytes) would differ + // per-platform even with an identical cache tag. + const sw = normalizeSource(fs.readFileSync(SW_JS, 'utf8')); const hash = crypto.createHash('sha256'); for (const name of Object.keys(bundles)) { hash.update(fs.readFileSync(path.join(DIST, name))); @@ -214,20 +237,29 @@ function updateServiceWorkerCache() { } // Watch mode -if (process.argv.includes('--watch')) { - console.log(' Watching for changes...\n'); - build(); +// DC-119: only auto-run when invoked directly (`node build.js`). Requiring +// build.js as a module (as tests/build-determinism.test.js does, to pin the +// normalizeSource regex) must NOT trigger a full dist rebuild. +if (require.main === module) { + if (process.argv.includes('--watch')) { + console.log(' Watching for changes...\n'); + build(); - const jsDir = path.join(__dirname, 'js'); - let debounce = null; - fs.watch(jsDir, { recursive: true }, (event, filename) => { - if (!filename || !filename.endsWith('.js')) return; - clearTimeout(debounce); - debounce = setTimeout(() => { - console.log(` Changed: ${filename}`); - build(); - }, 200); - }); -} else { - build(); + const jsDir = path.join(__dirname, 'js'); + let debounce = null; + fs.watch(jsDir, { recursive: true }, (event, filename) => { + if (!filename || !filename.endsWith('.js')) return; + clearTimeout(debounce); + debounce = setTimeout(() => { + console.log(` Changed: ${filename}`); + build(); + }, 200); + }); + } else { + build(); + } } + +// DC-119: export for tests (normalizeSource is pinned by +// tests/build-determinism.test.js). build/bundles stay internal. +module.exports = { normalizeSource }; diff --git a/status/dist/features.js b/status/dist/features.js index 01cf46f..8a196ef 100644 --- a/status/dist/features.js +++ b/status/dist/features.js @@ -90,14 +90,14 @@ - `);const k=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),h=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),S=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),w=DC.NAME;let E=null,O=null,C=null,j="left",N=w;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",E=null,O=null):(P.style.display="flex",M.style.display="none",C=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{E=e,B.src=e,I.textContent="New dark logo ready to save"})}),h?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,I.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{C=e,B.src=e,A.src=e,I.textContent="New logo ready to save (both themes)"})});function u(t){S.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||w,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function x(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{E=null,O=null,C=null,D&&(D.value=""),h&&(h.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=f.src,A.src=H.src;const t=f.src.includes("custom-logo")||H.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,k.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||w,e={position:j,dashboardTitle:t};z?.checked&&C?(e.dataDark=C,e.dataLight=C):(E&&(e.dataDark=E),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(f.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),k.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? + `);const k=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),b=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),h=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),S=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),f=DC.NAME;let E=null,O=null,L=null,j="left",N=f;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",E=null,O=null):(P.style.display="flex",M.style.display="none",L=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{E=e,B.src=e,I.textContent="New dark logo ready to save"})}),b?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,I.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{L=e,B.src=e,A.src=e,I.textContent="New logo ready to save (both themes)"})});function u(t){S.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||f,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function w(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(h.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(h.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{E=null,O=null,L=null,D&&(D.value=""),b&&(b.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=h.src,A.src=H.src;const t=h.src.includes("custom-logo")||H.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,k.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||f,e={position:j,dashboardTitle:t};z?.checked&&L?(e.dataDark=L,e.dataLight=L):(E&&(e.dataDark=E),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(h.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),k.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? -This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",E=null,O=null,C=null,$.value=w,v(w),j="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(k,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),b=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function m(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),b.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,g.src=l,b.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),b.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),m(),x();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{k.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(k,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(k,B){const A=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";k.innerHTML="";for(const z of A){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===I&&(P.selected=!0),k.appendChild(P)}},(function(){let k="homelab",B=null;async function A(){try{const v=await fetch("/api/v1/config");if(v.ok&&(B=await v.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(v){console.warn("Could not fetch server config, checking localStorage fallback:",v.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function z(u){document.querySelectorAll(".setup-step").forEach(x=>{x.style.display="none"});const v=document.getElementById(u);v&&(v.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let v='${escapeHtml(A.network.name)}| When | ',t+='Level | ',t+='Context | ',t+='Message | ',t+='IP | ',t+="
|---|---|---|---|---|
| ${escapeHtml(i)} | `,t+=`${escapeHtml(a)} | `,t+=`${escapeHtml(n)} | `,t+=`${escapeHtml(r)} | `,t+=`${escapeHtml(d)} | `,t+="
| When | ',t+='Level | ',t+='Context | ',t+='Message | ',t+='IP | ',t+="
|---|---|---|---|---|
| ${escapeHtml(i)} | `,t+=`${escapeHtml(a)} | `,t+=`${escapeHtml(n)} | `,t+=`${escapeHtml(r)} | `,t+=`${escapeHtml(d)} | `,t+="
/var/log/journal + /usr/bin/journalctl mounted (start.sh)./var/log/journal + /usr/bin/journalctl mounted (start.sh).| Service | Status | ',d+='Uptime 24h | Uptime 7d | ',d+='Avg Response | Last Check |
|---|---|---|---|---|---|
| ${escapeHtml(y.name||y.serviceId)} | `,d+=`${L?"Up":"Down"} | `,d+=`${typeof q=="number"?q.toFixed(1)+"%":q} | `,d+=`${typeof F=="number"?F.toFixed(1)+"%":F} | `,d+=`${_} | `,d+=`${J} | `,d+="
| Service | Status | ',d+='Uptime 24h | Uptime 7d | ',d+='Avg Response | Last Check |
|---|---|---|---|---|---|
| ${escapeHtml(y.name||y.serviceId)} | `,d+=`${C?"Up":"Down"} | `,d+=`${typeof q=="number"?q.toFixed(1)+"%":q} | `,d+=`${typeof F=="number"?F.toFixed(1)+"%":F} | `,d+=`${_} | `,d+=`${J} | `,d+="
| Service | Type | Severity | Status | Duration | When |
|---|---|---|---|---|---|
| ${escapeHtml(q.serviceId)} | `,y+=`${escapeHtml(q.type)} | `,y+=`${m(q.severity)} | `,y+=`${q.status} | `,y+=`${_} | `,y+=`${timeAgo(q.createdAt)} | `,y+="
| Service | Status | SLA Target | Actions |
|---|---|---|---|
| ${escapeHtml(y.name||y.serviceId)} | `,d+=`${L?"Up":"Down"} | `,d+=`${y.sla?.target?y.sla.target+"%":"-"} | `,d+='',d+=``,d+=``,d+=" |
| Service | Type | Severity | Status | Duration | When |
|---|---|---|---|---|---|
| ${escapeHtml(q.serviceId)} | `,y+=`${escapeHtml(q.type)} | `,y+=`${m(q.severity)} | `,y+=`${q.status} | `,y+=`${_} | `,y+=`${timeAgo(q.createdAt)} | `,y+="
| Service | Status | SLA Target | Actions |
|---|---|---|---|
| ${escapeHtml(y.name||y.serviceId)} | `,d+=`${C?"Up":"Down"} | `,d+=`${y.sla?.target?y.sla.target+"%":"-"} | `,d+='',d+=``,d+=``,d+=" |
| Container | Image | Current | Latest | Actions |
|---|---|---|---|---|
| ${escapeHtml(n.containerName)} | `,a+=`${escapeHtml(n.imageName)} | `,a+=`${escapeHtml(n.currentDigest)} | `,a+=`${escapeHtml(n.latestDigest)} | `,a+='',a+=``,a+=``,a+=" |
| When | Container | Image | Duration | Status |
|---|---|---|---|---|
| ${timeAgo(o.timestamp)} | `,a+=`${escapeHtml(o.containerName)} | `,a+=`${escapeHtml(o.imageName)} | `,a+=`${n} | `,a+=`${i?"\u2713 success":"\u2717 failed"} | `,a+="
| ${escapeHtml(o.error)} | ||||
| Container | Schedule | Window | Rollback | Last Run | Actions | |||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ${escapeHtml(d)} | `,n+=`
+ `);const k=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function b(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML=' All containers are up to date. ',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='
Failed: ${escapeHtml(c.message)} `}}async function T(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textContent="\u2705 Done",showNotification(`Update all: ${e} succeeded, ${a} failed.`,e>0&&a===0?"success":"error"),setTimeout(()=>{t.textContent="\u2B06\uFE0F Update All",t.disabled=!1,b()},3e3)}document.getElementById("updates-update-all-btn")?.addEventListener("click",T);async function h(){I.textContent="\u{1F50D} Checking...",I.disabled=!0;try{const t=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!t.success)throw new Error(t.error);I.textContent="\u2705 Done!",await b()}catch(c){I.textContent="\u274C Failed",showNotification("Check error: "+c.message,"error")}setTimeout(()=>{I.textContent="\u{1F50D} Check for Updates",I.disabled=!1},3e3)}async function H(){try{P.innerHTML=' Loading... ';const t=await(await fetch("/api/v1/updates/history?limit=50")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){P.innerHTML='No update history yet. ';return}let a='
Failed: ${escapeHtml(c.message)} `}}async function S(){try{M.innerHTML=' Loading... ';const[c,t]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),e=await c.json(),a=await t.json(),o=e.success&&e.stats?e.stats:[],i=a.success&&a.config?a.config:{};if(o.length===0){M.innerHTML='No running containers found. ';return}let n='Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month. ';n+='
Failed: ${escapeHtml(c.message)} `}}const $=document.getElementById("dashcaddy-current-version"),w=document.getElementById("dashcaddy-update-badge"),E=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),j=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),R=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let x=null;function g(c,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function b(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(x=e,e.success&&e.available&&e.remote){w.style.display="",E.style.display="",O.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else w.style.display="none",E.style.display="none",await b(),c||g("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;j.textContent="Updating...",j.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),j.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw g("Update failed: "+c.message,"error"),j.textContent="Update Now",j.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){v.innerHTML='No self-update history. ';return}let a='
Failed: '+escapeHtml(c.message)+" "}}async function m(){try{const t=await(await fetch("/api/v1/system/rollback-versions")).json(),e=t.success&&t.versions?t.versions:[];if(e.length===0){showNotification("No rollback versions available.","info");return}const a=prompt(`Available rollback versions:
+ | `,n+=``,n+=` | `,n+=` | ${_} | `,n+=``,n+=" | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| When | Version | From | Status |
|---|---|---|---|
| '+timeAgo(o.timestamp)+" | ",a+='v'+escapeHtml(o.version)+(o.rollback?" (rollback)":"")+" | ",a+='v'+escapeHtml(o.fromVersion||"?")+" | ",a+=''+i+" | ",a+="
| '+escapeHtml(o.error)+" | |||
| '+escapeHtml(o.note)+" | |||
| Name | Driver | Scope | Actions |
|---|---|---|---|
| ${escapeHtml(H.driver)} | `,f+=`${escapeHtml(H.scope)} | `,f+='',S||(f+=``),f+=" |
| Name | Driver | Scope | Containers | Actions |
|---|---|---|---|---|
| ${escapeHtml(H.name)} | `,f+=`${escapeHtml(H.driver)} | `,f+=`${escapeHtml(H.scope)} | `,f+=`${H.containers} | `,f+='',S||(f+=``),f+=" |
| Name | Driver | Scope | Actions |
|---|---|---|---|
| ${escapeHtml(H.driver)} | `,h+=`${escapeHtml(H.scope)} | `,h+='',S||(h+=``),h+=" |
| Name | Driver | Scope | Containers | Actions |
|---|---|---|---|---|
| ${escapeHtml(H.name)} | `,h+=`${escapeHtml(H.driver)} | `,h+=`${escapeHtml(H.scope)} | `,h+=`${H.containers} | `,h+='',S||(h+=``),h+=" |
${escapeHtml(T)}`).join(", ")}${escapeHtml(T)}`).join(", ")}${escapeHtml(T.image)}${escapeHtml(f.subdomain)}`),f.reason&&(T+=` (${escapeHtml(f.reason)})`),T+="${escapeHtml(T)}`).join(", ")}${escapeHtml(T)}`).join(", ")}${escapeHtml(T.image)}${escapeHtml(h.subdomain)}`),h.reason&&(T+=` (${escapeHtml(h.reason)})`),T+="| When | ',m+='Actor | ',m+='IP | ',m+='Action | ',m+='Resource | ',m+='Result | ',m+="
|---|---|---|---|---|---|
| ${timeAgo(c.timestamp)} | `,m+=`${e} | `,m+=`${escapeHtml(c.ip||"-")} | `,m+=`${escapeHtml(c.action||"-")} | `,m+=`${escapeHtml(c.resource||"-")} | `,m+=`${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")} | `,m+="
| When | ',m+='Actor | ',m+='IP | ',m+='Action | ',m+='Resource | ',m+='Result | ',m+="
|---|---|---|---|---|---|
| ${timeAgo(c.timestamp)} | `,m+=`${e} | `,m+=`${escapeHtml(c.ip||"-")} | `,m+=`${escapeHtml(c.action||"-")} | `,m+=`${escapeHtml(c.resource||"-")} | `,m+=`${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")} | `,m+="
| ${g(String(m.key))} | ${m.count} |
| ${g(String(m.key))} | ${m.count} |