diff --git a/src/lib/components/BackupPanel.svelte b/src/lib/components/BackupPanel.svelte new file mode 100644 index 0000000..96eb494 --- /dev/null +++ b/src/lib/components/BackupPanel.svelte @@ -0,0 +1,211 @@ + + +
+

Backup & Restore

+ +
+

+ Create a complete backup of all your MUD client data, including profiles, triggers, and settings. + You can use this backup to restore your configuration on another device or browser. +

+ + {#if backupStats} +
+ Current data: +
    +
  • {backupStats.profileCount} profile{backupStats.profileCount !== 1 ? 's' : ''}
  • +
  • {backupStats.triggerCount} trigger{backupStats.triggerCount !== 1 ? 's' : ''}
  • +
  • Settings: {backupStats.hasSettings ? 'Yes' : 'No'}
  • +
+
+ {/if} + + {#if importError} +
+ {importError} +
+ {/if} + + {#if importSuccess} +
+ {importSuccess} +
+ {/if} +
+ +
+ + + +
+
+ + diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte new file mode 100644 index 0000000..a981563 --- /dev/null +++ b/src/lib/components/SettingsPanel.svelte @@ -0,0 +1,371 @@ + + +
+
+
+ Dark Mode + +
+ +
+ Show Timestamps + +
+ +
+ ANSI Colors + +
+ +
+ Font + +
+ +
+ Global Sound Volume +
+ { + // Debounce volume changes to avoid performance issues with rapid changes + if (window.volumeDebounceTimeout) { + clearTimeout(window.volumeDebounceTimeout); + } + + // Read value directly from the input + const newVolume = parseFloat(e.target.value); + + // Update the store with a slight delay to avoid excessive updates + window.volumeDebounceTimeout = setTimeout(() => { + uiSettings.update(settings => ({ + ...settings, + globalVolume: newVolume + })); + }, 100); + }} + bind:value={$uiSettings.globalVolume} + > + {($uiSettings.globalVolume * 100).toFixed(0)}% +
+
+ +

Debugging

+ +
+ Show GMCP Messages + +
+ Shows GMCP protocol messages in the output window for debugging +
+
+ +

Accessibility

+ +
+ Text-to-Speech + +
+ +
+ High Contrast + +
+ +
+ Font Size +
+ + {$accessibilitySettings.fontSize}px +
+
+ + {#if $accessibilitySettings.textToSpeech} +
+ Interrupt Speech on Enter + +
+ Automatically stop speaking when the Enter key is pressed +
+
+ +
+ Speech Rate +
+ + {$accessibilitySettings.speechRate.toFixed(1)} +
+
+ +
+ Speech Pitch +
+ + {$accessibilitySettings.speechPitch.toFixed(1)} +
+
+ +
+ Speech Volume +
+ + {($accessibilitySettings.speechVolume * 100).toFixed(0)}% +
+
+ {/if} + +

Settings Management

+ +
+ + + +
+ + +
+
+ + diff --git a/src/lib/settings/SettingsManager.ts b/src/lib/settings/SettingsManager.ts new file mode 100644 index 0000000..1651552 --- /dev/null +++ b/src/lib/settings/SettingsManager.ts @@ -0,0 +1,251 @@ +import { EventEmitter } from '$lib/utils/EventEmitter'; +import { get } from 'svelte/store'; +import { accessibilitySettings, uiSettings } from '$lib/stores/mudStore'; + +export interface Settings { + accessibility: { + textToSpeech: boolean; + highContrast: boolean; + fontSize: number; + lineSpacing: number; + speechRate: number; + speechPitch: number; + speechVolume: number; + interruptSpeechOnEnter: boolean; + }; + ui: { + isDarkMode: boolean; + showTimestamps: boolean; + showSidebar: boolean; + splitViewDirection: 'horizontal' | 'vertical'; + inputHistorySize: number; + outputBufferSize: number; + ansiColor: boolean; + font: string; + debugGmcp: boolean; + globalVolume: number; + }; +} + +export class SettingsManager extends EventEmitter { + private settings: Settings; + private readonly STORAGE_KEY = 'svelte-mud-settings'; + + constructor() { + super(); + + // Initialize with default settings + this.settings = { + accessibility: { + textToSpeech: false, + highContrast: false, + fontSize: 16, + lineSpacing: 1.2, + speechRate: 1, + speechPitch: 1, + speechVolume: 1, + interruptSpeechOnEnter: true + }, + ui: { + isDarkMode: true, + showTimestamps: true, + showSidebar: true, + splitViewDirection: 'horizontal', + inputHistorySize: 100, + outputBufferSize: 1000, + ansiColor: true, + font: 'monospace', + debugGmcp: false, + globalVolume: 0.7 + } + }; + + // Load settings from storage + this.loadSettings(); + } + + /** + * Load settings from localStorage + */ + private loadSettings(): void { + if (typeof window === 'undefined') { + return; // Skip during SSR + } + + try { + const storedSettings = localStorage.getItem(this.STORAGE_KEY); + + if (storedSettings) { + const parsedSettings = JSON.parse(storedSettings); + + // Merge with defaults to ensure all properties exist + this.settings = { + accessibility: { + ...this.settings.accessibility, + ...parsedSettings.accessibility + }, + ui: { + ...this.settings.ui, + ...parsedSettings.ui + } + }; + + console.log('Loaded settings from localStorage:', this.settings); + + // Update Svelte stores with loaded settings + accessibilitySettings.set(this.settings.accessibility); + uiSettings.set(this.settings.ui); + + this.emit('settingsLoaded', this.settings); + } else { + console.log('No settings found in localStorage, using defaults'); + // Update Svelte stores with default settings + accessibilitySettings.set(this.settings.accessibility); + uiSettings.set(this.settings.ui); + } + } catch (error) { + console.error('Failed to load settings from localStorage:', error); + } + } + + /** + * Save settings to localStorage + */ + public saveSettings(): void { + if (typeof window === 'undefined') { + return; // Skip during SSR + } + + try { + // Get current values from stores + this.settings.accessibility = get(accessibilitySettings); + this.settings.ui = get(uiSettings); + + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.settings)); + console.log('Saved settings to localStorage:', this.settings); + + this.emit('settingsSaved', this.settings); + } catch (error) { + console.error('Failed to save settings to localStorage:', error); + } + } + + /** + * Update accessibility settings + */ + public updateAccessibilitySettings(newSettings: Partial): void { + accessibilitySettings.update(current => { + const updated = { ...current, ...newSettings }; + this.settings.accessibility = updated; + this.saveSettings(); + return updated; + }); + + this.emit('accessibilitySettingsUpdated', get(accessibilitySettings)); + } + + /** + * Update UI settings + */ + public updateUiSettings(newSettings: Partial): void { + uiSettings.update(current => { + const updated = { ...current, ...newSettings }; + this.settings.ui = updated; + this.saveSettings(); + return updated; + }); + + this.emit('uiSettingsUpdated', get(uiSettings)); + } + + /** + * Reset settings to defaults + */ + public resetSettings(): void { + // Create default settings + const defaultSettings: Settings = { + accessibility: { + textToSpeech: false, + highContrast: false, + fontSize: 16, + lineSpacing: 1.2, + speechRate: 1, + speechPitch: 1, + speechVolume: 1, + interruptSpeechOnEnter: true + }, + ui: { + isDarkMode: true, + showTimestamps: true, + showSidebar: true, + splitViewDirection: 'horizontal', + inputHistorySize: 100, + outputBufferSize: 1000, + ansiColor: true, + font: 'monospace', + debugGmcp: false, + globalVolume: 0.7 + } + }; + + // Update stores + accessibilitySettings.set(defaultSettings.accessibility); + uiSettings.set(defaultSettings.ui); + + // Update internal settings and save + this.settings = defaultSettings; + this.saveSettings(); + + this.emit('settingsReset', defaultSettings); + } + + /** + * Import settings from a JSON string + */ + public importSettings(json: string): void { + try { + const imported = JSON.parse(json); + + if (typeof imported === 'object' && imported !== null) { + // Create a valid settings object with defaults for missing properties + const newSettings: Settings = { + accessibility: { + ...this.settings.accessibility, + ...(imported.accessibility || {}) + }, + ui: { + ...this.settings.ui, + ...(imported.ui || {}) + } + }; + + // Update stores + accessibilitySettings.set(newSettings.accessibility); + uiSettings.set(newSettings.ui); + + // Update internal settings and save + this.settings = newSettings; + this.saveSettings(); + + this.emit('settingsImported', newSettings); + } + } catch (error) { + console.error('Failed to import settings:', error); + throw new Error('Failed to import settings. Invalid format.'); + } + } + + /** + * Export settings to a JSON string + */ + public exportSettings(): string { + // Get current values from stores + this.settings.accessibility = get(accessibilitySettings); + this.settings.ui = get(uiSettings); + + return JSON.stringify(this.settings, null, 2); + } +} + +// Create a singleton instance +export const settingsManager = new SettingsManager(); diff --git a/src/lib/settings/settingsTest.js b/src/lib/settings/settingsTest.js new file mode 100644 index 0000000..49b87fe --- /dev/null +++ b/src/lib/settings/settingsTest.js @@ -0,0 +1,47 @@ +// This is a simple browser console test that you can run to verify settings localStorage functionality +// Copy and paste this into the browser console after loading the application + +function testSettingsStorage() { + console.log("=== Settings Storage Test ==="); + + // Get current settings from localStorage + const currentSettings = localStorage.getItem('svelte-mud-settings'); + console.log("Current settings in localStorage:", currentSettings ? JSON.parse(currentSettings) : "None"); + + // Get settings from the stores + const mudStore = (window.mudStore || {}); + if (!mudStore.uiSettings || !mudStore.accessibilitySettings) { + console.error("Could not access mudStore. Make sure it's exposed to window in development."); + return; + } + + // Toggle dark mode + const isDarkMode = mudStore.uiSettings.isDarkMode; + console.log(`Current dark mode: ${isDarkMode}, toggling to ${!isDarkMode}`); + mudStore.uiSettings.isDarkMode = !isDarkMode; + + // Check localStorage again after changes + setTimeout(() => { + const updatedSettings = localStorage.getItem('svelte-mud-settings'); + console.log("Updated settings in localStorage:", updatedSettings ? JSON.parse(updatedSettings) : "None"); + + if (!updatedSettings) { + console.error("Settings were not saved to localStorage!"); + return; + } + + const parsedSettings = JSON.parse(updatedSettings); + if (parsedSettings.ui.isDarkMode !== !isDarkMode) { + console.error("Dark mode setting was not updated correctly in localStorage!"); + } else { + console.log("✅ Settings were properly saved to localStorage"); + } + + // Revert the change + console.log("Reverting dark mode setting..."); + mudStore.uiSettings.isDarkMode = isDarkMode; + }, 200); // Wait for the debounce timeout +} + +// Run the test +testSettingsStorage(); diff --git a/src/lib/stores/mudStore.ts b/src/lib/stores/mudStore.ts index 1c761b7..1e13a4a 100644 --- a/src/lib/stores/mudStore.ts +++ b/src/lib/stores/mudStore.ts @@ -1,4 +1,5 @@ import { writable, derived, get } from 'svelte/store'; +import { settingsManager } from '$lib/settings/SettingsManager'; import type { MudProfile } from '$lib/profiles/ProfileManager'; import type { MudConnection } from '$lib/connection/MudConnection'; import type { Trigger } from '$lib/triggers/TriggerSystem'; @@ -53,6 +54,23 @@ export const uiSettings = writable({ globalVolume: 0.7 // Global volume control for sounds (0-1) }); +// Subscribe to settings changes to save to localStorage +accessibilitySettings.subscribe(value => { + // Skip during SSR + if (typeof window !== 'undefined') { + // Use a small timeout to batch multiple rapid changes + setTimeout(() => settingsManager.saveSettings(), 100); + } +}); + +uiSettings.subscribe(value => { + // Skip during SSR + if (typeof window !== 'undefined') { + // Use a small timeout to batch multiple rapid changes + setTimeout(() => settingsManager.saveSettings(), 100); + } +}); + // Store for input history export const inputHistory = writable([]); export const inputHistoryIndex = writable(-1); diff --git a/src/lib/utils/BackupManager.ts b/src/lib/utils/BackupManager.ts new file mode 100644 index 0000000..84a729a --- /dev/null +++ b/src/lib/utils/BackupManager.ts @@ -0,0 +1,266 @@ +import { EventEmitter } from '$lib/utils/EventEmitter'; +import { ProfileManager } from '$lib/profiles/ProfileManager'; +import { TriggerSystem } from '$lib/triggers/TriggerSystem'; +import { settingsManager } from '$lib/settings/SettingsManager'; +import { get } from 'svelte/store'; +import { profiles, triggers, accessibilitySettings, uiSettings } from '$lib/stores/mudStore'; + +// Storage keys used by the app +const STORAGE_KEYS = { + PROFILES: 'svelte-mud-profiles', + TRIGGERS: 'mud-triggers', + SETTINGS: 'svelte-mud-settings' +}; + +// Current backup format version +const BACKUP_FORMAT_VERSION = '1.0.0'; + +// Interface for backup file format +interface BackupData { + version: string; + timestamp: number; + data: { + profiles?: any; + triggers?: any; + settings?: any; + [key: string]: any; // Allow for future storage items + }; +} + +export class BackupManager extends EventEmitter { + private profileManager: ProfileManager; + private triggerSystem: TriggerSystem; + + constructor() { + super(); + + // We'll initialize these when needed to avoid initialization order issues + this.profileManager = null; + this.triggerSystem = null; + } + + /** + * Initialize managers if needed + */ + private ensureManagers() { + if (!this.profileManager) { + this.profileManager = new ProfileManager(); + } + + if (!this.triggerSystem) { + this.triggerSystem = new TriggerSystem(); + } + } + + /** + * Create a full backup of all app data + */ + public createBackup(): BackupData { + if (typeof window === 'undefined') { + throw new Error('Cannot create backup during server-side rendering'); + } + + // Create backup object + const backup: BackupData = { + version: BACKUP_FORMAT_VERSION, + timestamp: Date.now(), + data: {} + }; + + // Add all localStorage items that match our known keys + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + + // Skip items that don't look like our app data + if (!key.startsWith('svelte-mud-') && !key.startsWith('mud-')) { + continue; + } + + try { + const value = localStorage.getItem(key); + if (value) { + // Parse the JSON value + const parsedValue = JSON.parse(value); + + // Store by category + if (key === STORAGE_KEYS.PROFILES) { + backup.data.profiles = parsedValue; + } else if (key === STORAGE_KEYS.TRIGGERS) { + backup.data.triggers = parsedValue; + } else if (key === STORAGE_KEYS.SETTINGS) { + backup.data.settings = parsedValue; + } else { + // Store other items directly by key + backup.data[key] = parsedValue; + } + } + } catch (error) { + console.error(`Failed to parse localStorage item ${key}:`, error); + } + } + + return backup; + } + + /** + * Export backup as JSON file + */ + public exportBackup(): void { + try { + const backup = this.createBackup(); + const backupJson = JSON.stringify(backup, null, 2); + const blob = new Blob([backupJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + // Create a link to download the backup + const a = document.createElement('a'); + a.href = url; + + // Format date for filename + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD + + a.download = `svelte-mud-backup-${dateStr}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.emit('backupExported', backup); + } catch (error) { + console.error('Failed to export backup:', error); + throw new Error(`Failed to export backup: ${error.message}`); + } + } + + /** + * Import backup from a JSON string + */ + public async importBackup(json: string): Promise { + try { + // Parse backup data + const backup = JSON.parse(json) as BackupData; + + // Verify format version + if (!backup.version) { + throw new Error('Invalid backup file format: Missing version'); + } + + // Check if the backup is too new for this version of the app + // Simple semver major version check + const backupMajorVersion = parseInt(backup.version.split('.')[0], 10); + const currentMajorVersion = parseInt(BACKUP_FORMAT_VERSION.split('.')[0], 10); + + if (backupMajorVersion > currentMajorVersion) { + throw new Error(`Backup file version (${backup.version}) is newer than the app can handle (${BACKUP_FORMAT_VERSION})`); + } + + // Ensure we have the managers initialized + this.ensureManagers(); + + // Restore profiles + if (backup.data.profiles) { + await this.restoreProfiles(backup.data.profiles); + } + + // Restore triggers + if (backup.data.triggers) { + await this.restoreTriggers(backup.data.triggers); + } + + // Restore settings + if (backup.data.settings) { + await this.restoreSettings(backup.data.settings); + } + + // Restore any other data + for (const key in backup.data) { + if ( + key !== 'profiles' && + key !== 'triggers' && + key !== 'settings' && + key.startsWith('svelte-mud-') || key.startsWith('mud-') + ) { + localStorage.setItem(key, JSON.stringify(backup.data[key])); + } + } + + this.emit('backupImported', backup); + } catch (error) { + console.error('Failed to import backup:', error); + throw new Error(`Failed to import backup: ${error.message}`); + } + } + + /** + * Restore profiles from backup + */ + private async restoreProfiles(profilesData: any[]): Promise { + if (!Array.isArray(profilesData)) { + throw new Error('Invalid profiles data: Expected array'); + } + + // Clear existing profiles + localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify([])); + + // Import each profile + for (const profile of profilesData) { + this.profileManager.addProfile(profile); + } + + // Update the profiles store + profiles.set(this.profileManager.getProfiles()); + } + + /** + * Restore triggers from backup + */ + private async restoreTriggers(triggersData: any[]): Promise { + if (!Array.isArray(triggersData)) { + throw new Error('Invalid triggers data: Expected array'); + } + + // Clear existing triggers + localStorage.setItem(STORAGE_KEYS.TRIGGERS, JSON.stringify([])); + + // Import each trigger + for (const trigger of triggersData) { + this.triggerSystem.addTrigger(trigger); + } + + // Update the triggers store + triggers.set(this.triggerSystem.getTriggers()); + } + + /** + * Restore settings from backup + */ + private async restoreSettings(settingsData: any): Promise { + if (typeof settingsData !== 'object' || settingsData === null) { + throw new Error('Invalid settings data: Expected object'); + } + + // Update settings in localStorage + localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settingsData)); + + // Update the settings stores + if (settingsData.accessibility) { + accessibilitySettings.set(settingsData.accessibility); + } + + if (settingsData.ui) { + uiSettings.set(settingsData.ui); + } + + // Make sure settings manager knows about the changes + settingsManager.saveSettings(); + } +} + +// Create a singleton instance +export const backupManager = new BackupManager(); + +// Expose to window for testing in development mode +if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') { + (window as any).backupManager = backupManager; +} diff --git a/src/lib/utils/backupTest.js b/src/lib/utils/backupTest.js new file mode 100644 index 0000000..49e790b --- /dev/null +++ b/src/lib/utils/backupTest.js @@ -0,0 +1,43 @@ +// This is a simple browser console test that you can run to verify backup/restore functionality +// Copy and paste this into the browser console after loading the application + +function testBackupSystem() { + console.log("=== Backup System Test ==="); + + // First check if we can access the backup manager + if (!window.backupManager) { + console.error("Could not access backupManager. Add 'window.backupManager = backupManager;' to BackupManager.ts for testing."); + return; + } + + // Create a backup object without downloading the file + console.log("Creating backup object..."); + const backup = window.backupManager.createBackup(); + + console.log("Backup object created:", backup); + console.log("Backup version:", backup.version); + console.log("Timestamp:", new Date(backup.timestamp).toLocaleString()); + + // Check what data was backed up + console.log("Profiles found:", backup.data.profiles ? backup.data.profiles.length : 0); + console.log("Triggers found:", backup.data.triggers ? backup.data.triggers.length : 0); + console.log("Settings found:", backup.data.settings ? "Yes" : "No"); + + // Count total number of items in the backup + const totalItems = Object.keys(backup.data).length; + console.log("Total data items:", totalItems); + + if (totalItems === 0) { + console.error("No data was found in the backup. Make sure you have some profiles, triggers, or settings saved."); + return; + } + + console.log("✅ Backup creation successful"); + + // We can't test actual import here as it would overwrite the user's data + console.log("To test import functionality, export a backup file,"); + console.log("modify some settings, then import the backup file back."); +} + +// Run the test +testBackupSystem(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a0ed50f..3abaedb 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { profiles, activeProfileId, uiSettings, accessibilitySettings } from '$lib/stores/mudStore'; import { ProfileManager } from '$lib/profiles/ProfileManager'; import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager'; + import { settingsManager } from '$lib/settings/SettingsManager'; import { shortcutManager } from '$lib/utils/KeyboardShortcutManager'; import PwaUpdater from '$lib/components/PwaUpdater.svelte'; import '../app.css'; @@ -157,6 +158,9 @@ profileManager = new ProfileManager(); + // Settings manager is initialized as a singleton when imported + // We don't need to create a new instance + // Initialize accessibility manager for global control accessibilityManager = new AccessibilityManager(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f3cf7b7..24cb11b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,6 +9,7 @@ } import MudMdi from '$lib/components/MudMdi.svelte'; import KeyboardShortcutsHelp from '$lib/components/KeyboardShortcutsHelp.svelte'; + import SettingsPanel from '$lib/components/SettingsPanel.svelte'; import { ModalHelper } from '$lib/utils/ModalHelper'; import { profiles, @@ -465,172 +466,7 @@

Settings

-
-
- Dark Mode - -
- -
- Show Timestamps - -
- -
- ANSI Colors - -
- -
- Font - -
- -
- Global Sound Volume -
- { - // Debounce volume changes to avoid performance issues with rapid changes - if (window.volumeDebounceTimeout) { - clearTimeout(window.volumeDebounceTimeout); - } - - // Read value directly from the input - const newVolume = parseFloat(e.target.value); - - // Update the store with a slight delay to avoid excessive updates - window.volumeDebounceTimeout = setTimeout(() => { - uiSettings.update(settings => ({ - ...settings, - globalVolume: newVolume - })); - }, 100); - }} - bind:value={$uiSettings.globalVolume} - > - {($uiSettings.globalVolume * 100).toFixed(0)}% -
-
- -

Debugging

- -
- Show GMCP Messages - -
- Shows GMCP protocol messages in the output window for debugging -
-
- -

Accessibility

- -
- Text-to-Speech - -
- -
- High Contrast - -
- -
- Font Size -
- - {$accessibilitySettings.fontSize}px -
-
- - {#if $accessibilitySettings.textToSpeech} -
- Interrupt Speech on Enter - -
- Automatically stop speaking when the Enter key is pressed -
-
- -
- Speech Rate -
- - {$accessibilitySettings.speechRate.toFixed(1)} -
-
- -
- Speech Pitch -
- - {$accessibilitySettings.speechPitch.toFixed(1)} -
-
- -
- Speech Volume -
- - {($accessibilitySettings.speechVolume * 100).toFixed(0)}% -
-
- {/if} -
+ {/if}