mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
feat: url state management
This commit is contained in:
@@ -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
|
||||
- **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
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user