diff --git a/docs/dev-plan.md b/docs/dev-plan.md index 3ab44ba..07e48de 100644 --- a/docs/dev-plan.md +++ b/docs/dev-plan.md @@ -140,18 +140,19 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu --- -### **Phase 7: Draft/Published Workflow** +### **Phase 7: Draft/Published Workflow** ✅ **COMPLETE** **Goal**: Safe experimentation without losing working versions -- [ ] Add "Published" badge/indicator to snippet list items -- [ ] Add "Publish" button in editor UI -- [ ] Toggle between viewing draft vs published version -- [ ] On publish: copy `draftSpec` → `spec`, update `published` timestamp -- [ ] Visual indicator in editor showing draft vs published state -- [ ] Option to revert draft to last published version -- [ ] Prevent accidental data loss with clear state indication - -**Deliverable**: Git-like draft/staged workflow for specs +**Deliverables**: +- Draft/Published toggle buttons in editor header (merged visual design) +- Status indicator lights on snippets (🟢 green = no changes, 🟡 yellow = has draft) +- Publish button (green, copies draftSpec → spec) +- Revert button (orange, copies spec → draftSpec with confirmation) +- Context-aware button visibility (only shown in draft mode) +- Read-only published view when draft exists +- Auto-draft creation when editing published view without draft +- Auto-select first snippet on page load +- Instant status light updates after auto-save --- @@ -303,15 +304,15 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu ## Current Status -**Completed**: Phases 0-6 (Storage, UI, editor, rendering, persistence, CRUD, organization) -**Active**: Phase 7 - Draft/Published Workflow +**Completed**: Phases 0-7 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow) +**Active**: Phase 8 - Storage Monitoring **See**: `CLAUDE.md` for concise current state summary --- ## Implemented Features -### Core Capabilities (Phases 0-6) +### Core Capabilities (Phases 0-7) - 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 @@ -320,11 +321,17 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu - Real-time search across snippet name, comment, and spec content - Auto-save system (1s debounce) for specs and metadata - Ghost card interface for snippet creation +- Draft/Published workflow with version control +- Status indicator lights (green/yellow) showing draft state +- Context-aware Publish/Revert buttons with color coding - Retro Windows 2000 aesthetic throughout ### Technical Implementation - **State Management**: Synchronous `isUpdatingEditor` flag prevents unwanted auto-saves +- **View Modes**: `currentViewMode` tracks draft vs published state +- **Read-only Logic**: Monaco editor locked in published view when draft exists +- **Auto-draft Creation**: Editing published without draft auto-switches to draft mode - **Debouncing**: 1.5s render, 1s auto-save, 300ms search - **AMD Resolution**: Temporary `window.define` disabling for Vega library loading - **Panel Memory**: localStorage persistence for sizes and visibility across sessions -- **Data Model**: Phase 0 schema with `spec` and `draftSpec` fields ready for versioning \ No newline at end of file +- **Data Model**: Phase 0 schema with `spec` (published) and `draftSpec` (working) fields \ No newline at end of file diff --git a/index.html b/index.html index 52838af..1e059e3 100644 --- a/index.html +++ b/index.html @@ -103,7 +103,16 @@
- Editor + Editor +
+ + + View: +
+ + +
+
diff --git a/src/js/app.js b/src/js/app.js index 460e997..bfe532d 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -12,6 +12,12 @@ document.addEventListener('DOMContentLoaded', function () { renderSnippetList(); + // Auto-select first snippet on page load + const firstSnippet = SnippetStorage.listSnippets()[0]; + if (firstSnippet) { + selectSnippet(firstSnippet.id); + } + // Load saved layout loadLayoutFromStorage(); @@ -86,4 +92,17 @@ document.addEventListener('DOMContentLoaded', function () { alert('Coming soon in a future phase!'); }); }); + + // View mode toggle buttons + document.getElementById('view-draft').addEventListener('click', () => { + switchViewMode('draft'); + }); + + document.getElementById('view-published').addEventListener('click', () => { + switchViewMode('published'); + }); + + // Publish and Revert buttons + document.getElementById('publish-btn').addEventListener('click', publishDraft); + document.getElementById('revert-btn').addEventListener('click', revertDraft); }); diff --git a/src/js/config.js b/src/js/config.js index bbb2911..8c2c05c 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -1,6 +1,7 @@ // Global variables and configuration let editor; // Global editor instance let renderTimeout; // For debouncing +let currentViewMode = 'draft'; // Track current view mode: 'draft' or 'published' // Panel resizing variables let isResizing = false; diff --git a/src/js/snippet-manager.js b/src/js/snippet-manager.js index f2f363b..e97afd3 100644 --- a/src/js/snippet-manager.js +++ b/src/js/snippet-manager.js @@ -234,10 +234,17 @@ function renderSnippetList(searchQuery = null) { dateText = formatSnippetDate(snippet.modified); } + // Determine status: green if no draft changes, yellow if has draft + const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); + const statusClass = hasDraft ? 'draft' : 'published'; + return `
  • -
    ${snippet.name}
    -
    ${dateText}
    +
    +
    ${snippet.name}
    +
    ${dateText}
    +
    +
  • `; }).join(''); @@ -441,12 +448,9 @@ function selectSnippet(snippetId) { }); document.querySelector(`[data-snippet-id="${snippetId}"]`).classList.add('selected'); - // Load draft spec into editor (prevent auto-save during update) - if (editor) { - window.isUpdatingEditor = true; - editor.setValue(JSON.stringify(snippet.draftSpec, null, 2)); - window.isUpdatingEditor = false; - } + // Load spec based on current view mode + loadSnippetIntoEditor(snippet); + updateViewModeUI(snippet); // Show and populate meta fields const metaSection = document.getElementById('snippet-meta'); @@ -484,6 +488,9 @@ window.isUpdatingEditor = false; // Global flag to prevent auto-save/debounce du function autoSaveDraft() { if (!window.currentSnippetId || !editor) return; + // Only save to draft if we're in draft mode + if (currentViewMode !== 'draft') return; + try { const currentSpec = JSON.parse(editor.getValue()); const snippet = SnippetStorage.getSnippet(window.currentSnippetId); @@ -491,6 +498,15 @@ function autoSaveDraft() { if (snippet) { snippet.draftSpec = currentSpec; SnippetStorage.saveSnippet(snippet); + + // Refresh snippet list to update status light + renderSnippetList(); + // Restore selection + const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); + if (selectedItem) selectedItem.classList.add('selected'); + + // Update button states + updateViewModeUI(snippet); } } catch (error) { // Ignore JSON parse errors during editing @@ -502,6 +518,20 @@ function debouncedAutoSave() { // Don't auto-save if we're programmatically updating the editor if (window.isUpdatingEditor) return; + // If viewing published and no draft exists, create draft automatically + if (currentViewMode === 'published' && window.currentSnippetId) { + const snippet = SnippetStorage.getSnippet(window.currentSnippetId); + if (snippet) { + const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); + if (!hasDraft) { + // No draft exists, automatically switch to draft mode + currentViewMode = 'draft'; + updateViewModeUI(snippet); + editor.updateOptions({ readOnly: false }); + } + } + } + clearTimeout(autoSaveTimeout); autoSaveTimeout = setTimeout(autoSaveDraft, 1000); // 1 second delay } @@ -659,3 +689,112 @@ function renameSnippet(snippetId, newName) { return true; } +// Load snippet into editor based on view mode +function loadSnippetIntoEditor(snippet) { + if (!editor) return; + + window.isUpdatingEditor = true; + + const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); + + if (currentViewMode === 'draft') { + editor.setValue(JSON.stringify(snippet.draftSpec, null, 2)); + editor.updateOptions({ readOnly: false }); + } else { + // Published view - always read-only if draft exists + editor.setValue(JSON.stringify(snippet.spec, null, 2)); + editor.updateOptions({ readOnly: hasDraft }); + } + + window.isUpdatingEditor = false; +} + +// Update view mode UI (buttons and editor state) +function updateViewModeUI(snippet) { + const draftBtn = document.getElementById('view-draft'); + const publishedBtn = document.getElementById('view-published'); + const publishBtn = document.getElementById('publish-btn'); + const revertBtn = document.getElementById('revert-btn'); + + // Update toggle button states + if (currentViewMode === 'draft') { + draftBtn.classList.add('active'); + publishedBtn.classList.remove('active'); + } else { + draftBtn.classList.remove('active'); + publishedBtn.classList.add('active'); + } + + // Show/hide and enable/disable action buttons based on mode + const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec); + + if (currentViewMode === 'draft') { + // In draft mode: show both buttons, enable based on draft existence + publishBtn.classList.add('visible'); + revertBtn.classList.add('visible'); + publishBtn.disabled = !hasDraft; + revertBtn.disabled = !hasDraft; + } else { + // In published mode: hide both buttons + publishBtn.classList.remove('visible'); + revertBtn.classList.remove('visible'); + } +} + +// Switch view mode +function switchViewMode(mode) { + if (!window.currentSnippetId) return; + + currentViewMode = mode; + const snippet = SnippetStorage.getSnippet(window.currentSnippetId); + if (snippet) { + loadSnippetIntoEditor(snippet); + updateViewModeUI(snippet); + } +} + +// Publish draft to spec +function publishDraft() { + if (!window.currentSnippetId) return; + + const snippet = SnippetStorage.getSnippet(window.currentSnippetId); + if (!snippet) return; + + // Copy draftSpec to spec + snippet.spec = JSON.parse(JSON.stringify(snippet.draftSpec)); + SnippetStorage.saveSnippet(snippet); + + // Refresh UI + renderSnippetList(); + const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); + if (selectedItem) selectedItem.classList.add('selected'); + + updateViewModeUI(snippet); +} + +// Revert draft to published spec +function revertDraft() { + if (!window.currentSnippetId) return; + + const snippet = SnippetStorage.getSnippet(window.currentSnippetId); + if (!snippet) return; + + if (confirm('Revert all draft changes to last published version? This cannot be undone.')) { + // Copy spec to draftSpec + snippet.draftSpec = JSON.parse(JSON.stringify(snippet.spec)); + SnippetStorage.saveSnippet(snippet); + + // Reload editor if in draft view + if (currentViewMode === 'draft') { + loadSnippetIntoEditor(snippet); + } + + // Refresh UI + renderSnippetList(); + const selectedItem = document.querySelector(`[data-snippet-id="${window.currentSnippetId}"]`); + if (selectedItem) selectedItem.classList.add('selected'); + + updateViewModeUI(snippet); + } +} + diff --git a/src/styles.css b/src/styles.css index d7b336b..adec5f4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -108,12 +108,125 @@ body { } .panel-header { - padding: 8px 12px; + padding: 6px 12px; background: #c0c0c0; border-bottom: 1px solid #808080; font-weight: normal; font-size: 12px; color: #000000; + display: flex; + justify-content: space-between; + align-items: center; + height: 28px; + box-sizing: border-box; +} + +.editor-controls { + display: flex; + align-items: center; + gap: 6px; + height: 20px; +} + +.view-label { + font-size: 10px; + color: #000000; + margin-right: 4px; +} + +.view-toggle-group { + display: flex; +} + +.view-toggle-btn { + background: #c0c0c0; + border: 1px solid #808080; + color: #000000; + padding: 2px 8px; + cursor: pointer; + font-size: 10px; + font-family: 'MS Sans Serif', Tahoma, sans-serif; + height: 20px; + box-sizing: border-box; +} + +.view-toggle-btn:first-child { + border-right: 1px solid #808080; +} + +.view-toggle-btn:last-child { + border-left: none; +} + +.view-toggle-btn:hover:not(.active) { + background: #d4d0c8; +} + +.view-toggle-btn:active { + background: #316ac5; + color: #ffffff; +} + +.view-toggle-btn.active { + background: #316ac5; + color: #ffffff; + border-top: 1px solid #0a246a; + border-left: 1px solid #0a246a; + border-bottom: 1px solid #4a7ac5; + border-right: 1px solid #4a7ac5; +} + +.view-toggle-btn.active:first-child { + border-right: 1px solid #4a7ac5; +} + +.view-toggle-btn.active:last-child { + border-left: 1px solid #0a246a; +} + +.action-btn { + border: 2px outset #c0c0c0; + color: #000000; + padding: 2px 8px; + cursor: pointer; + font-size: 10px; + font-family: 'MS Sans Serif', Tahoma, sans-serif; + display: none; + height: 20px; + box-sizing: border-box; +} + +.action-btn.visible { + display: block; +} + +.action-btn:hover { + filter: brightness(1.1); +} + +.action-btn:active { + border-style: inset; +} + +.action-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.publish-btn { + background: #90ee90; +} + +.publish-btn:hover { + background: #a0ffa0; +} + +.revert-btn { + background: #ffb080; +} + +.revert-btn:hover { + background: #ffc090; } /* Sort controls */ @@ -287,10 +400,35 @@ body { margin-bottom: 2px; cursor: pointer; background: #ffffff; + display: flex; + justify-content: space-between; + align-items: center; +} + +.snippet-info { + flex: 1; +} + +.snippet-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + margin-left: 8px; +} + +.snippet-status.published { + background: #00ff00; + box-shadow: 0 0 2px #00cc00; +} + +.snippet-status.draft { + background: #ffff00; + box-shadow: 0 0 2px #cccc00; } .snippet-item:hover { - background: #316ac5; + background: #6a9ad5; color: #ffffff; } @@ -299,6 +437,10 @@ body { color: #ffffff; } +.snippet-item.selected:hover { + background: #316ac5; +} + .snippet-name { font-weight: normal; font-size: 12px;