mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
feat: implement draft/published workflow with toggle and status indicators
This commit is contained in:
@@ -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
|
||||
- **Data Model**: Phase 0 schema with `spec` (published) and `draftSpec` (working) fields
|
||||
11
index.html
11
index.html
@@ -103,7 +103,16 @@
|
||||
<!-- Editor Panel -->
|
||||
<div class="panel editor-panel" id="editor-panel">
|
||||
<div class="panel-header">
|
||||
Editor
|
||||
<span>Editor</span>
|
||||
<div class="editor-controls">
|
||||
<button class="action-btn publish-btn" id="publish-btn">Publish</button>
|
||||
<button class="action-btn revert-btn" id="revert-btn">Revert</button>
|
||||
<span class="view-label">View:</span>
|
||||
<div class="view-toggle-group">
|
||||
<button class="view-toggle-btn active" id="view-draft">Draft</button>
|
||||
<button class="view-toggle-btn" id="view-published">Published</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="monaco-editor" style="height: 100%; width: 100%;"></div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `
|
||||
<li class="snippet-item" data-snippet-id="${snippet.id}">
|
||||
<div class="snippet-name">${snippet.name}</div>
|
||||
<div class="snippet-date">${dateText}</div>
|
||||
<div class="snippet-info">
|
||||
<div class="snippet-name">${snippet.name}</div>
|
||||
<div class="snippet-date">${dateText}</div>
|
||||
</div>
|
||||
<div class="snippet-status ${statusClass}"></div>
|
||||
</li>
|
||||
`;
|
||||
}).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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
146
src/styles.css
146
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;
|
||||
|
||||
Reference in New Issue
Block a user