From 5776f7e9106856d9d84a25b5313971b23783a4b3 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Wed, 15 Oct 2025 17:47:21 +0300 Subject: [PATCH] feat: url state management --- docs/dev-plan.md | 61 ++++++++++++++++++++++++++++++---- docs/features-list.md | 24 ++++++++++++-- src/js/app.js | 53 +++++++++++++++++++++++++++--- src/js/config.js | 69 +++++++++++++++++++++++++++++++++++++++ src/js/dataset-manager.js | 32 +++++++++++++++--- src/js/snippet-manager.js | 7 +++- 6 files changed, 228 insertions(+), 18 deletions(-) diff --git a/docs/dev-plan.md b/docs/dev-plan.md index 6404237..7cf3795 100644 --- a/docs/dev-plan.md +++ b/docs/dev-plan.md @@ -260,7 +260,47 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu --- -### **Phase 11: Advanced Dataset Features** _(Future)_ +### **Phase 11: URL State Management** ✅ **COMPLETE** +**Goal**: Shareable URLs and browser navigation support + +**Deliverables**: +- Hash-based URL routing for snippets and datasets +- URL patterns: + - Snippet selection: `#snippet-123456` + - Dataset modal open: `#datasets` + - Dataset selected: `#datasets/dataset-123456` + - New dataset form: `#datasets/new` +- URLState utility in config.js with parse/update/clear methods +- Browser back/forward navigation (hashchange event listener) +- Page reload preserves selected snippet or dataset state +- Automatic state restoration after editor initialization +- Restores snippet URL when closing dataset modal +- No external dependencies (native Hash API and History API) + +**Technical Implementation**: +- `URLState.parse()`: Parses hash into state object +- `URLState.update(state, replaceState)`: Updates URL with optional history.replaceState +- `URLState.clear()`: Removes hash from URL +- Integration points: + - `selectSnippet()`: Updates URL on snippet selection + - `selectDataset()`: Updates URL on dataset selection + - `openDatasetManager()`: Sets `#datasets` hash + - `closeDatasetManager()`: Restores snippet hash or clears + - `showNewDatasetForm()`: Sets `#datasets/new` hash +- `handleURLStateChange()`: Responds to hashchange events +- `initializeURLStateManagement()`: Called after Monaco editor ready +- Prevents double-prefix issues by handling prefix addition in URLState.update() +- Optional `updateURL` parameter on all functions to prevent infinite loops + +**Benefits**: +- Shareable links to specific snippets or datasets +- Browser navigation works intuitively +- Page refresh preserves user context +- Better UX for multi-tab workflows + +--- + +### **Phase 12: Advanced Dataset Features** _(Future)_ **Goal**: Enhanced dataset workflows - [ ] Detect inline data in Vega-Lite specs @@ -371,15 +411,15 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu ## Current Status -**Completed**: Phases 0-10 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export, dataset management) -**Next**: Phase 11 - Advanced Dataset Features (optional enhancements) +**Completed**: Phases 0-11 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export, dataset management, URL state management) +**Next**: Phase 12 - Advanced Dataset Features (optional enhancements) **See**: `CLAUDE.md` for concise current state summary --- ## Implemented Features -### Core Capabilities (Phases 0-10) +### Core Capabilities (Phases 0-11) - Three-panel resizable layout with memory and persistence - Monaco Editor v0.47.0 with Vega-Lite v5 schema validation - Live Vega-Lite rendering with debounced updates and error display @@ -404,7 +444,14 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu - Automatic metadata calculation and display - URL metadata fetching and refresh - Dataset reference resolution in Vega-Lite specs +- **URL State Management (Phase 11)**: + - Hash-based routing for snippets and datasets + - Browser back/forward navigation support + - Page reload preserves state + - Shareable URLs for specific snippets/datasets + - Restores snippet URL when closing dataset modal - Retro Windows 2000 aesthetic throughout +- Component-based CSS architecture with base classes ### Technical Implementation - **State Management**: Synchronous `isUpdatingEditor` flag prevents unwanted auto-saves @@ -422,5 +469,7 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu - **Dataset Storage**: IndexedDB with async/Promise-based API, unique name constraint - **Dataset Resolution**: Async spec transformation before rendering, format-aware data injection - **URL Metadata**: Fetch on creation with graceful CORS error handling -- **Modal UI**: Flexbox with overflow:auto, max-height responsive to viewport -- **Auto-detection**: URL validation, JSON/CSV/TSV parsing, confidence scoring, real-time feedback \ No newline at end of file +- **Modal UI**: Flexbox with overflow:auto, max-height responsive to viewport (80vh fixed height) +- **Auto-detection**: URL validation, JSON/CSV/TSV parsing, confidence scoring, real-time feedback +- **URL State Management**: Native Hash API with hashchange listener, initialized after editor ready +- **CSS Architecture**: Component-based with base classes (.btn, .input, .preview-box) and modifiers \ No newline at end of file diff --git a/docs/features-list.md b/docs/features-list.md index e7c1293..25f441b 100644 --- a/docs/features-list.md +++ b/docs/features-list.md @@ -168,7 +168,25 @@ --- -### **12. User Experience Enhancements** +### **12. URL State Management** +- Hash-based URL routing for snippets and datasets +- Snippet selection persists in URL (`#snippet-123456`) +- Dataset modal state persists in URL: + - Modal open: `#datasets` + - Dataset selected: `#datasets/dataset-123456` + - New dataset form: `#datasets/new` +- Browser back/forward navigation support +- Page reload preserves state (selected snippet/dataset) +- URL sharing for specific snippets or datasets +- Automatic state restoration on page load +- Restores snippet URL when closing dataset modal +- No external libraries (native Hash API) + +**Files**: `config.js` (URLState utility), `snippet-manager.js` (snippet URLs), `dataset-manager.js` (dataset URLs), `app.js` (hashchange listener) + +--- + +### **13. User Experience Enhancements** - Auto-select first snippet on page load - Relative date formatting (Today/Yesterday/X days ago) - Full datetime display in metadata panel @@ -184,8 +202,8 @@ ## 📊 **Feature Statistics** -- **Core Feature Groups**: 12 -- **Total Individual Capabilities**: ~60+ +- **Core Feature Groups**: 13 +- **Total Individual Capabilities**: ~70+ - **Storage Systems**: 2 (localStorage for snippets, IndexedDB for datasets) - **UI Panels**: 3 main + 1 modal - **Auto-save Points**: 3 (draft spec, name, comment) diff --git a/src/js/app.js b/src/js/app.js index 2c13f7c..35c9089 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -15,10 +15,12 @@ document.addEventListener('DOMContentLoaded', function () { // Update storage monitor updateStorageMonitor(); - // Auto-select first snippet on page load - const firstSnippet = SnippetStorage.listSnippets()[0]; - if (firstSnippet) { - selectSnippet(firstSnippet.id); + // Auto-select first snippet on page load (only if no hash in URL) + if (!window.location.hash) { + const firstSnippet = SnippetStorage.listSnippets()[0]; + if (firstSnippet) { + selectSnippet(firstSnippet.id); + } } // Load saved layout @@ -79,6 +81,9 @@ document.addEventListener('DOMContentLoaded', function () { // Initialize auto-save functionality initializeAutoSave(); + + // Initialize URL state management AFTER editor is ready + initializeURLStateManagement(); }); // Toggle panel buttons @@ -194,3 +199,43 @@ document.addEventListener('DOMContentLoaded', function () { document.getElementById('publish-btn').addEventListener('click', publishDraft); document.getElementById('revert-btn').addEventListener('click', revertDraft); }); + +// Handle URL hash changes (browser back/forward) +function handleURLStateChange() { + const state = URLState.parse(); + + if (state.view === 'datasets') { + // Open dataset modal + openDatasetManager(false); // Don't update URL + + if (state.datasetId === 'new') { + // Show new dataset form + showNewDatasetForm(false); + } else if (state.datasetId) { + // Extract numeric ID from "dataset-123456" + const numericId = parseFloat(state.datasetId.replace('dataset-', '')); + selectDataset(numericId, false); + } + } else if (state.snippetId) { + // Close dataset modal if open + const modal = document.getElementById('dataset-modal'); + if (modal && modal.style.display === 'flex') { + closeDatasetManager(false); + } + + // Select snippet + const numericId = parseFloat(state.snippetId.replace('snippet-', '')); + selectSnippet(numericId, false); + } +} + +// Initialize URL state management +function initializeURLStateManagement() { + // Handle hashchange event for back/forward navigation + window.addEventListener('hashchange', handleURLStateChange); + + // Check if there's a hash in the URL on page load + if (window.location.hash) { + handleURLStateChange(); + } +} diff --git a/src/js/config.js b/src/js/config.js index c64dda9..9128214 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -16,6 +16,75 @@ let panelMemory = { previewWidth: '25%' }; +// URL State Management +const URLState = { + // Parse current hash into state object + parse() { + const hash = window.location.hash.slice(1); // Remove '#' + if (!hash) return { view: 'snippets', snippetId: null, datasetId: null }; + + const parts = hash.split('/'); + + // #snippet-123456 + if (hash.startsWith('snippet-')) { + return { view: 'snippets', snippetId: hash, datasetId: null }; + } + + // #datasets + if (parts[0] === 'datasets') { + if (parts.length === 1) { + return { view: 'datasets', snippetId: null, datasetId: null }; + } + // #datasets/new + if (parts[1] === 'new') { + return { view: 'datasets', snippetId: null, datasetId: 'new' }; + } + // #datasets/dataset-123456 + if (parts[1].startsWith('dataset-')) { + return { view: 'datasets', snippetId: null, datasetId: parts[1] }; + } + } + + return { view: 'snippets', snippetId: null, datasetId: null }; + }, + + // Update URL hash without triggering hashchange + update(state, replaceState = false) { + let hash = ''; + + if (state.view === 'datasets') { + if (state.datasetId === 'new') { + hash = '#datasets/new'; + } else if (state.datasetId) { + // Add 'dataset-' prefix if not already present + const datasetId = typeof state.datasetId === 'string' && state.datasetId.startsWith('dataset-') + ? state.datasetId + : `dataset-${state.datasetId}`; + hash = `#datasets/${datasetId}`; + } else { + hash = '#datasets'; + } + } else if (state.snippetId) { + // Add 'snippet-' prefix if not already present + const snippetId = typeof state.snippetId === 'string' && state.snippetId.startsWith('snippet-') + ? state.snippetId + : `snippet-${state.snippetId}`; + hash = `#${snippetId}`; + } + + if (replaceState) { + window.history.replaceState(null, '', hash || '#'); + } else { + window.location.hash = hash; + } + }, + + // Clear hash + clear() { + window.history.replaceState(null, '', window.location.pathname); + } +}; + // Settings storage const AppSettings = { STORAGE_KEY: 'astrolabe-settings', diff --git a/src/js/dataset-manager.js b/src/js/dataset-manager.js index c4fd4a4..d657454 100644 --- a/src/js/dataset-manager.js +++ b/src/js/dataset-manager.js @@ -315,7 +315,7 @@ async function renderDatasetList() { } // Select a dataset and show details -async function selectDataset(datasetId) { +async function selectDataset(datasetId, updateURL = true) { const dataset = await DatasetStorage.getDataset(datasetId); if (!dataset) return; @@ -361,20 +361,39 @@ async function selectDataset(datasetId) { // Store current dataset ID window.currentDatasetId = datasetId; + + // Update URL state (URLState.update will add 'dataset-' prefix) + if (updateURL) { + URLState.update({ view: 'datasets', snippetId: null, datasetId: datasetId }); + } } // Open dataset manager modal -function openDatasetManager() { +function openDatasetManager(updateURL = true) { const modal = document.getElementById('dataset-modal'); modal.style.display = 'flex'; renderDatasetList(); + + // Update URL state + if (updateURL) { + URLState.update({ view: 'datasets', snippetId: null, datasetId: null }); + } } // Close dataset manager modal -function closeDatasetManager() { +function closeDatasetManager(updateURL = true) { const modal = document.getElementById('dataset-modal'); modal.style.display = 'none'; window.currentDatasetId = null; + + // Update URL state - restore snippet if one is selected + if (updateURL) { + if (window.currentSnippetId) { + URLState.update({ view: 'snippets', snippetId: window.currentSnippetId, datasetId: null }); + } else { + URLState.clear(); + } + } } // Auto-detect data format from pasted content @@ -552,7 +571,7 @@ function hideDetectionConfirmation() { } // Show new dataset form -function showNewDatasetForm() { +function showNewDatasetForm(updateURL = true) { document.getElementById('dataset-list-view').style.display = 'none'; document.getElementById('dataset-form-view').style.display = 'block'; document.getElementById('dataset-form-name').value = ''; @@ -563,6 +582,11 @@ function showNewDatasetForm() { // Hide detection confirmation hideDetectionConfirmation(); + // Update URL state + if (updateURL) { + URLState.update({ view: 'datasets', snippetId: null, datasetId: 'new' }); + } + // Add paste handler if not already added if (!window.datasetListenersAdded) { const inputEl = document.getElementById('dataset-form-input'); diff --git a/src/js/snippet-manager.js b/src/js/snippet-manager.js index 016b271..dd8b595 100644 --- a/src/js/snippet-manager.js +++ b/src/js/snippet-manager.js @@ -460,7 +460,7 @@ function attachSnippetEventListeners() { } // Select and load a snippet into the editor -function selectSnippet(snippetId) { +function selectSnippet(snippetId, updateURL = true) { const snippet = SnippetStorage.getSnippet(snippetId); if (!snippet) return; @@ -500,6 +500,11 @@ function selectSnippet(snippetId) { // Store currently selected snippet ID globally window.currentSnippetId = snippetId; + + // Update URL state (URLState.update will add 'snippet-' prefix) + if (updateURL) { + URLState.update({ view: 'snippets', snippetId: snippetId, datasetId: null }); + } } // Auto-save functionality