From e2b4b77478a5dc27d8080f49819486b706e01a41 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 17 Oct 2025 00:45:03 +0300 Subject: [PATCH] feat: implement user settings modal with customizable editor preferences and performance settings --- index.html | 123 ++++++++++++++++ src/js/app.js | 266 ++++++++++++++++++++++++++++++++++- src/js/editor.js | 9 +- src/js/snippet-manager.js | 22 +-- src/js/user-settings.js | 288 ++++++++++++++++++++++++++++++++++++++ src/styles.css | 33 +++++ 6 files changed, 718 insertions(+), 23 deletions(-) create mode 100644 src/js/user-settings.js diff --git a/index.html b/index.html index 389a189..d14c4cc 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ Import Export Datasets + Settings Help @@ -401,6 +402,10 @@ Cmd/Ctrl+K Toggle dataset manager + + Cmd/Ctrl+, + Open settings + Cmd/Ctrl+S Publish draft (save) @@ -553,9 +558,127 @@ + + +
+ diff --git a/src/js/app.js b/src/js/app.js index 36f05d3..22b1fc5 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,6 +1,9 @@ // Application initialization and event handlers document.addEventListener('DOMContentLoaded', function () { + // Initialize user settings + initSettings(); + // Initialize snippet storage and render list initializeSnippetsStorage(); @@ -59,16 +62,28 @@ document.addEventListener('DOMContentLoaded', function () { // Load Vega libraries before creating editor await loadVegaLibraries(); + // Get user settings for editor configuration + const editorSettings = getSetting('editor') || { + fontSize: 12, + theme: 'vs-light', + minimap: false, + wordWrap: 'on', + lineNumbers: 'on', + tabSize: 2 + }; + // Create the editor editor = monaco.editor.create(document.getElementById('monaco-editor'), { value: JSON.stringify(sampleSpec, null, 2), language: 'json', - theme: 'vs-light', - fontSize: 12, - minimap: { enabled: false }, + theme: editorSettings.theme, + fontSize: editorSettings.fontSize, + minimap: { enabled: editorSettings.minimap }, scrollBeyondLastLine: false, automaticLayout: true, - wordWrap: 'on', + wordWrap: editorSettings.wordWrap, + lineNumbers: editorSettings.lineNumbers, + tabSize: editorSettings.tabSize, formatOnPaste: true, formatOnType: true }); @@ -135,6 +150,80 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Settings Modal + const settingsLink = document.getElementById('settings-link'); + const settingsModal = document.getElementById('settings-modal'); + const settingsModalClose = document.getElementById('settings-modal-close'); + const settingsApplyBtn = document.getElementById('settings-apply-btn'); + const settingsResetBtn = document.getElementById('settings-reset-btn'); + const settingsCancelBtn = document.getElementById('settings-cancel-btn'); + + if (settingsLink) { + settingsLink.addEventListener('click', function () { + openSettingsModal(); + }); + } + + if (settingsModalClose) { + settingsModalClose.addEventListener('click', closeSettingsModal); + } + + if (settingsCancelBtn) { + settingsCancelBtn.addEventListener('click', closeSettingsModal); + } + + if (settingsApplyBtn) { + settingsApplyBtn.addEventListener('click', applySettings); + } + + if (settingsResetBtn) { + settingsResetBtn.addEventListener('click', function() { + if (confirm('Reset all settings to defaults? This cannot be undone.')) { + resetSettings(); + loadSettingsIntoUI(); + Toast.show('Settings reset to defaults', 'success'); + } + }); + } + + // Close on overlay click + if (settingsModal) { + settingsModal.addEventListener('click', function (e) { + if (e.target === settingsModal) { + closeSettingsModal(); + } + }); + } + + // Settings UI interactions + const fontSizeSlider = document.getElementById('setting-font-size'); + const fontSizeValue = document.getElementById('setting-font-size-value'); + if (fontSizeSlider && fontSizeValue) { + fontSizeSlider.addEventListener('input', function() { + fontSizeValue.textContent = this.value + 'px'; + }); + } + + const renderDebounceSlider = document.getElementById('setting-render-debounce'); + const renderDebounceValue = document.getElementById('setting-render-debounce-value'); + if (renderDebounceSlider && renderDebounceValue) { + renderDebounceSlider.addEventListener('input', function() { + renderDebounceValue.textContent = this.value + 'ms'; + }); + } + + const dateFormatSelect = document.getElementById('setting-date-format'); + const customDateFormatItem = document.getElementById('custom-date-format-item'); + if (dateFormatSelect && customDateFormatItem) { + dateFormatSelect.addEventListener('change', function() { + if (this.value === 'custom') { + customDateFormatItem.style.display = 'block'; + } else { + customDateFormatItem.style.display = 'none'; + } + }); + } + // Dataset Manager const datasetsLink = document.getElementById('datasets-link'); const toggleDatasetsBtn = document.getElementById('toggle-datasets'); @@ -384,11 +473,21 @@ const KeyboardActions = { } }, + toggleSettings: function() { + const modal = document.getElementById('settings-modal'); + if (modal && modal.style.display === 'flex') { + closeSettingsModal(); + } else { + openSettingsModal(); + } + }, + closeAnyModal: function() { const helpModal = document.getElementById('help-modal'); const datasetModal = document.getElementById('dataset-modal'); const extractModal = document.getElementById('extract-modal'); const donateModal = document.getElementById('donate-modal'); + const settingsModal = document.getElementById('settings-modal'); if (helpModal && helpModal.style.display === 'flex') { closeHelpModal(); @@ -406,6 +505,10 @@ const KeyboardActions = { closeDonateModal(); return true; } + if (settingsModal && settingsModal.style.display === 'flex') { + closeSettingsModal(); + return true; + } return false; } }; @@ -437,6 +540,13 @@ function initializeKeyboardShortcuts() { return; } + // Cmd/Ctrl+,: Toggle settings + if (modifierKey && e.key === ',') { + e.preventDefault(); + KeyboardActions.toggleSettings(); + return; + } + // Cmd/Ctrl+S: Publish draft if (modifierKey && e.key.toLowerCase() === 's') { e.preventDefault(); @@ -496,3 +606,151 @@ function closeDonateModal() { modal.style.display = 'none'; } } + +// Settings modal functions +function openSettingsModal() { + loadSettingsIntoUI(); + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.style.display = 'flex'; + // Track event + Analytics.track('modal-settings', 'Open Settings modal'); + } +} + +function closeSettingsModal() { + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +function loadSettingsIntoUI() { + const settings = getSettings(); + + // Editor settings + const fontSizeSlider = document.getElementById('setting-font-size'); + const fontSizeValue = document.getElementById('setting-font-size-value'); + if (fontSizeSlider && fontSizeValue) { + fontSizeSlider.value = settings.editor.fontSize; + fontSizeValue.textContent = settings.editor.fontSize + 'px'; + } + + const themeSelect = document.getElementById('setting-theme'); + if (themeSelect) { + themeSelect.value = settings.editor.theme; + } + + const tabSizeSelect = document.getElementById('setting-tab-size'); + if (tabSizeSelect) { + tabSizeSelect.value = settings.editor.tabSize; + } + + const minimapCheckbox = document.getElementById('setting-minimap'); + if (minimapCheckbox) { + minimapCheckbox.checked = settings.editor.minimap; + } + + const wordWrapCheckbox = document.getElementById('setting-word-wrap'); + if (wordWrapCheckbox) { + wordWrapCheckbox.checked = settings.editor.wordWrap === 'on'; + } + + const lineNumbersCheckbox = document.getElementById('setting-line-numbers'); + if (lineNumbersCheckbox) { + lineNumbersCheckbox.checked = settings.editor.lineNumbers === 'on'; + } + + // Performance settings + const renderDebounceSlider = document.getElementById('setting-render-debounce'); + const renderDebounceValue = document.getElementById('setting-render-debounce-value'); + if (renderDebounceSlider && renderDebounceValue) { + renderDebounceSlider.value = settings.performance.renderDebounce; + renderDebounceValue.textContent = settings.performance.renderDebounce + 'ms'; + } + + // Formatting settings + const dateFormatSelect = document.getElementById('setting-date-format'); + const customDateFormatItem = document.getElementById('custom-date-format-item'); + if (dateFormatSelect) { + dateFormatSelect.value = settings.formatting.dateFormat; + if (customDateFormatItem) { + customDateFormatItem.style.display = settings.formatting.dateFormat === 'custom' ? 'block' : 'none'; + } + } + + const customDateFormatInput = document.getElementById('setting-custom-date-format'); + if (customDateFormatInput) { + customDateFormatInput.value = settings.formatting.customDateFormat; + } +} + +function applySettings() { + // Collect values from UI + const newSettings = { + 'editor.fontSize': parseInt(document.getElementById('setting-font-size').value), + 'editor.theme': document.getElementById('setting-theme').value, + 'editor.tabSize': parseInt(document.getElementById('setting-tab-size').value), + 'editor.minimap': document.getElementById('setting-minimap').checked, + 'editor.wordWrap': document.getElementById('setting-word-wrap').checked ? 'on' : 'off', + 'editor.lineNumbers': document.getElementById('setting-line-numbers').checked ? 'on' : 'off', + 'performance.renderDebounce': parseInt(document.getElementById('setting-render-debounce').value), + 'formatting.dateFormat': document.getElementById('setting-date-format').value, + 'formatting.customDateFormat': document.getElementById('setting-custom-date-format').value + }; + + // Validate settings + let hasErrors = false; + for (const [path, value] of Object.entries(newSettings)) { + const errors = validateSetting(path, value); + if (errors.length > 0) { + Toast.show(errors.join(', '), 'error'); + hasErrors = true; + break; + } + } + + if (hasErrors) { + return; + } + + // Save settings + if (updateSettings(newSettings)) { + // Apply editor settings immediately + if (editor) { + editor.updateOptions({ + fontSize: newSettings['editor.fontSize'], + theme: newSettings['editor.theme'], + tabSize: newSettings['editor.tabSize'], + minimap: { enabled: newSettings['editor.minimap'] }, + wordWrap: newSettings['editor.wordWrap'], + lineNumbers: newSettings['editor.lineNumbers'] + }); + } + + // Update debounced render function + if (typeof updateRenderDebounce === 'function') { + updateRenderDebounce(newSettings['performance.renderDebounce']); + } + + // Re-render snippet list to reflect date format changes + renderSnippetList(); + + // Update metadata display if a snippet is selected + if (window.currentSnippetId) { + const snippet = SnippetStorage.getSnippet(window.currentSnippetId); + if (snippet) { + document.getElementById('snippet-created').textContent = formatDate(snippet.created, true); + document.getElementById('snippet-modified').textContent = formatDate(snippet.modified, true); + } + } + + Toast.show('Settings applied successfully', 'success'); + closeSettingsModal(); + + // Track event + Analytics.track('settings-apply', 'Applied settings'); + } else { + Toast.show('Failed to save settings', 'error'); + } +} diff --git a/src/js/editor.js b/src/js/editor.js index a61b308..999fe15 100644 --- a/src/js/editor.js +++ b/src/js/editor.js @@ -111,7 +111,14 @@ function debouncedRender() { } clearTimeout(renderTimeout); - renderTimeout = setTimeout(renderVisualization, 1500); + const debounceTime = getSetting('performance.renderDebounce') || 1500; + renderTimeout = setTimeout(renderVisualization, debounceTime); +} + +// Update render debounce setting (called when settings are changed) +function updateRenderDebounce(newDebounce) { + // The next render will automatically use the new debounce time + // No need to do anything special here } // Load Vega libraries dynamically with UMD builds diff --git a/src/js/snippet-manager.js b/src/js/snippet-manager.js index 339b78c..4ec41ba 100644 --- a/src/js/snippet-manager.js +++ b/src/js/snippet-manager.js @@ -281,28 +281,14 @@ function initializeSnippetsStorage() { return existingSnippets; } -// Format date for display in snippet list +// Format date for display in snippet list (delegates to user-settings.js) function formatSnippetDate(isoString) { - const date = new Date(isoString); - const diffDays = Math.floor((new Date() - date) / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return `${diffDays} days ago`; - return date.toLocaleDateString(); + return formatDate(isoString, false); } -// Format full date/time for display in meta info +// Format full date/time for display in meta info (delegates to user-settings.js) function formatFullDate(isoString) { - const date = new Date(isoString); - return date.toLocaleString([], { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); + return formatDate(isoString, true); } // Render snippet list in the UI diff --git a/src/js/user-settings.js b/src/js/user-settings.js new file mode 100644 index 0000000..08eec60 --- /dev/null +++ b/src/js/user-settings.js @@ -0,0 +1,288 @@ +// user-settings.js - User preferences and configuration management + +const SETTINGS_STORAGE_KEY = 'astrolabe-user-settings'; + +// Default settings configuration +const DEFAULT_SETTINGS = { + version: 1, + + editor: { + fontSize: 12, // 10-18px + theme: 'vs-light', // 'vs-light' | 'vs-dark' | 'hc-black' + minimap: false, // true | false + wordWrap: 'on', // 'on' | 'off' + lineNumbers: 'on', // 'on' | 'off' + tabSize: 2 // 2-8 spaces + }, + + performance: { + renderDebounce: 1500 // 300-3000ms - delay before visualization renders + }, + + formatting: { + dateFormat: 'smart', // 'smart' | 'locale' | 'iso' | 'custom' + customDateFormat: 'yyyy-MM-dd HH:mm' // Used when dateFormat === 'custom' + } +}; + +// Current user settings (loaded from localStorage or defaults) +let userSettings = null; + +// Initialize settings - load from localStorage or use defaults +function initSettings() { + try { + const stored = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Merge with defaults to handle version upgrades + userSettings = mergeWithDefaults(parsed); + } else { + userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + } + } catch (error) { + console.error('Error loading user settings:', error); + userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + } + return userSettings; +} + +// Merge stored settings with defaults (handles new settings in updates) +function mergeWithDefaults(stored) { + const merged = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + + // Merge each section + for (const section in stored) { + if (merged[section] && typeof merged[section] === 'object') { + Object.assign(merged[section], stored[section]); + } else { + merged[section] = stored[section]; + } + } + + return merged; +} + +// Save settings to localStorage +function saveSettings() { + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(userSettings)); + return true; + } catch (error) { + console.error('Error saving user settings:', error); + return false; + } +} + +// Get all current settings +function getSettings() { + if (!userSettings) { + initSettings(); + } + return userSettings; +} + +// Get a specific setting by path (e.g., 'editor.fontSize') +function getSetting(path) { + const settings = getSettings(); + const parts = path.split('.'); + let value = settings; + + for (const part of parts) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + return undefined; + } + } + + return value; +} + +// Update a specific setting by path +function updateSetting(path, value) { + const settings = getSettings(); + const parts = path.split('.'); + let target = settings; + + // Navigate to the parent object + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!target[part] || typeof target[part] !== 'object') { + target[part] = {}; + } + target = target[part]; + } + + // Set the value + const lastPart = parts[parts.length - 1]; + target[lastPart] = value; + + return saveSettings(); +} + +// Update multiple settings at once +function updateSettings(updates) { + const settings = getSettings(); + + for (const path in updates) { + const parts = path.split('.'); + let target = settings; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!target[part] || typeof target[part] !== 'object') { + target[part] = {}; + } + target = target[part]; + } + + const lastPart = parts[parts.length - 1]; + target[lastPart] = updates[path]; + } + + return saveSettings(); +} + +// Reset all settings to defaults +function resetSettings() { + userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + return saveSettings(); +} + +// Export settings as JSON +function exportSettings() { + return JSON.stringify(getSettings(), null, 2); +} + +// Import settings from JSON string +function importSettings(jsonString) { + try { + const imported = JSON.parse(jsonString); + userSettings = mergeWithDefaults(imported); + return saveSettings(); + } catch (error) { + console.error('Error importing settings:', error); + return false; + } +} + +// Format date according to user settings +function formatDate(isoString, useFullFormat = false) { + const date = new Date(isoString); + const format = getSetting('formatting.dateFormat'); + + if (!useFullFormat && format === 'smart') { + // Smart format: relative for recent dates + const diffMs = new Date() - date; + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString(); + } + + if (format === 'locale') { + return useFullFormat + ? date.toLocaleString([], { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + : date.toLocaleDateString(); + } + + if (format === 'iso') { + return useFullFormat + ? date.toISOString() + : date.toISOString().split('T')[0]; + } + + if (format === 'custom') { + const customFormat = getSetting('formatting.customDateFormat'); + return formatCustomDate(date, customFormat); + } + + // Fallback to locale + return date.toLocaleDateString(); +} + +// Format date using custom format tokens +function formatCustomDate(date, format) { + const pad = (n, width = 2) => String(n).padStart(width, '0'); + + const tokens = { + 'yyyy': date.getFullYear(), + 'yy': String(date.getFullYear()).slice(-2), + 'MM': pad(date.getMonth() + 1), + 'M': date.getMonth() + 1, + 'dd': pad(date.getDate()), + 'd': date.getDate(), + 'HH': pad(date.getHours()), + 'H': date.getHours(), + 'hh': pad(date.getHours() % 12 || 12), + 'h': date.getHours() % 12 || 12, + 'mm': pad(date.getMinutes()), + 'm': date.getMinutes(), + 'ss': pad(date.getSeconds()), + 's': date.getSeconds(), + 'a': date.getHours() < 12 ? 'am' : 'pm', + 'A': date.getHours() < 12 ? 'AM' : 'PM' + }; + + let result = format; + // Sort by length descending to replace longer tokens first + const sortedTokens = Object.keys(tokens).sort((a, b) => b.length - a.length); + for (const token of sortedTokens) { + result = result.replace(new RegExp(token, 'g'), tokens[token]); + } + + return result; +} + +// Validate setting value +function validateSetting(path, value) { + const errors = []; + + if (path === 'editor.fontSize') { + if (typeof value !== 'number' || value < 10 || value > 18) { + errors.push('Font size must be between 10 and 18'); + } + } + + if (path === 'editor.theme') { + const validThemes = ['vs-light', 'vs-dark', 'hc-black']; + if (!validThemes.includes(value)) { + errors.push('Invalid theme value'); + } + } + + if (path === 'editor.tabSize') { + if (typeof value !== 'number' || value < 2 || value > 8) { + errors.push('Tab size must be between 2 and 8'); + } + } + + if (path === 'performance.renderDebounce') { + if (typeof value !== 'number' || value < 300 || value > 3000) { + errors.push('Render debounce must be between 300 and 3000ms'); + } + } + + if (path === 'formatting.dateFormat') { + const validFormats = ['smart', 'locale', 'iso', 'custom']; + if (!validFormats.includes(value)) { + errors.push('Invalid date format value'); + } + } + + return errors; +} diff --git a/src/styles.css b/src/styles.css index 6498e83..874e546 100644 --- a/src/styles.css +++ b/src/styles.css @@ -327,6 +327,39 @@ body { font-family: var(--font-main); height: 100vh; overflow: hidden; backgroun .donate-two-column { display: flex; gap: 20px; margin-bottom: 24px; } .donate-two-column .help-section { flex: 1; margin-bottom: 0; } +/* Settings Modal */ +.settings-content { padding: 20px; } +.settings-section { margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--win-gray-dark); } +.settings-section:last-of-type { border-bottom: none; padding-bottom: 0; } +.settings-heading { font-size: 14px; font-weight: bold; margin: 0 0 16px 0; color: var(--win-blue-dark); } + +.settings-item { margin-bottom: 16px; } +.settings-item:last-child { margin-bottom: 0; } + +.settings-label { display: block; font-size: 12px; font-weight: 500; color: #333; margin-bottom: 6px; } +.settings-label input[type="checkbox"] { margin-right: 6px; vertical-align: middle; } + +.settings-control { display: flex; align-items: center; gap: 12px; } + +.settings-slider { flex: 1; height: 4px; background: var(--win-gray-dark); outline: none; border-radius: 2px; -webkit-appearance: none; } +.settings-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: var(--win-blue); border: 2px solid var(--win-blue-dark); border-radius: 50%; cursor: pointer; } +.settings-slider::-moz-range-thumb { width: 16px; height: 16px; background: var(--win-blue); border: 2px solid var(--win-blue-dark); border-radius: 50%; cursor: pointer; } + +.settings-value { font-size: 12px; font-family: var(--font-mono); color: var(--win-gray-darker); min-width: 60px; text-align: right; } + +.settings-select { flex: 1; padding: 4px 8px; font-size: 12px; font-family: var(--font-mono); background: var(--bg-white); border: 2px inset var(--win-gray); color: #000; } +.settings-select:focus { outline: 1px dotted #000; } + +.settings-input { width: 100%; padding: 4px 8px; font-size: 12px; font-family: var(--font-mono); background: var(--bg-white); border: 2px inset var(--win-gray); color: #000; } +.settings-input:focus { outline: 1px dotted #000; } + +.settings-checkbox { width: 14px; height: 14px; cursor: pointer; } + +.settings-hint { font-size: 11px; color: var(--win-gray-darker); margin-top: 4px; line-height: 1.4; } + +.settings-actions { display: flex; gap: 8px; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--win-gray-dark); } +.settings-actions .btn-modal { flex: 1; } + /* Toast Notifications */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column-reverse; gap: 10px; max-width: 400px; } .toast { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--win-gray); border: 2px outset var(--win-gray); box-shadow: 4px 4px 8px rgba(0,0,0,0.3); font-size: 12px; min-width: 300px; opacity: 0; transform: translateX(400px); transition: all 0.3s ease; }