Files
astrolabe-nvc/src/js/user-settings.js

388 lines
14 KiB
JavaScript

// 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
},
ui: {
theme: 'light', // 'light' | 'experimental'
previewFitMode: 'default' // 'default' | 'width' | 'full'
},
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')
// Simplified: assumes 2-level structure (section.key)
function getSetting(path) {
const settings = getSettings();
const [section, key] = path.split('.');
return settings?.[section]?.[key];
}
// Update a specific setting by path
// Simplified: assumes 2-level structure (section.key)
function updateSetting(path, value) {
const settings = getSettings();
const [section, key] = path.split('.');
if (settings[section]) {
settings[section][key] = value;
return saveSettings();
}
return false;
}
// Update multiple settings at once
function updateSettings(updates) {
const settings = getSettings();
for (const path in updates) {
const [section, key] = path.split('.');
if (settings[section]) {
settings[section][key] = updates[path];
}
}
return saveSettings();
}
// Reset all settings to defaults
function resetSettings() {
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
return saveSettings();
}
// 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 - simplified with inline validation rules
function validateSetting(path, value) {
const rules = {
'editor.fontSize': () => typeof value === 'number' && value >= 10 && value <= 18 ? [] : ['Font size must be between 10 and 18'],
'editor.theme': () => ['vs-light', 'vs-dark', 'hc-black'].includes(value) ? [] : ['Invalid editor theme'],
'editor.tabSize': () => typeof value === 'number' && value >= 2 && value <= 8 ? [] : ['Tab size must be between 2 and 8'],
'performance.renderDebounce': () => typeof value === 'number' && value >= 300 && value <= 3000 ? [] : ['Render debounce must be between 300-3000ms'],
'formatting.dateFormat': () => ['smart', 'locale', 'iso', 'custom'].includes(value) ? [] : ['Invalid date format'],
'ui.theme': () => ['light', 'experimental'].includes(value) ? [] : ['Invalid UI theme']
};
return rules[path] ? rules[path]() : [];
}
// Alpine.js Component for settings panel
// Thin wrapper - Alpine handles form state and reactivity, user-settings.js handles storage
function settingsPanel() {
return {
// Form state (loaded from settings on open)
uiTheme: 'light',
fontSize: 12,
editorTheme: 'vs-light',
tabSize: 2,
minimap: false,
wordWrap: true,
lineNumbers: true,
renderDebounce: 1500,
dateFormat: 'smart',
customDateFormat: 'yyyy-MM-dd HH:mm',
// Original values for dirty checking
originalSettings: null,
// Initialize component with current settings
init() {
this.loadSettings();
},
// Load settings from storage into form
loadSettings() {
const settings = getSettings();
this.uiTheme = settings.ui.theme;
this.fontSize = settings.editor.fontSize;
this.editorTheme = settings.editor.theme;
this.tabSize = settings.editor.tabSize;
this.minimap = settings.editor.minimap;
this.wordWrap = settings.editor.wordWrap === 'on';
this.lineNumbers = settings.editor.lineNumbers === 'on';
this.renderDebounce = settings.performance.renderDebounce;
this.dateFormat = settings.formatting.dateFormat;
this.customDateFormat = settings.formatting.customDateFormat;
// Store original values for dirty checking
this.originalSettings = JSON.stringify(this.getCurrentFormState());
},
// Get current form state as object
getCurrentFormState() {
return {
uiTheme: this.uiTheme,
fontSize: this.fontSize,
editorTheme: this.editorTheme,
tabSize: this.tabSize,
minimap: this.minimap,
wordWrap: this.wordWrap,
lineNumbers: this.lineNumbers,
renderDebounce: this.renderDebounce,
dateFormat: this.dateFormat,
customDateFormat: this.customDateFormat
};
},
// Check if settings have been modified
get isDirty() {
return this.originalSettings !== JSON.stringify(this.getCurrentFormState());
},
// Show custom date format field when 'custom' is selected
get showCustomDateFormat() {
return this.dateFormat === 'custom';
},
// Apply settings and save
apply() {
const newSettings = {
'ui.theme': this.uiTheme,
'editor.fontSize': parseInt(this.fontSize),
'editor.theme': this.editorTheme,
'editor.tabSize': parseInt(this.tabSize),
'editor.minimap': this.minimap,
'editor.wordWrap': this.wordWrap ? 'on' : 'off',
'editor.lineNumbers': this.lineNumbers ? 'on' : 'off',
'performance.renderDebounce': parseInt(this.renderDebounce),
'formatting.dateFormat': this.dateFormat,
'formatting.customDateFormat': this.customDateFormat
};
// 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 theme to document
document.documentElement.setAttribute('data-theme', this.uiTheme);
// Sync editor theme with UI theme
const editorTheme = this.uiTheme === 'experimental' ? 'vs-dark' : 'vs-light';
newSettings['editor.theme'] = editorTheme;
// Apply editor settings immediately
if (editor) {
editor.updateOptions({
fontSize: newSettings['editor.fontSize'],
theme: editorTheme,
tabSize: newSettings['editor.tabSize'],
minimap: { enabled: newSettings['editor.minimap'] },
wordWrap: newSettings['editor.wordWrap'],
lineNumbers: newSettings['editor.lineNumbers']
});
}
// Update the editor theme in settings
updateSetting('editor.theme', editorTheme);
// 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 (Alpine.store('snippets').currentSnippetId) {
const snippet = SnippetStorage.getSnippet(Alpine.store('snippets').currentSnippetId);
if (snippet) {
document.getElementById('snippet-created').textContent = formatDate(snippet.created, true);
document.getElementById('snippet-modified').textContent = formatDate(snippet.modified, true);
}
}
Toast.success('Settings applied successfully');
closeSettingsModal();
// Track event
Analytics.track('settings-apply', 'Applied settings');
} else {
Toast.error('Failed to save settings');
}
},
// Reset to defaults
reset() {
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
resetSettings();
this.loadSettings();
Toast.success('Settings reset to defaults');
}
},
// Cancel changes and close modal
cancel() {
closeSettingsModal();
}
};
}