mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 13:12:23 +00:00
Merge pull request #1 from olehomelchenko/refactor/alpinejs
Refactor/alpinejs
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node --check:*)"
|
||||
"Bash(node --check:*)",
|
||||
"WebSearch"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -21,6 +21,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Removed
|
||||
- (Removed features will be listed here)
|
||||
|
||||
---
|
||||
## [0.4.0] - 2025-11-26
|
||||
|
||||
### Changed
|
||||
- **Alpine.js Integration**: Migrated interactive UI components to Alpine.js framework for improved reactivity and maintainability
|
||||
- Chart Builder controls now use Alpine.js reactive data binding
|
||||
- Preview Panel fit mode controls migrated to Alpine stores
|
||||
- Toast notification system backed by Alpine store with declarative rendering
|
||||
- Simplified state management with reactive Alpine stores
|
||||
- No user-facing behavior changes (internal architecture refactor)
|
||||
- Improved code organization and reduced DOM manipulation complexity
|
||||
|
||||
---
|
||||
## [0.2.0] - 2025-11-17
|
||||
|
||||
|
||||
579
index.html
579
index.html
@@ -25,6 +25,9 @@
|
||||
|
||||
<!-- Monaco Editor -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -33,7 +36,7 @@
|
||||
<div class="header-left">
|
||||
<img src="src/favicon.svg" class="header-icon" alt="Astrolabe">
|
||||
<span class="header-title">Astrolabe</span>
|
||||
<span class="version-badge" id="app-version-badge">v0.3.0</span>
|
||||
<span class="version-badge" id="app-version-badge">v0.4.0</span>
|
||||
</div>
|
||||
<div class="header-links">
|
||||
<span class="header-link" id="import-link" title="Import snippets and datasets">Import</span>
|
||||
@@ -48,14 +51,20 @@
|
||||
|
||||
<div class="app-container">
|
||||
<!-- Toggle Button Strip -->
|
||||
<div class="toggle-strip">
|
||||
<button class="btn btn-icon xlarge active" id="toggle-snippet-panel" title="Toggle Snippets Panel">
|
||||
<div class="toggle-strip" x-data>
|
||||
<button class="btn btn-icon xlarge" id="toggle-snippet-panel"
|
||||
:class="{ 'active': $store.panels.snippetVisible }" @click="togglePanel('snippet-panel')"
|
||||
title="Toggle Snippets Panel">
|
||||
📄
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge active" id="toggle-editor-panel" title="Toggle Editor Panel">
|
||||
<button class="btn btn-icon xlarge" id="toggle-editor-panel"
|
||||
:class="{ 'active': $store.panels.editorVisible }" @click="togglePanel('editor-panel')"
|
||||
title="Toggle Editor Panel">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge active" id="toggle-preview-panel" title="Toggle Preview Panel">
|
||||
<button class="btn btn-icon xlarge" id="toggle-preview-panel"
|
||||
:class="{ 'active': $store.panels.previewVisible }" @click="togglePanel('preview-panel')"
|
||||
title="Toggle Preview Panel">
|
||||
👁️
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge" id="toggle-datasets" title="Datasets">
|
||||
@@ -65,46 +74,78 @@
|
||||
|
||||
<div class="main-panels">
|
||||
<!-- Snippet Library Panel -->
|
||||
<div class="panel snippet-panel" id="snippet-panel">
|
||||
<div class="panel snippet-panel" id="snippet-panel" x-data="snippetList()">
|
||||
<div class="panel-header">
|
||||
Snippets
|
||||
</div>
|
||||
<div class="sort-controls">
|
||||
<span class="sort-label">Sort by:</span>
|
||||
<button class="sort-btn active" data-sort="modified" title="Sort by last modified date">
|
||||
<button class="sort-btn" :class="{ 'active': sortBy === 'modified' }"
|
||||
@click="toggleSort('modified')" title="Sort by last modified date">
|
||||
<span class="sort-text">Modified</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
<span class="sort-arrow"
|
||||
x-text="sortBy === 'modified' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="created" title="Sort by creation date">
|
||||
<button class="sort-btn" :class="{ 'active': sortBy === 'created' }" @click="toggleSort('created')"
|
||||
title="Sort by creation date">
|
||||
<span class="sort-text">Created</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
<span class="sort-arrow"
|
||||
x-text="sortBy === 'created' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="name" title="Sort alphabetically by name">
|
||||
<button class="sort-btn" :class="{ 'active': sortBy === 'name' }" @click="toggleSort('name')"
|
||||
title="Sort alphabetically by name">
|
||||
<span class="sort-text">Name</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
<span class="sort-arrow" x-text="sortBy === 'name' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="size" title="Sort by snippet size">
|
||||
<button class="sort-btn" :class="{ 'active': sortBy === 'size' }" @click="toggleSort('size')"
|
||||
title="Sort by snippet size">
|
||||
<span class="sort-text">Size</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
<span class="sort-arrow" x-text="sortBy === 'size' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-controls">
|
||||
<input type="text" id="snippet-search" placeholder="Search snippets..." />
|
||||
<button class="btn btn-icon" id="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="snippet-search" x-model="searchQuery" placeholder="Search snippets..." />
|
||||
<button class="btn btn-icon" @click="clearSearch()" title="Clear search">×</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<ul class="snippet-list" id="snippet-list">
|
||||
<!-- Dynamically populated by renderSnippetList() -->
|
||||
<!-- Ghost card for creating new snippets -->
|
||||
<li class="snippet-item ghost-card" id="new-snippet-card" @click="createNewSnippet()">
|
||||
<div class="snippet-name">+ Create New Snippet</div>
|
||||
<div class="snippet-date">Click to create</div>
|
||||
</li>
|
||||
|
||||
<!-- Snippet items -->
|
||||
<template x-for="snippet in filteredSnippets" :key="snippet.id">
|
||||
<li class="snippet-item" :data-item-id="snippet.id"
|
||||
:class="{ 'selected': $store.snippets.currentSnippetId === snippet.id }"
|
||||
@click="selectSnippet(snippet.id)">
|
||||
<div class="snippet-info">
|
||||
<div class="snippet-name">
|
||||
<span x-text="snippet.name"></span>
|
||||
<span x-show="snippet.datasetRefs && snippet.datasetRefs.length > 0"
|
||||
class="snippet-dataset-icon" title="Uses external dataset">📁</span>
|
||||
</div>
|
||||
<div class="snippet-date" x-text="formatDate(snippet)"></div>
|
||||
</div>
|
||||
<span x-show="getSize(snippet) >= 1" class="snippet-size"
|
||||
x-text="getSize(snippet).toFixed(0) + ' KB'"></span>
|
||||
<div class="snippet-status" :class="hasDraft(snippet) ? 'draft' : 'published'"></div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="placeholder">
|
||||
<div class="placeholder" x-show="filteredSnippets.length === 0"
|
||||
x-text="searchQuery.trim() ? 'No snippets match your search' : 'No snippets found'">
|
||||
Click to select a snippet
|
||||
</div>
|
||||
<div class="snippet-meta" id="snippet-meta" style="display: none;">
|
||||
<div class="meta-header">Name</div>
|
||||
<input type="text" id="snippet-name" class="input small" placeholder="Snippet name..." />
|
||||
<input type="text" id="snippet-name" class="input small" placeholder="Snippet name..."
|
||||
x-model="snippetName" @input="saveMetaDebounced()" />
|
||||
|
||||
<div class="meta-header">Comment</div>
|
||||
<textarea id="snippet-comment" class="input textarea medium" placeholder="Add a comment..." rows="3"></textarea>
|
||||
<textarea id="snippet-comment" class="input textarea medium" placeholder="Add a comment..."
|
||||
rows="3" x-model="snippetComment" @input="saveMetaDebounced()"></textarea>
|
||||
|
||||
<div class="meta-info">
|
||||
<div class="meta-info-item">
|
||||
@@ -125,8 +166,10 @@
|
||||
</div>
|
||||
|
||||
<div class="meta-actions">
|
||||
<button class="btn btn-standard flex" id="duplicate-btn" title="Create a copy of this snippet">Duplicate</button>
|
||||
<button class="btn btn-standard flex danger" id="delete-btn" title="Delete this snippet permanently">Delete</button>
|
||||
<button class="btn btn-standard flex" id="duplicate-btn"
|
||||
title="Create a copy of this snippet">Duplicate</button>
|
||||
<button class="btn btn-standard flex danger" id="delete-btn"
|
||||
title="Delete this snippet permanently">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-monitor" id="storage-monitor">
|
||||
@@ -145,17 +188,26 @@
|
||||
<div class="resize-handle" id="resize-handle-1"></div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div class="panel editor-panel" id="editor-panel">
|
||||
<div class="panel editor-panel" id="editor-panel" x-data>
|
||||
<div class="panel-header">
|
||||
<span>Editor</span>
|
||||
<div class="editor-controls">
|
||||
<button class="btn btn-action" id="extract-btn" style="display: none; background: #87CEEB;" title="Extract inline data to a reusable dataset">Extract to Dataset</button>
|
||||
<button class="btn btn-action publish" id="publish-btn" title="Publish draft changes (Cmd/Ctrl+S)">Publish</button>
|
||||
<button class="btn btn-action revert" id="revert-btn" title="Discard draft and revert to published version">Revert</button>
|
||||
<button class="btn btn-action" id="extract-btn" style="display: none; background: #87CEEB;"
|
||||
title="Extract inline data to a reusable dataset">Extract to Dataset</button>
|
||||
<button class="btn btn-action publish" id="publish-btn"
|
||||
title="Publish draft changes (Cmd/Ctrl+S)">Publish</button>
|
||||
<button class="btn btn-action revert" id="revert-btn"
|
||||
title="Discard draft and revert to published version">Revert</button>
|
||||
<span class="view-label">View:</span>
|
||||
<div class="view-toggle-group">
|
||||
<button class="btn btn-toggle active" id="view-draft" title="View and edit draft version">Draft</button>
|
||||
<button class="btn btn-toggle" id="view-published" title="View published version (read-only if draft exists)">Published</button>
|
||||
<button class="btn btn-toggle" id="view-draft"
|
||||
:class="{ 'active': $store.snippets.viewMode === 'draft' }"
|
||||
@click="$store.snippets.viewMode = 'draft'; switchViewMode('draft')"
|
||||
title="View and edit draft version">Draft</button>
|
||||
<button class="btn btn-toggle" id="view-published"
|
||||
:class="{ 'active': $store.snippets.viewMode === 'published' }"
|
||||
@click="$store.snippets.viewMode = 'published'; switchViewMode('published')"
|
||||
title="View published version (read-only if draft exists)">Published</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,17 +223,27 @@
|
||||
<div class="panel preview-panel" id="preview-panel">
|
||||
<div class="panel-header">
|
||||
<span>Preview</span>
|
||||
<div class="preview-controls">
|
||||
<div class="preview-controls" x-data>
|
||||
<span class="view-label">Fit:</span>
|
||||
<div class="view-toggle-group">
|
||||
<button class="btn btn-toggle active" id="preview-fit-default" title="Display at original spec dimensions">Original</button>
|
||||
<button class="btn btn-toggle" id="preview-fit-width" title="Scale to fit preview pane width (⚠️ for faceted specs, applies to each facet)">Width</button>
|
||||
<button class="btn btn-toggle" id="preview-fit-full" title="Scale to fit entire preview pane (⚠️ for faceted specs, applies to each facet)">Full</button>
|
||||
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'default' }"
|
||||
@click="$store.preview.fitMode = 'default'; setPreviewFitMode('default')"
|
||||
id="preview-fit-default" title="Display at original spec dimensions">Original</button>
|
||||
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'width' }"
|
||||
@click="$store.preview.fitMode = 'width'; setPreviewFitMode('width')"
|
||||
id="preview-fit-width"
|
||||
title="Scale to fit preview pane width (⚠️ for faceted specs, applies to each facet)">Width</button>
|
||||
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'full' }"
|
||||
@click="$store.preview.fitMode = 'full'; setPreviewFitMode('full')"
|
||||
id="preview-fit-full"
|
||||
title="Scale to fit entire preview pane (⚠️ for faceted specs, applies to each facet)">Full</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content" style="position: relative;">
|
||||
<div id="vega-preview" style="height: 100%; width: 100%; overflow: auto; display: flex; align-items: center; justify-content: center;"></div>
|
||||
<div id="vega-preview"
|
||||
style="height: 100%; width: 100%; overflow: auto; display: flex; align-items: center; justify-content: center;">
|
||||
</div>
|
||||
<div id="preview-overlay" class="preview-overlay" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,36 +259,65 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- List View (default) -->
|
||||
<div id="dataset-list-view" class="dataset-view">
|
||||
<div id="dataset-list-view" class="dataset-view" x-data="datasetList()">
|
||||
<div class="dataset-list-header">
|
||||
<button class="btn btn-modal primary" id="new-dataset-btn" title="Create a new dataset">New Dataset</button>
|
||||
<button class="btn btn-modal" id="import-dataset-btn" title="Import dataset from file">Import</button>
|
||||
<input type="file" id="import-dataset-file" accept=".json,.csv,.tsv,.txt" style="display: none;" />
|
||||
<button class="btn btn-modal primary" id="new-dataset-btn" title="Create a new dataset">New
|
||||
Dataset</button>
|
||||
<button class="btn btn-modal" id="import-dataset-btn"
|
||||
title="Import dataset from file">Import</button>
|
||||
<input type="file" id="import-dataset-file" accept=".json,.csv,.tsv,.txt"
|
||||
style="display: none;" />
|
||||
</div>
|
||||
<div class="dataset-container">
|
||||
<div class="dataset-list" id="dataset-list">
|
||||
<!-- Dynamically populated by renderDatasetList() -->
|
||||
<!-- Dataset items rendered by Alpine.js -->
|
||||
<template x-for="dataset in datasets" :key="dataset.id">
|
||||
<div class="dataset-item" :data-item-id="dataset.id"
|
||||
:class="{ 'selected': $store.datasets.currentDatasetId === dataset.id }"
|
||||
@click="selectDataset(dataset.id)">
|
||||
<div class="dataset-info">
|
||||
<div class="dataset-name" x-text="dataset.name"></div>
|
||||
<div class="dataset-meta" x-text="formatMeta(dataset)"></div>
|
||||
</div>
|
||||
<div class="dataset-usage-badge" x-show="getUsageCount(dataset) > 0"
|
||||
:title="getUsageCount(dataset) + ' snippet' + (getUsageCount(dataset) !== 1 ? 's' : '') + ' using this dataset'"
|
||||
x-text="'📄 ' + getUsageCount(dataset)">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dataset-empty" x-show="datasets.length === 0">
|
||||
No datasets yet. Click "New Dataset" to create one.
|
||||
</div>
|
||||
</div>
|
||||
<div class="dataset-details" id="dataset-details" style="display: none;">
|
||||
<div class="dataset-detail-section">
|
||||
<div class="dataset-actions">
|
||||
<button class="btn btn-modal primary" id="edit-dataset-btn" title="Edit this dataset contents">Edit Contents</button>
|
||||
<button class="btn btn-modal primary" id="build-chart-btn" title="Build a chart using this dataset">Build Chart</button>
|
||||
<button class="btn btn-modal primary" id="new-snippet-btn" title="Create a new snippet using this dataset">New Snippet</button>
|
||||
<button class="btn btn-modal" id="export-dataset-btn" title="Export this dataset to file">Export</button>
|
||||
<button class="btn btn-modal" id="copy-reference-btn" title="Copy dataset reference to clipboard">Copy Reference</button>
|
||||
<button class="btn btn-modal danger" id="delete-dataset-btn" title="Delete this dataset permanently">Delete</button>
|
||||
<button class="btn btn-modal primary" id="edit-dataset-btn"
|
||||
title="Edit this dataset contents">Edit Contents</button>
|
||||
<button class="btn btn-modal primary" id="build-chart-btn"
|
||||
title="Build a chart using this dataset">Build Chart</button>
|
||||
<button class="btn btn-modal primary" id="new-snippet-btn"
|
||||
title="Create a new snippet using this dataset">New Snippet</button>
|
||||
<button class="btn btn-modal" id="export-dataset-btn"
|
||||
title="Export this dataset to file">Export</button>
|
||||
<button class="btn btn-modal" id="copy-reference-btn"
|
||||
title="Copy dataset reference to clipboard">Copy Reference</button>
|
||||
<button class="btn btn-modal danger" id="delete-dataset-btn"
|
||||
title="Delete this dataset permanently">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="dataset-detail-header">Name</div>
|
||||
<input type="text" id="dataset-detail-name" class="input" placeholder="Dataset name..." />
|
||||
<input type="text" id="dataset-detail-name" class="input"
|
||||
placeholder="Dataset name..." />
|
||||
|
||||
<div class="dataset-detail-header">Comment</div>
|
||||
<textarea id="dataset-detail-comment" class="input textarea" placeholder="Add a comment..." rows="3"></textarea>
|
||||
<textarea id="dataset-detail-comment" class="input textarea"
|
||||
placeholder="Add a comment..." rows="3"></textarea>
|
||||
|
||||
<div class="dataset-detail-header-row">
|
||||
<span class="dataset-detail-header">Overview</span>
|
||||
<button class="btn btn-icon large" id="refresh-metadata-btn" style="display: none;" title="Refresh metadata from URL">🔄</button>
|
||||
<button class="btn btn-icon large" id="refresh-metadata-btn" style="display: none;"
|
||||
title="Refresh metadata from URL">🔄</button>
|
||||
</div>
|
||||
<div class="dataset-overview-grid">
|
||||
<div class="overview-section">
|
||||
@@ -272,12 +363,15 @@
|
||||
<div class="dataset-detail-header-row">
|
||||
<span class="dataset-detail-header">Preview</span>
|
||||
<div class="preview-toggle-group" id="preview-toggle-group" style="display: none;">
|
||||
<button class="btn btn-toggle small active" id="preview-raw-btn" title="Show raw data preview">Raw</button>
|
||||
<button class="btn btn-toggle small" id="preview-table-btn" title="Show data in table format with type detection">Table</button>
|
||||
<button class="btn btn-toggle small active" id="preview-raw-btn"
|
||||
title="Show raw data preview">Raw</button>
|
||||
<button class="btn btn-toggle small" id="preview-table-btn"
|
||||
title="Show data in table format with type detection">Table</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="dataset-preview" class="preview-box large"></pre>
|
||||
<div id="dataset-preview-table" class="preview-table-container" style="display: none;"></div>
|
||||
<div id="dataset-preview-table" class="preview-table-container" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div id="dataset-snippets-section" style="display: none;">
|
||||
<div class="dataset-detail-header">Linked Snippets</div>
|
||||
@@ -297,7 +391,8 @@
|
||||
|
||||
<div class="dataset-form-group">
|
||||
<label class="dataset-form-label">Name *</label>
|
||||
<input type="text" id="dataset-form-name" class="input" placeholder="Enter dataset name..." />
|
||||
<input type="text" id="dataset-form-name" class="input"
|
||||
placeholder="Enter dataset name..." />
|
||||
</div>
|
||||
|
||||
<div class="dataset-form-group">
|
||||
@@ -305,7 +400,8 @@
|
||||
<div class="dataset-format-hint">
|
||||
Paste your data (JSON, CSV, or TSV) or a URL. Format will be detected automatically.
|
||||
</div>
|
||||
<textarea id="dataset-form-input" class="input textarea" placeholder="Paste data or URL here..." rows="12"></textarea>
|
||||
<textarea id="dataset-form-input" class="input textarea"
|
||||
placeholder="Paste data or URL here..." rows="12"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Detection Confirmation UI -->
|
||||
@@ -315,7 +411,8 @@
|
||||
<div class="detection-badges">
|
||||
<span class="detection-badge" id="detected-format">JSON</span>
|
||||
<span class="detection-badge" id="detected-source">Inline</span>
|
||||
<span class="detected-confidence high" id="detected-confidence">high confidence</span>
|
||||
<span class="detected-confidence high" id="detected-confidence">high
|
||||
confidence</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detection-preview-label">Preview:</div>
|
||||
@@ -324,14 +421,17 @@
|
||||
|
||||
<div class="dataset-form-group">
|
||||
<label class="dataset-form-label">Comment</label>
|
||||
<textarea id="dataset-form-comment" class="input textarea" placeholder="Optional description..." rows="3"></textarea>
|
||||
<textarea id="dataset-form-comment" class="input textarea"
|
||||
placeholder="Optional description..." rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dataset-form-error" id="dataset-form-error"></div>
|
||||
|
||||
<div class="dataset-form-actions">
|
||||
<button class="btn btn-modal primary" id="save-dataset-btn" title="Save this dataset">Save Dataset</button>
|
||||
<button class="btn btn-modal" id="cancel-dataset-btn" title="Cancel and return to dataset list">Cancel</button>
|
||||
<button class="btn btn-modal primary" id="save-dataset-btn" title="Save this dataset">Save
|
||||
Dataset</button>
|
||||
<button class="btn btn-modal" id="cancel-dataset-btn"
|
||||
title="Cancel and return to dataset list">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +450,8 @@
|
||||
<div style="padding: 16px;">
|
||||
<div class="dataset-form-group">
|
||||
<label class="dataset-form-label">Dataset Name *</label>
|
||||
<input type="text" id="extract-dataset-name" class="input" placeholder="Enter dataset name..." />
|
||||
<input type="text" id="extract-dataset-name" class="input"
|
||||
placeholder="Enter dataset name..." />
|
||||
</div>
|
||||
|
||||
<div class="dataset-form-group">
|
||||
@@ -361,7 +462,8 @@
|
||||
<div class="dataset-form-error" id="extract-form-error"></div>
|
||||
|
||||
<div class="dataset-form-actions">
|
||||
<button class="btn btn-modal primary" id="extract-create-btn" title="Create dataset and update snippet reference">Create Dataset</button>
|
||||
<button class="btn btn-modal primary" id="extract-create-btn"
|
||||
title="Create dataset and update snippet reference">Create Dataset</button>
|
||||
<button class="btn btn-modal" id="extract-cancel-btn" title="Cancel extraction">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,27 +476,39 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Build Chart</span>
|
||||
<button class="btn btn-icon" id="chart-builder-modal-close" title="Close chart builder (Escape)">×</button>
|
||||
<button class="btn btn-icon" id="chart-builder-modal-close"
|
||||
@click="$el.closest('#chart-builder-view')._x_dataStack[0].close()"
|
||||
title="Close chart builder (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Chart Builder View -->
|
||||
<div id="chart-builder-view" class="chart-builder-view">
|
||||
<div id="chart-builder-view" class="chart-builder-view" x-data="chartBuilder()">
|
||||
<div class="chart-builder-container">
|
||||
<!-- Left Panel: Configuration -->
|
||||
<div class="chart-builder-config">
|
||||
<div class="chart-builder-header">
|
||||
<button class="btn btn-modal" id="chart-builder-back-btn" title="Back to dataset details">← Back to Dataset</button>
|
||||
<button class="btn btn-modal" id="chart-builder-back-btn" @click="close()"
|
||||
title="Back to dataset details">← Back to Dataset</button>
|
||||
</div>
|
||||
|
||||
<div class="chart-builder-section">
|
||||
<div class="mark-type-row">
|
||||
<label class="chart-builder-label">Mark Type*</label>
|
||||
<div class="mark-toggle-group">
|
||||
<button class="btn btn-toggle small active" data-mark="bar" title="Bar chart">Bar</button>
|
||||
<button class="btn btn-toggle small" data-mark="line" title="Line chart">Line</button>
|
||||
<button class="btn btn-toggle small" data-mark="point" title="Point chart">Point</button>
|
||||
<button class="btn btn-toggle small" data-mark="area" title="Area chart">Area</button>
|
||||
<button class="btn btn-toggle small" data-mark="circle" title="Circle chart">Circle</button>
|
||||
<button class="btn btn-toggle small" :class="{ 'active': markType === 'bar' }"
|
||||
@click="setMarkType('bar')" data-mark="bar" title="Bar chart">Bar</button>
|
||||
<button class="btn btn-toggle small" :class="{ 'active': markType === 'line' }"
|
||||
@click="setMarkType('line')" data-mark="line"
|
||||
title="Line chart">Line</button>
|
||||
<button class="btn btn-toggle small" :class="{ 'active': markType === 'point' }"
|
||||
@click="setMarkType('point')" data-mark="point"
|
||||
title="Point chart">Point</button>
|
||||
<button class="btn btn-toggle small" :class="{ 'active': markType === 'area' }"
|
||||
@click="setMarkType('area')" data-mark="area"
|
||||
title="Area chart">Area</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': markType === 'circle' }" @click="setMarkType('circle')"
|
||||
data-mark="circle" title="Circle chart">Circle</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,17 +520,30 @@
|
||||
<div class="encoding-group">
|
||||
<div class="encoding-row">
|
||||
<label class="encoding-header">X Axis</label>
|
||||
<select id="encoding-x-field" class="input">
|
||||
<select id="encoding-x-field" class="input" x-model="encodings.x.field"
|
||||
@change="setEncodingField('x', $event.target.value)">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="encoding-type">
|
||||
<label class="encoding-type-label">Type</label>
|
||||
<div class="type-toggle-group">
|
||||
<button class="btn btn-toggle small" data-encoding="x" data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small" data-encoding="x" data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small" data-encoding="x" data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small" data-encoding="x" data-type="temporal" title="Temporal">T</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.x.type === 'quantitative' }"
|
||||
@click="setEncodingType('x', 'quantitative')" data-encoding="x"
|
||||
data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.x.type === 'ordinal' }"
|
||||
@click="setEncodingType('x', 'ordinal')" data-encoding="x"
|
||||
data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.x.type === 'nominal' }"
|
||||
@click="setEncodingType('x', 'nominal')" data-encoding="x"
|
||||
data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.x.type === 'temporal' }"
|
||||
@click="setEncodingType('x', 'temporal')" data-encoding="x"
|
||||
data-type="temporal" title="Temporal">T</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,17 +552,30 @@
|
||||
<div class="encoding-group">
|
||||
<div class="encoding-row">
|
||||
<label class="encoding-header">Y Axis</label>
|
||||
<select id="encoding-y-field" class="input">
|
||||
<select id="encoding-y-field" class="input" x-model="encodings.y.field"
|
||||
@change="setEncodingField('y', $event.target.value)">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="encoding-type">
|
||||
<label class="encoding-type-label">Type</label>
|
||||
<div class="type-toggle-group">
|
||||
<button class="btn btn-toggle small" data-encoding="y" data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small" data-encoding="y" data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small" data-encoding="y" data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small" data-encoding="y" data-type="temporal" title="Temporal">T</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.y.type === 'quantitative' }"
|
||||
@click="setEncodingType('y', 'quantitative')" data-encoding="y"
|
||||
data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.y.type === 'ordinal' }"
|
||||
@click="setEncodingType('y', 'ordinal')" data-encoding="y"
|
||||
data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.y.type === 'nominal' }"
|
||||
@click="setEncodingType('y', 'nominal')" data-encoding="y"
|
||||
data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.y.type === 'temporal' }"
|
||||
@click="setEncodingType('y', 'temporal')" data-encoding="y"
|
||||
data-type="temporal" title="Temporal">T</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,17 +584,30 @@
|
||||
<div class="encoding-group">
|
||||
<div class="encoding-row">
|
||||
<label class="encoding-header">Color</label>
|
||||
<select id="encoding-color-field" class="input">
|
||||
<select id="encoding-color-field" class="input" x-model="encodings.color.field"
|
||||
@change="setEncodingField('color', $event.target.value)">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="encoding-type">
|
||||
<label class="encoding-type-label">Type</label>
|
||||
<div class="type-toggle-group">
|
||||
<button class="btn btn-toggle small" data-encoding="color" data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small" data-encoding="color" data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small" data-encoding="color" data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small" data-encoding="color" data-type="temporal" title="Temporal">T</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.color.type === 'quantitative' }"
|
||||
@click="setEncodingType('color', 'quantitative')" data-encoding="color"
|
||||
data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.color.type === 'ordinal' }"
|
||||
@click="setEncodingType('color', 'ordinal')" data-encoding="color"
|
||||
data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.color.type === 'nominal' }"
|
||||
@click="setEncodingType('color', 'nominal')" data-encoding="color"
|
||||
data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.color.type === 'temporal' }"
|
||||
@click="setEncodingType('color', 'temporal')" data-encoding="color"
|
||||
data-type="temporal" title="Temporal">T</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,17 +616,30 @@
|
||||
<div class="encoding-group">
|
||||
<div class="encoding-row">
|
||||
<label class="encoding-header">Size</label>
|
||||
<select id="encoding-size-field" class="input">
|
||||
<select id="encoding-size-field" class="input" x-model="encodings.size.field"
|
||||
@change="setEncodingField('size', $event.target.value)">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="encoding-type">
|
||||
<label class="encoding-type-label">Type</label>
|
||||
<div class="type-toggle-group">
|
||||
<button class="btn btn-toggle small" data-encoding="size" data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small" data-encoding="size" data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small" data-encoding="size" data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small" data-encoding="size" data-type="temporal" title="Temporal">T</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.size.type === 'quantitative' }"
|
||||
@click="setEncodingType('size', 'quantitative')" data-encoding="size"
|
||||
data-type="quantitative" title="Quantitative">Q</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.size.type === 'ordinal' }"
|
||||
@click="setEncodingType('size', 'ordinal')" data-encoding="size"
|
||||
data-type="ordinal" title="Ordinal">O</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.size.type === 'nominal' }"
|
||||
@click="setEncodingType('size', 'nominal')" data-encoding="size"
|
||||
data-type="nominal" title="Nominal">N</button>
|
||||
<button class="btn btn-toggle small"
|
||||
:class="{ 'active': encodings.size.type === 'temporal' }"
|
||||
@click="setEncodingType('size', 'temporal')" data-encoding="size"
|
||||
data-type="temporal" title="Temporal">T</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,11 +650,14 @@
|
||||
<div class="chart-dimensions-group">
|
||||
<div class="dimension-input-group">
|
||||
<label class="dimension-label">Width</label>
|
||||
<input type="number" id="chart-width" class="input small" placeholder="auto" min="1" />
|
||||
<input type="number" id="chart-width" class="input small" x-model.number="width"
|
||||
@input="updatePreview()" placeholder="auto" min="1" />
|
||||
</div>
|
||||
<div class="dimension-input-group">
|
||||
<label class="dimension-label">Height</label>
|
||||
<input type="number" id="chart-height" class="input small" placeholder="auto" min="1" />
|
||||
<input type="number" id="chart-height" class="input small"
|
||||
x-model.number="height" @input="updatePreview()" placeholder="auto"
|
||||
min="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,8 +665,11 @@
|
||||
<div class="chart-builder-error" id="chart-builder-error"></div>
|
||||
|
||||
<div class="chart-builder-actions">
|
||||
<button class="btn btn-modal primary" id="chart-builder-create-btn" title="Create snippet from chart" disabled>Create Snippet</button>
|
||||
<button class="btn btn-modal" id="chart-builder-cancel-btn" title="Cancel and close">Cancel</button>
|
||||
<button class="btn btn-modal primary" id="chart-builder-create-btn"
|
||||
@click="createSnippet()" :disabled="!isValid"
|
||||
title="Create snippet from chart">Create Snippet</button>
|
||||
<button class="btn btn-modal" id="chart-builder-cancel-btn" @click="close()"
|
||||
title="Cancel and close">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -530,13 +702,16 @@
|
||||
<h3 class="help-heading">About Astrolabe</h3>
|
||||
<p class="help-text">
|
||||
Astrolabe is a lightweight, browser-based snippet manager for Vega-Lite visualizations.
|
||||
It's designed to help you quickly create, organize, and iterate on visualization specs without
|
||||
It's designed to help you quickly create, organize, and iterate on visualization specs
|
||||
without
|
||||
the overhead of a full development environment.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
Everything runs locally in your browser—no server, no signup, no data leaving your machine.
|
||||
Your snippets and datasets are stored using browser storage, so they persist across sessions.
|
||||
As a Progressive Web App, Astrolabe works fully offline after your first visit and can be installed
|
||||
Your snippets and datasets are stored using browser storage, so they persist across
|
||||
sessions.
|
||||
As a Progressive Web App, Astrolabe works fully offline after your first visit and can be
|
||||
installed
|
||||
as a standalone application.
|
||||
</p>
|
||||
</section>
|
||||
@@ -545,13 +720,19 @@
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Key Features</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Three-panel workspace</strong> — Snippet library, Monaco code editor with Vega-Lite schema validation, and live preview</li>
|
||||
<li><strong>Draft/published workflow</strong> — Experiment safely without losing your working version</li>
|
||||
<li><strong>Dataset library</strong> — Store and reuse datasets across multiple visualizations (supports JSON, CSV, TSV, TopoJSON)</li>
|
||||
<li><strong>Offline-capable</strong> — Works without internet connection after first visit; install as standalone app</li>
|
||||
<li><strong>Three-panel workspace</strong> — Snippet library, Monaco code editor with
|
||||
Vega-Lite schema validation, and live preview</li>
|
||||
<li><strong>Draft/published workflow</strong> — Experiment safely without losing your
|
||||
working version</li>
|
||||
<li><strong>Dataset library</strong> — Store and reuse datasets across multiple
|
||||
visualizations (supports JSON, CSV, TSV, TopoJSON)</li>
|
||||
<li><strong>Offline-capable</strong> — Works without internet connection after first visit;
|
||||
install as standalone app</li>
|
||||
<li><strong>Import/export</strong> — Back up your work or move it between browsers</li>
|
||||
<li><strong>Inline data extraction</strong> — Convert hardcoded data into reusable datasets</li>
|
||||
<li><strong>Search and sorting</strong> — Find snippets by name, comment, or spec content</li>
|
||||
<li><strong>Inline data extraction</strong> — Convert hardcoded data into reusable datasets
|
||||
</li>
|
||||
<li><strong>Search and sorting</strong> — Find snippets by name, comment, or spec content
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -561,19 +742,23 @@
|
||||
<div class="help-workflow">
|
||||
<div class="help-step">
|
||||
<strong>1. Create a snippet</strong>
|
||||
<p>Click the "Create New Snippet" ghost card at the top of the snippet list. A sample Vega-Lite spec loads automatically.</p>
|
||||
<p>Click the "Create New Snippet" ghost card at the top of the snippet list. A sample
|
||||
Vega-Lite spec loads automatically.</p>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<strong>2. Edit in the Draft view</strong>
|
||||
<p>Changes auto-save as you type. The preview updates automatically. Your published version stays safe until you're ready.</p>
|
||||
<p>Changes auto-save as you type. The preview updates automatically. Your published
|
||||
version stays safe until you're ready.</p>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<strong>3. Publish when ready</strong>
|
||||
<p>Click "Publish" (or Cmd/Ctrl+S) to save your draft as the official version. Use "Revert" if you want to discard changes.</p>
|
||||
<p>Click "Publish" (or Cmd/Ctrl+S) to save your draft as the official version. Use
|
||||
"Revert" if you want to discard changes.</p>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<strong>4. Organize with datasets</strong>
|
||||
<p>Open the Dataset Manager to create reusable datasets. Reference them in your specs using <code>{"data": {"name": "dataset-name"}}</code>.</p>
|
||||
<p>Open the Dataset Manager to create reusable datasets. Reference them in your specs
|
||||
using <code>{"data": {"name": "dataset-name"}}</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -612,14 +797,19 @@
|
||||
<h3 class="help-heading">Storage & Limits</h3>
|
||||
|
||||
<div class="help-warning">
|
||||
<strong>⚠️ Important:</strong> All data is stored in your browser's local storage. If you clear your browser cache or site data,
|
||||
your snippets and datasets will be permanently deleted. Regular exports are recommended as backups.
|
||||
<strong>⚠️ Important:</strong> All data is stored in your browser's local storage. If you
|
||||
clear your browser cache or site data,
|
||||
your snippets and datasets will be permanently deleted. Regular exports are recommended as
|
||||
backups.
|
||||
</div>
|
||||
|
||||
<ul class="help-list">
|
||||
<li><strong>Snippets</strong> — Stored in localStorage with a 5 MB limit (shared across all snippets). The storage monitor shows current usage.</li>
|
||||
<li><strong>Datasets</strong> — Stored in IndexedDB with effectively unlimited space (browser-dependent, typically 50 MB+).</li>
|
||||
<li><strong>Backup</strong> — Use Import/Export to save your work as JSON files. Datasets can be exported individually from the Dataset Manager.</li>
|
||||
<li><strong>Snippets</strong> — Stored in localStorage with a 5 MB limit (shared across all
|
||||
snippets). The storage monitor shows current usage.</li>
|
||||
<li><strong>Datasets</strong> — Stored in IndexedDB with effectively unlimited space
|
||||
(browser-dependent, typically 50 MB+).</li>
|
||||
<li><strong>Backup</strong> — Use Import/Export to save your work as JSON files. Datasets
|
||||
can be exported individually from the Dataset Manager.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -627,12 +817,18 @@
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Tips & Tricks</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Sort snippets</strong> — Use the sort buttons to organize by Modified, Created, Name, or Size. Click a button twice to reverse the sort order (⬇ becomes ⬆).</li>
|
||||
<li><strong>Extract inline data</strong> — When editing a spec with inline data, click "Extract to Dataset" to create a reusable dataset automatically.</li>
|
||||
<li><strong>Dataset references</strong> — Astrolabe resolves dataset references at render time, so you can freely switch between inline and referenced data.</li>
|
||||
<li><strong>Search across specs</strong> — The search box looks inside snippet names, comments, and the spec content itself.</li>
|
||||
<li><strong>Linked datasets</strong> — The metadata panel shows which datasets a snippet uses, and the Dataset Manager shows which snippets reference each dataset.</li>
|
||||
<li><strong>URL datasets</strong> — Reference remote data by URL. Astrolabe fetches and caches it for preview, but the URL is what gets stored.</li>
|
||||
<li><strong>Sort snippets</strong> — Use the sort buttons to organize by Modified, Created,
|
||||
Name, or Size. Click a button twice to reverse the sort order (⬇ becomes ⬆).</li>
|
||||
<li><strong>Extract inline data</strong> — When editing a spec with inline data, click
|
||||
"Extract to Dataset" to create a reusable dataset automatically.</li>
|
||||
<li><strong>Dataset references</strong> — Astrolabe resolves dataset references at render
|
||||
time, so you can freely switch between inline and referenced data.</li>
|
||||
<li><strong>Search across specs</strong> — The search box looks inside snippet names,
|
||||
comments, and the spec content itself.</li>
|
||||
<li><strong>Linked datasets</strong> — The metadata panel shows which datasets a snippet
|
||||
uses, and the Dataset Manager shows which snippets reference each dataset.</li>
|
||||
<li><strong>URL datasets</strong> — Reference remote data by URL. Astrolabe fetches and
|
||||
caches it for preview, but the URL is what gets stored.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -643,17 +839,28 @@
|
||||
<strong>Your data stays yours.</strong> Astrolabe is built with privacy as a core principle:
|
||||
</p>
|
||||
<ul class="help-list">
|
||||
<li><strong>Local-first architecture</strong> — All snippets and datasets are stored in your browser (localStorage and IndexedDB). Nothing is sent to any server.</li>
|
||||
<li><strong>No accounts, no signup</strong> — There's no authentication system, no user profiles, no cloud sync. Your work exists only on your machine.</li>
|
||||
<li><strong>No cookies</strong> — Astrolabe doesn't use cookies or any persistent tracking identifiers.</li>
|
||||
<li><strong>Privacy-friendly analytics</strong> — We use GoatCounter (privacy-focused, GDPR-compliant) to track basic usage patterns like "snippet created" or "dataset exported." We collect <strong>zero personal information</strong>: no snippet names, no dataset content, no IP addresses, no user identifiers. Just aggregate counts to understand which features are used.</li>
|
||||
<li><strong>Data portability</strong> — Export all your snippets and datasets anytime as standard JSON/CSV/TSV files. No vendor lock-in.</li>
|
||||
<li><strong>Local-first architecture</strong> — All snippets and datasets are stored in your
|
||||
browser (localStorage and IndexedDB). Nothing is sent to any server.</li>
|
||||
<li><strong>No accounts, no signup</strong> — There's no authentication system, no user
|
||||
profiles, no cloud sync. Your work exists only on your machine.</li>
|
||||
<li><strong>No cookies</strong> — Astrolabe doesn't use cookies or any persistent tracking
|
||||
identifiers.</li>
|
||||
<li><strong>Privacy-friendly analytics</strong> — We use GoatCounter (privacy-focused,
|
||||
GDPR-compliant) to track basic usage patterns like "snippet created" or "dataset
|
||||
exported." We collect <strong>zero personal information</strong>: no snippet names, no
|
||||
dataset content, no IP addresses, no user identifiers. Just aggregate counts to
|
||||
understand which features are used.</li>
|
||||
<li><strong>Data portability</strong> — Export all your snippets and datasets anytime as
|
||||
standard JSON/CSV/TSV files. No vendor lock-in.</li>
|
||||
</ul>
|
||||
<p class="help-text">
|
||||
<strong>What analytics we collect:</strong> Action types (e.g., "snippet-create", "dataset-export"), generic metadata (e.g., format types like JSON/CSV, counts like "5 snippets"). That's it.
|
||||
<strong>What analytics we collect:</strong> Action types (e.g., "snippet-create",
|
||||
"dataset-export"), generic metadata (e.g., format types like JSON/CSV, counts like "5
|
||||
snippets"). That's it.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
<strong>What we DON'T collect:</strong> Snippet names, dataset names, actual data content, URLs, email addresses, or any personally identifiable information.
|
||||
<strong>What we DON'T collect:</strong> Snippet names, dataset names, actual data content,
|
||||
URLs, email addresses, or any personally identifiable information.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -673,15 +880,18 @@
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Why Donate?</h3>
|
||||
<p class="help-text">
|
||||
If you are reading this, you probably found Astrolabe to be useful enough to support its creators.
|
||||
It is a free open-source project built in Kyiv, Ukraine.
|
||||
If you are reading this, you probably found Astrolabe to be useful enough to support its
|
||||
creators.
|
||||
It is a free open-source project built in Kyiv, Ukraine.
|
||||
<br>
|
||||
<br>
|
||||
This passion project of mine is possible because my relatives, friends, and colleagues took arms and joined Armed Forces
|
||||
This passion project of mine is possible because my relatives, friends, and colleagues took
|
||||
arms and joined Armed Forces
|
||||
to defend their country and loved ones against Russian invasion.
|
||||
<br>
|
||||
<br>
|
||||
I feel deep gratitude to them, so I will humbly ask you to redirect your donations to the foundations - you'll find the links below.
|
||||
I feel deep gratitude to them, so I will humbly ask you to redirect your donations to the
|
||||
foundations - you'll find the links below.
|
||||
<br>
|
||||
<br>
|
||||
</section>
|
||||
@@ -689,31 +899,39 @@
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Where to Donate</h3>
|
||||
<p class="help-text">
|
||||
<ul class="help-list">
|
||||
<li>
|
||||
<ul class="help-list">
|
||||
<li>
|
||||
|
||||
<strong><a href="https://bank.gov.ua/en/news/all/natsionalniy-bank-vidkriv-spetsrahunok-dlya-zboru-koshtiv-na-potrebi-armiyi">
|
||||
National Bank of Ukraine's special account for Ukraine's Armed Forces
|
||||
</a> - Direct government channel</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://savelife.in.ua/en/donate-en/#donate-army-card-once">
|
||||
Come Back Alive Foundation</a></strong> - One of the biggest and most trusted. They've been around since 2014 and share <a href="https://savelife.in.ua/en/reporting-en/">detailed reports</a> of where money goes.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://macpaw.foundation/">
|
||||
MacPaw Foundation</a></strong> - Founded by MacPaw (where I work). Started in 2016, shifted focus in 2022 to support the Defence Forces.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://foundation.kse.ua/en/humanitarian-projects/">
|
||||
KSE Foundation</a></strong>. Kyiv School of Economics (where I teach). Focuses on both education and humanitarian support for people and defenders
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://standforukraine.com/">
|
||||
Stand for Ukraine</a></strong> - Not a foundation, but an aggregator of reliable organizations.
|
||||
The list of fundraisers goes beyond military and covers recovery of veterans & victims of war, shelter for the refugees and many more.
|
||||
</li>
|
||||
</ul>
|
||||
<strong><a
|
||||
href="https://bank.gov.ua/en/news/all/natsionalniy-bank-vidkriv-spetsrahunok-dlya-zboru-koshtiv-na-potrebi-armiyi">
|
||||
National Bank of Ukraine's special account for Ukraine's Armed Forces
|
||||
</a> - Direct government channel</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://savelife.in.ua/en/donate-en/#donate-army-card-once">
|
||||
Come Back Alive Foundation</a></strong> - One of the biggest and most
|
||||
trusted. They've been around since 2014 and share <a
|
||||
href="https://savelife.in.ua/en/reporting-en/">detailed reports</a> of where
|
||||
money goes.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://macpaw.foundation/">
|
||||
MacPaw Foundation</a></strong> - Founded by MacPaw (where I work). Started
|
||||
in 2016, shifted focus in 2022 to support the Defence Forces.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://foundation.kse.ua/en/humanitarian-projects/">
|
||||
KSE Foundation</a></strong>. Kyiv School of Economics (where I teach).
|
||||
Focuses on both education and humanitarian support for people and defenders
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://standforukraine.com/">
|
||||
Stand for Ukraine</a></strong> - Not a foundation, but an aggregator of
|
||||
reliable organizations.
|
||||
The list of fundraisers goes beyond military and covers recovery of veterans &
|
||||
victims of war, shelter for the refugees and many more.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -745,7 +963,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal" style="display: none;">
|
||||
<div id="settings-modal" class="modal" style="display: none;" x-data="settingsPanel()">
|
||||
<div class="modal-content" style="max-width: 600px; height: auto; max-height: 85vh;">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Settings</span>
|
||||
@@ -760,7 +978,7 @@
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-ui-theme">Theme</label>
|
||||
<div class="settings-control">
|
||||
<select id="setting-ui-theme" class="settings-select">
|
||||
<select id="setting-ui-theme" class="settings-select" x-model="uiTheme">
|
||||
<option value="light">Light</option>
|
||||
<option value="experimental">Dark (Experimental)</option>
|
||||
</select>
|
||||
@@ -775,15 +993,17 @@
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-font-size">Font Size</label>
|
||||
<div class="settings-control">
|
||||
<input type="range" id="setting-font-size" min="10" max="18" step="1" value="12" class="settings-slider" />
|
||||
<span class="settings-value" id="setting-font-size-value">12px</span>
|
||||
<input type="range" id="setting-font-size" min="10" max="18" step="1"
|
||||
class="settings-slider" x-model.number="fontSize" />
|
||||
<span class="settings-value" id="setting-font-size-value"
|
||||
x-text="fontSize + 'px'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-editor-theme">Editor Theme</label>
|
||||
<div class="settings-control">
|
||||
<select id="setting-editor-theme" class="settings-select">
|
||||
<select id="setting-editor-theme" class="settings-select" x-model="editorTheme">
|
||||
<option value="vs-light">Light</option>
|
||||
<option value="vs-dark">Dark</option>
|
||||
<option value="hc-black">High Contrast</option>
|
||||
@@ -794,7 +1014,7 @@
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-tab-size">Tab Size</label>
|
||||
<div class="settings-control">
|
||||
<select id="setting-tab-size" class="settings-select">
|
||||
<select id="setting-tab-size" class="settings-select" x-model.number="tabSize">
|
||||
<option value="2">2 spaces</option>
|
||||
<option value="4">4 spaces</option>
|
||||
<option value="8">8 spaces</option>
|
||||
@@ -804,21 +1024,24 @@
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-minimap" class="settings-checkbox" />
|
||||
<input type="checkbox" id="setting-minimap" class="settings-checkbox"
|
||||
x-model="minimap" />
|
||||
Show minimap
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-word-wrap" class="settings-checkbox" checked />
|
||||
<input type="checkbox" id="setting-word-wrap" class="settings-checkbox"
|
||||
x-model="wordWrap" />
|
||||
Enable word wrap
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-line-numbers" class="settings-checkbox" checked />
|
||||
<input type="checkbox" id="setting-line-numbers" class="settings-checkbox"
|
||||
x-model="lineNumbers" />
|
||||
Show line numbers
|
||||
</label>
|
||||
</div>
|
||||
@@ -831,8 +1054,10 @@
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-render-debounce">Render Delay</label>
|
||||
<div class="settings-control">
|
||||
<input type="range" id="setting-render-debounce" min="300" max="3000" step="100" value="1500" class="settings-slider" />
|
||||
<span class="settings-value" id="setting-render-debounce-value">1500ms</span>
|
||||
<input type="range" id="setting-render-debounce" min="300" max="3000" step="100"
|
||||
class="settings-slider" x-model.number="renderDebounce" />
|
||||
<span class="settings-value" id="setting-render-debounce-value"
|
||||
x-text="renderDebounce + 'ms'"></span>
|
||||
</div>
|
||||
<div class="settings-hint">Delay before visualization updates while typing</div>
|
||||
</div>
|
||||
@@ -845,7 +1070,7 @@
|
||||
<div class="settings-item">
|
||||
<label class="settings-label" for="setting-date-format">Date Format</label>
|
||||
<div class="settings-control">
|
||||
<select id="setting-date-format" class="settings-select">
|
||||
<select id="setting-date-format" class="settings-select" x-model="dateFormat">
|
||||
<option value="smart">Smart (relative times)</option>
|
||||
<option value="locale">Locale (browser default)</option>
|
||||
<option value="iso">ISO 8601</option>
|
||||
@@ -854,22 +1079,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-item" id="custom-date-format-item" style="display: none;">
|
||||
<div class="settings-item" id="custom-date-format-item" x-show="showCustomDateFormat">
|
||||
<label class="settings-label" for="setting-custom-date-format">Custom Format</label>
|
||||
<div class="settings-control">
|
||||
<input type="text" id="setting-custom-date-format" class="settings-input" value="yyyy-MM-dd HH:mm" placeholder="yyyy-MM-dd HH:mm" />
|
||||
<input type="text" id="setting-custom-date-format" class="settings-input"
|
||||
placeholder="yyyy-MM-dd HH:mm" x-model="customDateFormat" />
|
||||
</div>
|
||||
<div class="settings-hint">
|
||||
Tokens: yyyy (year), MM (month), dd (day), HH (24h), hh (12h), mm (min), ss (sec), a/A (am/pm)
|
||||
Tokens: yyyy (year), MM (month), dd (day), HH (24h), hh (12h), mm (min), ss (sec), a/A
|
||||
(am/pm)
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="settings-actions">
|
||||
<button class="btn btn-modal primary" id="settings-apply-btn" title="Apply and save settings">Apply</button>
|
||||
<button class="btn btn-modal" id="settings-reset-btn" title="Reset to default settings">Reset to Defaults</button>
|
||||
<button class="btn btn-modal" id="settings-cancel-btn" title="Cancel without saving">Cancel</button>
|
||||
<button class="btn btn-modal primary" id="settings-apply-btn" @click="apply()"
|
||||
:disabled="!isDirty" title="Apply and save settings">Apply</button>
|
||||
<button class="btn btn-modal" id="settings-reset-btn" @click="reset()"
|
||||
title="Reset to default settings">Reset to Defaults</button>
|
||||
<button class="btn btn-modal" id="settings-cancel-btn" @click="cancel()"
|
||||
title="Cancel without saving">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -877,11 +1107,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast-container"></div>
|
||||
<div id="toast-container" x-data>
|
||||
<template x-for="toast in $store.toasts.items" :key="toast.id">
|
||||
<div :class="'toast toast-' + toast.type + (toast.visible ? ' toast-show' : '')">
|
||||
<span class="toast-icon" x-text="$store.toasts.getIcon(toast.type)"></span>
|
||||
<span class="toast-message" x-text="toast.message"></span>
|
||||
<button class="toast-close" @click="$store.toasts.remove(toast.id)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script src="src/js/user-settings.js"></script>
|
||||
<script src="src/js/config.js"></script>
|
||||
<script src="src/js/generic-storage-ui.js"></script>
|
||||
<script src="src/js/config.js"></script>
|
||||
<script src="src/js/snippet-manager.js"></script>
|
||||
<script src="src/js/dataset-manager.js"></script>
|
||||
<script src="src/js/chart-builder.js"></script>
|
||||
@@ -890,8 +1128,7 @@
|
||||
<script src="src/js/app.js"></script>
|
||||
|
||||
<!-- GoatCounter Analytics -->
|
||||
<script data-goatcounter="https://astrolabe.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
<script data-goatcounter="https://astrolabe.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -26,6 +26,7 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
## Technical Stack
|
||||
|
||||
- **Frontend**: Vanilla JavaScript (ES6+), HTML5, CSS3
|
||||
- **Reactivity**: Alpine.js v3.x (7KB, lightweight reactive framework)
|
||||
- **Editor**: Monaco Editor v0.47.0 (via CDN)
|
||||
- **Visualization**: Vega-Embed v6 (includes Vega v5 & Vega-Lite v5)
|
||||
- **Storage**: localStorage (snippets) + IndexedDB (datasets)
|
||||
@@ -35,6 +36,122 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
|
||||
|
||||
---
|
||||
|
||||
## Alpine.js Integration
|
||||
|
||||
Astrolabe uses Alpine.js for reactive UI management while maintaining vanilla JavaScript for business logic. This hybrid approach provides automatic DOM updates without complex state management overhead.
|
||||
|
||||
### Architecture Pattern
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Alpine.js (7KB) │ ← Reactivity + UI bindings
|
||||
└──────────┬──────────┘
|
||||
│ calls
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Storage Layer │ ← All business logic
|
||||
│ - SnippetStorage │ (filtering, sorting, CRUD)
|
||||
│ - DatasetStorage │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Clean separation:**
|
||||
- **Alpine**: Handles reactivity, DOM updates, user interactions
|
||||
- **Storage**: Single source of truth for data logic
|
||||
|
||||
### Alpine Stores
|
||||
|
||||
Global reactive state managed through Alpine stores:
|
||||
|
||||
**`Alpine.store('snippets')`**
|
||||
- `currentSnippetId` - Currently selected snippet
|
||||
- `viewMode` - 'draft' or 'published' view toggle
|
||||
|
||||
**`Alpine.store('datasets')`**
|
||||
- `currentDatasetId` - Currently selected dataset
|
||||
- `currentDatasetData` - Currently loaded dataset data
|
||||
|
||||
**`Alpine.store('panels')`**
|
||||
- `snippetVisible` - Snippet panel visibility
|
||||
- `editorVisible` - Editor panel visibility
|
||||
- `previewVisible` - Preview panel visibility
|
||||
|
||||
**`Alpine.store('toasts')`**
|
||||
- `items` - Toast notification queue
|
||||
- `add(message, type)` - Add toast
|
||||
- `remove(id)` - Dismiss toast
|
||||
|
||||
**`Alpine.store('preview')`**
|
||||
- `fitMode` - Preview fit mode ('default' | 'width' | 'full')
|
||||
|
||||
### Alpine Components
|
||||
|
||||
**`snippetList()`** - Snippet panel management
|
||||
- `searchQuery` - Reactive search filter
|
||||
- `sortBy`, `sortOrder` - Sort state
|
||||
- `snippetName`, `snippetComment` - Meta field values
|
||||
- `filteredSnippets` - Computed property calling SnippetStorage
|
||||
- Auto-save with debouncing for meta fields
|
||||
|
||||
**`datasetList()`** - Dataset list rendering
|
||||
- `datasets` - Dataset array from IndexedDB
|
||||
- Helper methods for formatting and usage counts
|
||||
|
||||
**`settingsPanel()`** - Settings modal form
|
||||
- All form field values with `x-model` binding
|
||||
- `isDirty` - Computed property for Apply button state
|
||||
- Form validation and persistence
|
||||
|
||||
**`chartBuilder()`** - Chart Builder modal
|
||||
- Full reactive state for mark types, encodings, dimensions
|
||||
- `spec` - Computed Vega-Lite spec from current state
|
||||
- `isValid` - Computed validation for required encodings
|
||||
- Debounced preview rendering
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Two-way binding with x-model:**
|
||||
```html
|
||||
<input type="text" x-model="snippetName" @input="saveMetaDebounced()">
|
||||
```
|
||||
|
||||
**Conditional rendering with x-show:**
|
||||
```html
|
||||
<div x-show="isDirty">Unsaved changes</div>
|
||||
```
|
||||
|
||||
**List rendering with x-for:**
|
||||
```html
|
||||
<template x-for="snippet in filteredSnippets" :key="snippet.id">
|
||||
<div @click="selectSnippet(snippet.id)">...</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Dynamic classes with :class:**
|
||||
```html
|
||||
<button :class="{ 'active': $store.snippets.viewMode === 'draft' }">
|
||||
```
|
||||
|
||||
### Migration Principles
|
||||
|
||||
1. Alpine is view layer only - never holds authoritative data
|
||||
2. Storage layer remains unchanged - Alpine calls existing functions
|
||||
3. Components are thin wrappers around business logic
|
||||
4. Automatic reactivity eliminates manual DOM updates
|
||||
5. Alpine and vanilla JavaScript coexist harmoniously
|
||||
|
||||
### Migration History
|
||||
|
||||
Alpine.js was incrementally adopted across 8 phases (2025-01), migrating UI components from vanilla JavaScript:
|
||||
- Snippet Panel, Dataset Manager, View Mode Toggle, Settings Modal (Phase 1-4)
|
||||
- **Chart Builder Modal** (Phase 5) - Largest migration, ~360 lines of event listeners removed
|
||||
- Meta Fields, Panel Visibility Toggles, Toast Notifications (Phase 6-7)
|
||||
- **Preview Panel Controls** (Phase 8) - Completed standardization of toggle groups
|
||||
|
||||
**Total impact**: ~625 lines of vanilla JS removed, significantly improved maintainability and code readability. All migrations maintain full backward compatibility with Storage layer.
|
||||
|
||||
---
|
||||
|
||||
## Data Schemas
|
||||
|
||||
### Snippet Schema
|
||||
@@ -176,22 +293,24 @@ web/
|
||||
### Module Responsibilities
|
||||
|
||||
**config.js** (~200 lines)
|
||||
- Global state variables (`currentSnippetId`, `currentViewMode`, etc.)
|
||||
- Settings API (load, save, get, set, validate)
|
||||
- Utility functions (date formatting, Toast notifications, URLState)
|
||||
- Utility functions (date formatting, URLState)
|
||||
- Toast notification system (Alpine store integration)
|
||||
- Sample data for first-time users
|
||||
|
||||
**snippet-manager.js** (~1100 lines)
|
||||
- Alpine store and component for snippet list UI
|
||||
- SnippetStorage wrapper for localStorage operations
|
||||
- Full CRUD operations (create, read, update, delete, duplicate)
|
||||
- Search and multi-field sorting
|
||||
- Search and multi-field sorting with reactive bindings
|
||||
- Draft/published workflow logic
|
||||
- Dataset reference extraction (recursive)
|
||||
- Import/export functionality
|
||||
- Storage monitoring and size calculation
|
||||
- Auto-save system with debouncing
|
||||
- Auto-save system with debouncing for drafts and metadata
|
||||
|
||||
**dataset-manager.js** (~1200 lines)
|
||||
- Alpine store and component for dataset list UI
|
||||
- DatasetStorage wrapper for IndexedDB operations
|
||||
- Full CRUD operations with async/Promise API
|
||||
- Format detection (JSON, CSV, TSV, TopoJSON)
|
||||
@@ -214,8 +333,9 @@ web/
|
||||
- Snippet creation with auto-generated metadata
|
||||
|
||||
**panel-manager.js** (~200 lines)
|
||||
- Alpine store for panel visibility state
|
||||
- Drag-to-resize implementation
|
||||
- Panel show/hide toggle logic
|
||||
- Panel show/hide toggle logic with reactive button states
|
||||
- Panel memory system (remembers sizes when hidden)
|
||||
- Proportional width redistribution
|
||||
- localStorage persistence for layout state
|
||||
@@ -229,12 +349,13 @@ web/
|
||||
- Format-aware data injection (JSON/CSV/TSV/TopoJSON/URL)
|
||||
|
||||
**user-settings.js** (~300 lines)
|
||||
- Alpine component for settings form with reactive bindings
|
||||
- Settings validation and defaults
|
||||
- Editor configuration management
|
||||
- Theme system (light/dark)
|
||||
- Date formatting engine
|
||||
- Performance tuning options
|
||||
- Settings modal UI logic
|
||||
- Form state tracking with computed isDirty property
|
||||
|
||||
**app.js** (~270 lines)
|
||||
- Application initialization sequence
|
||||
|
||||
280
src/js/app.js
280
src/js/app.js
@@ -34,12 +34,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// Initialize snippet storage and render list (async)
|
||||
initializeSnippetsStorage().then(() => {
|
||||
// Initialize sort controls
|
||||
initializeSortControls();
|
||||
|
||||
// Initialize search controls
|
||||
initializeSearchControls();
|
||||
|
||||
// Render snippet list (now handled reactively by Alpine)
|
||||
renderSnippetList();
|
||||
|
||||
// Update storage monitor
|
||||
@@ -135,20 +130,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize auto-save functionality
|
||||
initializeAutoSave();
|
||||
|
||||
// Initialize chart builder
|
||||
initializeChartBuilder();
|
||||
|
||||
// Initialize URL state management AFTER editor is ready
|
||||
initializeURLStateManagement();
|
||||
});
|
||||
|
||||
// Toggle panel buttons
|
||||
document.querySelectorAll('[id^="toggle-"][id$="-panel"]').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const panelId = this.id.replace('toggle-', '');
|
||||
togglePanel(panelId);
|
||||
});
|
||||
});
|
||||
// Toggle panel buttons (now handled by Alpine.js in index.html)
|
||||
|
||||
// Header links
|
||||
const importLink = document.getElementById('import-link');
|
||||
@@ -183,61 +169,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// Settings Modal
|
||||
const settingsLink = document.getElementById('settings-link');
|
||||
const settingsApplyBtn = document.getElementById('settings-apply-btn');
|
||||
const settingsResetBtn = document.getElementById('settings-reset-btn');
|
||||
const settingsCancelBtn = document.getElementById('settings-cancel-btn');
|
||||
|
||||
if (settingsLink) {
|
||||
settingsLink.addEventListener('click', openSettingsModal);
|
||||
}
|
||||
|
||||
if (settingsCancelBtn) {
|
||||
settingsCancelBtn.addEventListener('click', closeSettingsModal);
|
||||
}
|
||||
|
||||
if (settingsApplyBtn) {
|
||||
settingsApplyBtn.addEventListener('click', applySettings);
|
||||
}
|
||||
|
||||
if (settingsResetBtn) {
|
||||
settingsResetBtn.addEventListener('click', function() {
|
||||
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
|
||||
resetSettings();
|
||||
loadSettingsIntoUI();
|
||||
Toast.show('Settings reset to defaults', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Settings UI interactions
|
||||
const fontSizeSlider = document.getElementById('setting-font-size');
|
||||
const fontSizeValue = document.getElementById('setting-font-size-value');
|
||||
if (fontSizeSlider && fontSizeValue) {
|
||||
fontSizeSlider.addEventListener('input', function() {
|
||||
fontSizeValue.textContent = this.value + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
const renderDebounceSlider = document.getElementById('setting-render-debounce');
|
||||
const renderDebounceValue = document.getElementById('setting-render-debounce-value');
|
||||
if (renderDebounceSlider && renderDebounceValue) {
|
||||
renderDebounceSlider.addEventListener('input', function() {
|
||||
renderDebounceValue.textContent = this.value + 'ms';
|
||||
});
|
||||
}
|
||||
|
||||
const dateFormatSelect = document.getElementById('setting-date-format');
|
||||
const customDateFormatItem = document.getElementById('custom-date-format-item');
|
||||
if (dateFormatSelect && customDateFormatItem) {
|
||||
dateFormatSelect.addEventListener('change', function() {
|
||||
if (this.value === 'custom') {
|
||||
customDateFormatItem.style.display = 'block';
|
||||
} else {
|
||||
customDateFormatItem.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Settings buttons and UI interactions now handled by Alpine.js in settingsPanel() component
|
||||
|
||||
// Dataset Manager
|
||||
const datasetsLink = document.getElementById('datasets-link');
|
||||
@@ -264,9 +201,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// Edit dataset button
|
||||
if (editDatasetBtn) {
|
||||
editDatasetBtn.addEventListener('click', async function() {
|
||||
if (window.currentDatasetId) {
|
||||
await showEditDatasetForm(window.currentDatasetId);
|
||||
editDatasetBtn.addEventListener('click', async function () {
|
||||
if (Alpine.store('datasets').currentDatasetId) {
|
||||
await showEditDatasetForm(Alpine.store('datasets').currentDatasetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -314,8 +251,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const buildChartBtn = document.getElementById('build-chart-btn');
|
||||
if (buildChartBtn) {
|
||||
buildChartBtn.addEventListener('click', async () => {
|
||||
if (window.currentDatasetId) {
|
||||
openChartBuilder(window.currentDatasetId);
|
||||
if (Alpine.store('datasets').currentDatasetId) {
|
||||
openChartBuilder(Alpine.store('datasets').currentDatasetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -336,22 +273,22 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const previewRawBtn = document.getElementById('preview-raw-btn');
|
||||
const previewTableBtn = document.getElementById('preview-table-btn');
|
||||
if (previewRawBtn) {
|
||||
previewRawBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showRawPreview(window.currentDatasetData);
|
||||
previewRawBtn.addEventListener('click', function () {
|
||||
if (Alpine.store('datasets').currentDatasetData) {
|
||||
showRawPreview(Alpine.store('datasets').currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (previewTableBtn) {
|
||||
previewTableBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showTablePreview(window.currentDatasetData);
|
||||
previewTableBtn.addEventListener('click', function () {
|
||||
if (Alpine.store('datasets').currentDatasetData) {
|
||||
showTablePreview(Alpine.store('datasets').currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Global modal event delegation - handles close buttons and overlay clicks
|
||||
document.addEventListener('click', function(e) {
|
||||
document.addEventListener('click', function (e) {
|
||||
// Handle modal close buttons (×)
|
||||
if (e.target.id && e.target.id.endsWith('-modal-close')) {
|
||||
const modalId = e.target.id.replace('-close', '');
|
||||
@@ -366,27 +303,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
});
|
||||
|
||||
// View mode toggle buttons
|
||||
document.getElementById('view-draft').addEventListener('click', () => {
|
||||
switchViewMode('draft');
|
||||
});
|
||||
|
||||
document.getElementById('view-published').addEventListener('click', () => {
|
||||
switchViewMode('published');
|
||||
});
|
||||
|
||||
// Preview fit mode buttons
|
||||
document.getElementById('preview-fit-default').addEventListener('click', () => {
|
||||
setPreviewFitMode('default');
|
||||
});
|
||||
|
||||
document.getElementById('preview-fit-width').addEventListener('click', () => {
|
||||
setPreviewFitMode('width');
|
||||
});
|
||||
|
||||
document.getElementById('preview-fit-full').addEventListener('click', () => {
|
||||
setPreviewFitMode('full');
|
||||
});
|
||||
|
||||
// Publish and Revert buttons
|
||||
document.getElementById('publish-btn').addEventListener('click', publishDraft);
|
||||
@@ -466,11 +382,11 @@ function initializeURLStateManagement() {
|
||||
|
||||
// Keyboard shortcut action handlers (shared between Monaco and document)
|
||||
const KeyboardActions = {
|
||||
createNewSnippet: function() {
|
||||
createNewSnippet: function () {
|
||||
createNewSnippet();
|
||||
},
|
||||
|
||||
toggleDatasetManager: function() {
|
||||
toggleDatasetManager: function () {
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeDatasetManager();
|
||||
@@ -479,13 +395,13 @@ const KeyboardActions = {
|
||||
}
|
||||
},
|
||||
|
||||
publishDraft: function() {
|
||||
if (currentViewMode === 'draft' && window.currentSnippetId) {
|
||||
publishDraft: function () {
|
||||
if (Alpine.store('snippets').viewMode === 'draft' && Alpine.store('snippets').currentSnippetId) {
|
||||
publishDraft();
|
||||
}
|
||||
},
|
||||
|
||||
toggleSettings: function() {
|
||||
toggleSettings: function () {
|
||||
if (ModalManager.isOpen('settings-modal')) {
|
||||
closeSettingsModal();
|
||||
} else {
|
||||
@@ -493,7 +409,7 @@ const KeyboardActions = {
|
||||
}
|
||||
},
|
||||
|
||||
closeAnyModal: function() {
|
||||
closeAnyModal: function () {
|
||||
// Try ModalManager first for standard modals
|
||||
if (ModalManager.closeAny()) {
|
||||
return true;
|
||||
@@ -515,7 +431,7 @@ const KeyboardActions = {
|
||||
|
||||
// Keyboard shortcuts handler (document-level)
|
||||
function initializeKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Escape: Close any open modal
|
||||
if (e.key === 'Escape') {
|
||||
if (KeyboardActions.closeAnyModal()) {
|
||||
@@ -573,160 +489,12 @@ function registerMonacoKeyboardShortcuts() {
|
||||
KeyboardActions.publishDraft);
|
||||
}
|
||||
|
||||
// Settings modal functions (special handling for loading settings into UI)
|
||||
// Settings modal functions (simplified - most logic now in Alpine settingsPanel() component)
|
||||
function openSettingsModal() {
|
||||
loadSettingsIntoUI();
|
||||
ModalManager.open('settings-modal');
|
||||
// Settings will be loaded via Alpine's init() method
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
ModalManager.close('settings-modal');
|
||||
}
|
||||
|
||||
function loadSettingsIntoUI() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Appearance settings
|
||||
const uiThemeSelect = document.getElementById('setting-ui-theme');
|
||||
if (uiThemeSelect) {
|
||||
uiThemeSelect.value = settings.ui.theme;
|
||||
}
|
||||
|
||||
// Editor settings
|
||||
const fontSizeSlider = document.getElementById('setting-font-size');
|
||||
const fontSizeValue = document.getElementById('setting-font-size-value');
|
||||
if (fontSizeSlider && fontSizeValue) {
|
||||
fontSizeSlider.value = settings.editor.fontSize;
|
||||
fontSizeValue.textContent = settings.editor.fontSize + 'px';
|
||||
}
|
||||
|
||||
const editorThemeSelect = document.getElementById('setting-editor-theme');
|
||||
if (editorThemeSelect) {
|
||||
editorThemeSelect.value = settings.editor.theme;
|
||||
}
|
||||
|
||||
const tabSizeSelect = document.getElementById('setting-tab-size');
|
||||
if (tabSizeSelect) {
|
||||
tabSizeSelect.value = settings.editor.tabSize;
|
||||
}
|
||||
|
||||
const minimapCheckbox = document.getElementById('setting-minimap');
|
||||
if (minimapCheckbox) {
|
||||
minimapCheckbox.checked = settings.editor.minimap;
|
||||
}
|
||||
|
||||
const wordWrapCheckbox = document.getElementById('setting-word-wrap');
|
||||
if (wordWrapCheckbox) {
|
||||
wordWrapCheckbox.checked = settings.editor.wordWrap === 'on';
|
||||
}
|
||||
|
||||
const lineNumbersCheckbox = document.getElementById('setting-line-numbers');
|
||||
if (lineNumbersCheckbox) {
|
||||
lineNumbersCheckbox.checked = settings.editor.lineNumbers === 'on';
|
||||
}
|
||||
|
||||
// Performance settings
|
||||
const renderDebounceSlider = document.getElementById('setting-render-debounce');
|
||||
const renderDebounceValue = document.getElementById('setting-render-debounce-value');
|
||||
if (renderDebounceSlider && renderDebounceValue) {
|
||||
renderDebounceSlider.value = settings.performance.renderDebounce;
|
||||
renderDebounceValue.textContent = settings.performance.renderDebounce + 'ms';
|
||||
}
|
||||
|
||||
// Formatting settings
|
||||
const dateFormatSelect = document.getElementById('setting-date-format');
|
||||
const customDateFormatItem = document.getElementById('custom-date-format-item');
|
||||
if (dateFormatSelect) {
|
||||
dateFormatSelect.value = settings.formatting.dateFormat;
|
||||
if (customDateFormatItem) {
|
||||
customDateFormatItem.style.display = settings.formatting.dateFormat === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const customDateFormatInput = document.getElementById('setting-custom-date-format');
|
||||
if (customDateFormatInput) {
|
||||
customDateFormatInput.value = settings.formatting.customDateFormat;
|
||||
}
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
// Collect values from UI
|
||||
const newSettings = {
|
||||
'ui.theme': document.getElementById('setting-ui-theme').value,
|
||||
'editor.fontSize': parseInt(document.getElementById('setting-font-size').value),
|
||||
'editor.theme': document.getElementById('setting-editor-theme').value,
|
||||
'editor.tabSize': parseInt(document.getElementById('setting-tab-size').value),
|
||||
'editor.minimap': document.getElementById('setting-minimap').checked,
|
||||
'editor.wordWrap': document.getElementById('setting-word-wrap').checked ? 'on' : 'off',
|
||||
'editor.lineNumbers': document.getElementById('setting-line-numbers').checked ? 'on' : 'off',
|
||||
'performance.renderDebounce': parseInt(document.getElementById('setting-render-debounce').value),
|
||||
'formatting.dateFormat': document.getElementById('setting-date-format').value,
|
||||
'formatting.customDateFormat': document.getElementById('setting-custom-date-format').value
|
||||
};
|
||||
|
||||
// Validate settings
|
||||
let hasErrors = false;
|
||||
for (const [path, value] of Object.entries(newSettings)) {
|
||||
const errors = validateSetting(path, value);
|
||||
if (errors.length > 0) {
|
||||
Toast.show(errors.join(', '), 'error');
|
||||
hasErrors = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if (updateSettings(newSettings)) {
|
||||
// Apply theme to document
|
||||
const uiTheme = newSettings['ui.theme'];
|
||||
document.documentElement.setAttribute('data-theme', uiTheme);
|
||||
|
||||
// Sync editor theme with UI theme
|
||||
const editorTheme = uiTheme === 'experimental' ? 'vs-dark' : 'vs-light';
|
||||
newSettings['editor.theme'] = editorTheme;
|
||||
|
||||
// Apply editor settings immediately
|
||||
if (editor) {
|
||||
editor.updateOptions({
|
||||
fontSize: newSettings['editor.fontSize'],
|
||||
theme: editorTheme,
|
||||
tabSize: newSettings['editor.tabSize'],
|
||||
minimap: { enabled: newSettings['editor.minimap'] },
|
||||
wordWrap: newSettings['editor.wordWrap'],
|
||||
lineNumbers: newSettings['editor.lineNumbers']
|
||||
});
|
||||
}
|
||||
|
||||
// Update the editor theme in settings
|
||||
updateSetting('editor.theme', editorTheme);
|
||||
|
||||
// Update debounced render function
|
||||
if (typeof updateRenderDebounce === 'function') {
|
||||
updateRenderDebounce(newSettings['performance.renderDebounce']);
|
||||
}
|
||||
|
||||
// Re-render snippet list to reflect date format changes
|
||||
renderSnippetList();
|
||||
|
||||
// Update metadata display if a snippet is selected
|
||||
if (window.currentSnippetId) {
|
||||
const snippet = SnippetStorage.getSnippet(window.currentSnippetId);
|
||||
if (snippet) {
|
||||
document.getElementById('snippet-created').textContent = formatDate(snippet.created, true);
|
||||
document.getElementById('snippet-modified').textContent = formatDate(snippet.modified, true);
|
||||
}
|
||||
}
|
||||
|
||||
Toast.show('Settings applied successfully', 'success');
|
||||
closeSettingsModal();
|
||||
|
||||
// Track event
|
||||
Analytics.track('settings-apply', 'Applied settings');
|
||||
} else {
|
||||
Toast.show('Failed to save settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,299 @@
|
||||
// Chart Builder - Visual chart construction from datasets
|
||||
|
||||
// Global state for chart builder
|
||||
window.chartBuilderState = null;
|
||||
/**
|
||||
* Alpine.js component for Chart Builder
|
||||
* Manages reactive state for chart configuration and preview
|
||||
*/
|
||||
function chartBuilder() {
|
||||
return {
|
||||
// Dataset info
|
||||
datasetId: null,
|
||||
datasetName: null,
|
||||
dataset: null,
|
||||
|
||||
// Timeout for debounced preview updates
|
||||
let previewUpdateTimeout = null;
|
||||
// Chart configuration
|
||||
markType: 'bar',
|
||||
encodings: {
|
||||
x: { field: '', type: 'nominal' },
|
||||
y: { field: '', type: 'quantitative' },
|
||||
color: { field: '', type: 'nominal' },
|
||||
size: { field: '', type: 'quantitative' }
|
||||
},
|
||||
width: null,
|
||||
height: null,
|
||||
|
||||
// UI state
|
||||
previewTimeout: null,
|
||||
|
||||
// Computed: Generate Vega-Lite spec from current state
|
||||
get spec() {
|
||||
const spec = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"data": { "name": this.datasetName },
|
||||
"mark": { "type": this.markType, "tooltip": true },
|
||||
"encoding": {}
|
||||
};
|
||||
|
||||
// Add encodings
|
||||
['x', 'y', 'color', 'size'].forEach(channel => {
|
||||
const enc = this.encodings[channel];
|
||||
if (enc.field) {
|
||||
spec.encoding[channel] = {
|
||||
field: enc.field,
|
||||
type: enc.type
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Add dimensions if specified
|
||||
if (this.width) spec.width = parseInt(this.width);
|
||||
if (this.height) spec.height = parseInt(this.height);
|
||||
|
||||
// Remove empty encoding object
|
||||
if (Object.keys(spec.encoding).length === 0) {
|
||||
delete spec.encoding;
|
||||
}
|
||||
|
||||
return spec;
|
||||
},
|
||||
|
||||
// Computed: Check if configuration is valid
|
||||
get isValid() {
|
||||
return Object.values(this.encodings).some(enc => enc.field !== '');
|
||||
},
|
||||
|
||||
// Initialize component with dataset
|
||||
async init(datasetId) {
|
||||
try {
|
||||
// Validate datasetId is provided
|
||||
if (!datasetId || isNaN(datasetId)) {
|
||||
console.warn('Chart builder init called without valid datasetId');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch dataset from IndexedDB
|
||||
this.dataset = await DatasetStorage.getDataset(datasetId);
|
||||
if (!this.dataset) {
|
||||
Toast.error('Dataset not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.datasetId = datasetId;
|
||||
this.datasetName = this.dataset.name;
|
||||
|
||||
// Populate field dropdowns
|
||||
populateFieldDropdowns(this.dataset);
|
||||
|
||||
// Auto-select smart defaults
|
||||
this.autoSelectDefaults();
|
||||
|
||||
// Trigger initial preview
|
||||
this.$nextTick(() => {
|
||||
this.updatePreview();
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing chart builder:', error);
|
||||
Toast.error('Error opening chart builder');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Auto-select smart defaults based on column types
|
||||
autoSelectDefaults() {
|
||||
const columns = this.dataset.columns || [];
|
||||
const columnTypes = this.dataset.columnTypes || [];
|
||||
|
||||
if (columns.length === 0) return;
|
||||
|
||||
// Select first column for X axis
|
||||
if (columns.length >= 1) {
|
||||
const firstCol = columns[0];
|
||||
const firstColType = columnTypes.find(ct => ct.name === firstCol);
|
||||
this.encodings.x.field = firstCol;
|
||||
this.encodings.x.type = firstColType ? mapColumnTypeToVegaType(firstColType.type) : 'nominal';
|
||||
}
|
||||
|
||||
// Select second column for Y axis (if exists)
|
||||
if (columns.length >= 2) {
|
||||
const secondCol = columns[1];
|
||||
const secondColType = columnTypes.find(ct => ct.name === secondCol);
|
||||
this.encodings.y.field = secondCol;
|
||||
this.encodings.y.type = secondColType ? mapColumnTypeToVegaType(secondColType.type) : 'quantitative';
|
||||
}
|
||||
},
|
||||
|
||||
// Set mark type and update preview
|
||||
setMarkType(type) {
|
||||
this.markType = type;
|
||||
this.updatePreview();
|
||||
},
|
||||
|
||||
// Set encoding field and auto-detect type if needed
|
||||
async setEncodingField(channel, field) {
|
||||
this.encodings[channel].field = field;
|
||||
|
||||
if (field && this.dataset) {
|
||||
// Auto-detect type from column type
|
||||
const columnTypes = this.dataset.columnTypes || [];
|
||||
const colType = columnTypes.find(ct => ct.name === field);
|
||||
if (colType) {
|
||||
this.encodings[channel].type = mapColumnTypeToVegaType(colType.type);
|
||||
}
|
||||
}
|
||||
|
||||
this.updatePreview();
|
||||
},
|
||||
|
||||
// Set encoding type and update preview
|
||||
setEncodingType(channel, type) {
|
||||
if (this.encodings[channel].field) {
|
||||
this.encodings[channel].type = type;
|
||||
this.updatePreview();
|
||||
}
|
||||
},
|
||||
|
||||
// Update preview with debouncing
|
||||
updatePreview() {
|
||||
clearTimeout(this.previewTimeout);
|
||||
|
||||
// Get debounce time from settings (default 1500ms)
|
||||
const debounceTime = getSetting('performance.renderDebounce') || 1500;
|
||||
|
||||
this.previewTimeout = setTimeout(async () => {
|
||||
await this.renderPreview();
|
||||
}, debounceTime);
|
||||
},
|
||||
|
||||
// Render preview in chart builder
|
||||
async renderPreview() {
|
||||
const previewContainer = document.getElementById('chart-builder-preview');
|
||||
const errorDiv = document.getElementById('chart-builder-error');
|
||||
|
||||
if (!previewContainer) return;
|
||||
|
||||
try {
|
||||
// Validate configuration
|
||||
if (!this.isValid) {
|
||||
previewContainer.innerHTML = '<div class="chart-preview-placeholder">Configure at least one encoding to see preview</div>';
|
||||
if (errorDiv) errorDiv.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve dataset references
|
||||
const resolvedSpec = await resolveDatasetReferences(JSON.parse(JSON.stringify(this.spec)));
|
||||
|
||||
// Clear container
|
||||
previewContainer.innerHTML = '';
|
||||
|
||||
// Render with Vega-Embed
|
||||
await window.vegaEmbed('#chart-builder-preview', resolvedSpec, {
|
||||
actions: false,
|
||||
renderer: 'svg'
|
||||
});
|
||||
|
||||
// Clear error
|
||||
if (errorDiv) errorDiv.textContent = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rendering chart preview:', error);
|
||||
|
||||
// Show error message
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = error.message || 'Error rendering chart';
|
||||
}
|
||||
|
||||
// Show error in preview
|
||||
previewContainer.innerHTML = `<div class="chart-preview-placeholder" style="color: #d32f2f;">Error: ${error.message || 'Failed to render chart'}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Create snippet from current chart configuration
|
||||
async createSnippet() {
|
||||
if (!this.isValid) return;
|
||||
|
||||
try {
|
||||
// Create snippet with auto-generated name
|
||||
const snippetName = generateSnippetName();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const snippet = {
|
||||
id: generateSnippetId(),
|
||||
name: snippetName,
|
||||
created: now,
|
||||
modified: now,
|
||||
spec: this.spec,
|
||||
draftSpec: null,
|
||||
comment: `Chart built from dataset: ${this.datasetName}`,
|
||||
tags: [],
|
||||
datasetRefs: [this.datasetName],
|
||||
meta: {}
|
||||
};
|
||||
|
||||
// Save snippet
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Close modals
|
||||
this.close();
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
if (datasetModal) datasetModal.style.display = 'none';
|
||||
|
||||
// Select and open the new snippet
|
||||
selectSnippet(snippet.id);
|
||||
|
||||
// Show success message
|
||||
Toast.success(`Created snippet: ${snippetName}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating snippet from builder:', error);
|
||||
Toast.error('Error creating snippet');
|
||||
}
|
||||
},
|
||||
|
||||
// Close chart builder and cleanup
|
||||
close() {
|
||||
const modal = document.getElementById('chart-builder-modal');
|
||||
modal.style.display = 'none';
|
||||
|
||||
// Update URL - go back to dataset view
|
||||
if (this.datasetId) {
|
||||
URLState.update({
|
||||
view: 'datasets',
|
||||
datasetId: this.datasetId,
|
||||
action: null
|
||||
});
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(this.previewTimeout);
|
||||
|
||||
// Reset state
|
||||
this.datasetId = null;
|
||||
this.datasetName = null;
|
||||
this.dataset = null;
|
||||
this.markType = 'bar';
|
||||
this.encodings = {
|
||||
x: { field: '', type: 'nominal' },
|
||||
y: { field: '', type: 'quantitative' },
|
||||
color: { field: '', type: 'nominal' },
|
||||
size: { field: '', type: 'quantitative' }
|
||||
};
|
||||
this.width = null;
|
||||
this.height = null;
|
||||
|
||||
// Clear preview
|
||||
const previewContainer = document.getElementById('chart-builder-preview');
|
||||
if (previewContainer) {
|
||||
previewContainer.innerHTML = '<div class="chart-preview-placeholder">Configure chart to see preview</div>';
|
||||
}
|
||||
|
||||
// Clear error
|
||||
const errorDiv = document.getElementById('chart-builder-error');
|
||||
if (errorDiv) errorDiv.textContent = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Map column types to Vega-Lite types
|
||||
function mapColumnTypeToVegaType(columnType) {
|
||||
@@ -23,38 +312,26 @@ function setActiveToggle(buttons, activeButton) {
|
||||
activeButton.classList.add('active');
|
||||
}
|
||||
|
||||
|
||||
// Open chart builder modal with dataset
|
||||
async function openChartBuilder(datasetId) {
|
||||
try {
|
||||
// Fetch dataset from IndexedDB
|
||||
const dataset = await DatasetStorage.getDataset(datasetId);
|
||||
if (!dataset) {
|
||||
showToast('Dataset not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize state with defaults
|
||||
window.chartBuilderState = {
|
||||
datasetId: datasetId,
|
||||
datasetName: dataset.name,
|
||||
spec: {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"data": {"name": dataset.name},
|
||||
"mark": {"type": "bar", "tooltip": true},
|
||||
"encoding": {}
|
||||
}
|
||||
};
|
||||
|
||||
// Populate field dropdowns with dataset columns BEFORE showing modal
|
||||
populateFieldDropdowns(dataset);
|
||||
|
||||
// Auto-select smart defaults
|
||||
autoSelectDefaults(dataset);
|
||||
|
||||
// Show modal
|
||||
// Show modal first
|
||||
const modal = document.getElementById('chart-builder-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Get Alpine component instance and initialize it
|
||||
const chartBuilderView = document.getElementById('chart-builder-view');
|
||||
if (chartBuilderView && chartBuilderView._x_dataStack) {
|
||||
const component = chartBuilderView._x_dataStack[0];
|
||||
const success = await component.init(datasetId);
|
||||
|
||||
if (!success) {
|
||||
modal.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL to reflect chart builder state
|
||||
URLState.update({
|
||||
view: 'datasets',
|
||||
@@ -62,18 +339,15 @@ async function openChartBuilder(datasetId) {
|
||||
action: 'build'
|
||||
});
|
||||
|
||||
// Initial preview update (with a small delay to ensure DOM is ready)
|
||||
setTimeout(() => {
|
||||
updateChartBuilderPreview();
|
||||
}, 50);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error opening chart builder:', error);
|
||||
showToast('Error opening chart builder', 'error');
|
||||
Toast.error('Error opening chart builder');
|
||||
}
|
||||
}
|
||||
|
||||
// Populate field dropdowns with dataset columns
|
||||
|
||||
|
||||
// Populate field dropdowns with dataset columns (utility function)
|
||||
function populateFieldDropdowns(dataset) {
|
||||
const encodings = ['x', 'y', 'color', 'size'];
|
||||
const columns = dataset.columns || [];
|
||||
@@ -95,363 +369,11 @@ function populateFieldDropdowns(dataset) {
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-select smart defaults based on column types
|
||||
function autoSelectDefaults(dataset) {
|
||||
const columns = dataset.columns || [];
|
||||
const columnTypes = dataset.columnTypes || [];
|
||||
|
||||
if (columns.length === 0) return;
|
||||
|
||||
// Select first column for X axis
|
||||
if (columns.length >= 1) {
|
||||
const firstCol = columns[0];
|
||||
const firstColType = columnTypes.find(ct => ct.name === firstCol);
|
||||
setEncoding('x', firstCol, firstColType ? mapColumnTypeToVegaType(firstColType.type) : 'nominal');
|
||||
}
|
||||
|
||||
// Select second column for Y axis (if exists)
|
||||
if (columns.length >= 2) {
|
||||
const secondCol = columns[1];
|
||||
const secondColType = columnTypes.find(ct => ct.name === secondCol);
|
||||
setEncoding('y', secondCol, secondColType ? mapColumnTypeToVegaType(secondColType.type) : 'quantitative');
|
||||
}
|
||||
}
|
||||
|
||||
// Set encoding field and type in UI and state
|
||||
function setEncoding(channel, field, type) {
|
||||
// Update dropdown
|
||||
const select = document.getElementById(`encoding-${channel}-field`);
|
||||
if (select) {
|
||||
select.value = field;
|
||||
}
|
||||
|
||||
// Update type toggle buttons
|
||||
const typeButtons = document.querySelectorAll(`[data-encoding="${channel}"][data-type]`);
|
||||
const activeButton = Array.from(typeButtons).find(btn => btn.dataset.type === type);
|
||||
if (activeButton) {
|
||||
setActiveToggle(typeButtons, activeButton);
|
||||
}
|
||||
|
||||
// Update state
|
||||
if (!window.chartBuilderState) return;
|
||||
|
||||
if (field) {
|
||||
window.chartBuilderState.spec.encoding[channel] = {
|
||||
field: field,
|
||||
type: type
|
||||
};
|
||||
} else {
|
||||
delete window.chartBuilderState.spec.encoding[channel];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Vega-Lite spec from current state
|
||||
function generateVegaLiteSpec() {
|
||||
if (!window.chartBuilderState) return null;
|
||||
|
||||
const state = window.chartBuilderState;
|
||||
const spec = JSON.parse(JSON.stringify(state.spec)); // Deep clone
|
||||
|
||||
// Add width/height if specified
|
||||
const width = document.getElementById('chart-width');
|
||||
const height = document.getElementById('chart-height');
|
||||
|
||||
if (width && width.value) {
|
||||
spec.width = parseInt(width.value);
|
||||
}
|
||||
|
||||
if (height && height.value) {
|
||||
spec.height = parseInt(height.value);
|
||||
}
|
||||
|
||||
// Remove empty encodings
|
||||
if (Object.keys(spec.encoding).length === 0) {
|
||||
delete spec.encoding;
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Validate chart configuration
|
||||
function validateChartConfig() {
|
||||
if (!window.chartBuilderState) return false;
|
||||
|
||||
const spec = window.chartBuilderState.spec;
|
||||
const encoding = spec.encoding || {};
|
||||
|
||||
// At least one encoding must be set
|
||||
const hasEncoding = Object.keys(encoding).length > 0;
|
||||
|
||||
return hasEncoding;
|
||||
}
|
||||
|
||||
// Update preview with debouncing
|
||||
function updateChartBuilderPreview() {
|
||||
clearTimeout(previewUpdateTimeout);
|
||||
|
||||
// Get debounce time from settings (default 1500ms)
|
||||
const debounceTime = getSetting('performance.renderDebounce') || 1500;
|
||||
|
||||
previewUpdateTimeout = setTimeout(async () => {
|
||||
await renderChartBuilderPreview();
|
||||
}, debounceTime);
|
||||
}
|
||||
|
||||
// Render preview in chart builder
|
||||
async function renderChartBuilderPreview() {
|
||||
const previewContainer = document.getElementById('chart-builder-preview');
|
||||
const errorDiv = document.getElementById('chart-builder-error');
|
||||
const createBtn = document.getElementById('chart-builder-create-btn');
|
||||
|
||||
if (!previewContainer) return;
|
||||
|
||||
try {
|
||||
// Validate configuration
|
||||
const isValid = validateChartConfig();
|
||||
|
||||
if (!isValid) {
|
||||
// Show placeholder
|
||||
previewContainer.innerHTML = '<div class="chart-preview-placeholder">Configure at least one encoding to see preview</div>';
|
||||
if (errorDiv) errorDiv.textContent = '';
|
||||
if (createBtn) createBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate spec
|
||||
const spec = generateVegaLiteSpec();
|
||||
if (!spec) return;
|
||||
|
||||
// Resolve dataset references (reuse existing function from editor.js)
|
||||
const resolvedSpec = await resolveDatasetReferences(JSON.parse(JSON.stringify(spec)));
|
||||
|
||||
// Clear container
|
||||
previewContainer.innerHTML = '';
|
||||
|
||||
// Render with Vega-Embed
|
||||
await window.vegaEmbed('#chart-builder-preview', resolvedSpec, {
|
||||
actions: false,
|
||||
renderer: 'svg'
|
||||
});
|
||||
|
||||
// Clear error and enable create button
|
||||
if (errorDiv) errorDiv.textContent = '';
|
||||
if (createBtn) createBtn.disabled = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rendering chart preview:', error);
|
||||
|
||||
// Show error message
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = error.message || 'Error rendering chart';
|
||||
}
|
||||
|
||||
// Show error in preview
|
||||
previewContainer.innerHTML = `<div class="chart-preview-placeholder" style="color: #d32f2f;">Error: ${error.message || 'Failed to render chart'}</div>`;
|
||||
|
||||
// Disable create button
|
||||
if (createBtn) createBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create snippet from chart builder
|
||||
async function createSnippetFromBuilder() {
|
||||
if (!window.chartBuilderState) return;
|
||||
|
||||
try {
|
||||
// Generate final spec
|
||||
const spec = generateVegaLiteSpec();
|
||||
if (!spec) return;
|
||||
|
||||
// Create snippet with auto-generated name
|
||||
const snippetName = generateSnippetName();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const snippet = {
|
||||
id: generateSnippetId(),
|
||||
name: snippetName,
|
||||
created: now,
|
||||
modified: now,
|
||||
spec: spec,
|
||||
draftSpec: null,
|
||||
comment: `Chart built from dataset: ${window.chartBuilderState.datasetName}`,
|
||||
tags: [],
|
||||
datasetRefs: [window.chartBuilderState.datasetName],
|
||||
meta: {}
|
||||
};
|
||||
|
||||
// Save snippet
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Close chart builder
|
||||
closeChartBuilder();
|
||||
|
||||
// Close dataset modal if open
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
if (datasetModal) {
|
||||
datasetModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Select and open the new snippet
|
||||
selectSnippet(snippet.id);
|
||||
|
||||
// Show success message
|
||||
showToast(`Created snippet: ${snippetName}`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating snippet from builder:', error);
|
||||
showToast('Error creating snippet', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Close chart builder modal
|
||||
// closeChartBuilder - now calls Alpine component's close() method
|
||||
function closeChartBuilder() {
|
||||
const modal = document.getElementById('chart-builder-modal');
|
||||
modal.style.display = 'none';
|
||||
|
||||
// Update URL - go back to dataset view
|
||||
if (window.chartBuilderState && window.chartBuilderState.datasetId) {
|
||||
URLState.update({
|
||||
view: 'datasets',
|
||||
datasetId: window.chartBuilderState.datasetId,
|
||||
action: null
|
||||
});
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(previewUpdateTimeout);
|
||||
|
||||
// Clear state
|
||||
window.chartBuilderState = null;
|
||||
|
||||
// Clear preview
|
||||
const previewContainer = document.getElementById('chart-builder-preview');
|
||||
if (previewContainer) {
|
||||
previewContainer.innerHTML = '<div class="chart-preview-placeholder">Configure chart to see preview</div>';
|
||||
}
|
||||
|
||||
// Clear error
|
||||
const errorDiv = document.getElementById('chart-builder-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = '';
|
||||
}
|
||||
|
||||
// Reset create button
|
||||
const createBtn = document.getElementById('chart-builder-create-btn');
|
||||
if (createBtn) {
|
||||
createBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart builder event listeners
|
||||
function initializeChartBuilder() {
|
||||
// Close button
|
||||
const closeBtn = document.getElementById('chart-builder-modal-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', closeChartBuilder);
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = document.getElementById('chart-builder-cancel-btn');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', closeChartBuilder);
|
||||
}
|
||||
|
||||
// Back button
|
||||
const backBtn = document.getElementById('chart-builder-back-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
closeChartBuilder();
|
||||
// Dataset modal should still be open
|
||||
});
|
||||
}
|
||||
|
||||
// Create snippet button
|
||||
const createBtn = document.getElementById('chart-builder-create-btn');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', createSnippetFromBuilder);
|
||||
}
|
||||
|
||||
// Mark type toggle buttons
|
||||
const markButtons = document.querySelectorAll('.mark-toggle-group .btn-toggle');
|
||||
markButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setActiveToggle(markButtons, btn);
|
||||
|
||||
if (window.chartBuilderState) {
|
||||
window.chartBuilderState.spec.mark.type = btn.dataset.mark;
|
||||
updateChartBuilderPreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Encoding field dropdowns
|
||||
const encodings = ['x', 'y', 'color', 'size'];
|
||||
encodings.forEach(encoding => {
|
||||
const select = document.getElementById(`encoding-${encoding}-field`);
|
||||
if (select) {
|
||||
select.addEventListener('change', async (e) => {
|
||||
const field = e.target.value;
|
||||
|
||||
if (!window.chartBuilderState) return;
|
||||
|
||||
if (field) {
|
||||
// Try to get active type button, or auto-detect from dataset
|
||||
const activeTypeBtn = document.querySelector(`[data-encoding="${encoding}"][data-type].active`);
|
||||
let type = activeTypeBtn ? activeTypeBtn.dataset.type : 'nominal';
|
||||
|
||||
// If no active type button, auto-detect from column type
|
||||
if (!activeTypeBtn && window.chartBuilderState.datasetId) {
|
||||
const dataset = await DatasetStorage.getDataset(window.chartBuilderState.datasetId);
|
||||
const columnTypes = dataset.columnTypes || [];
|
||||
const colType = columnTypes.find(ct => ct.name === field);
|
||||
if (colType) {
|
||||
type = mapColumnTypeToVegaType(colType.type);
|
||||
}
|
||||
}
|
||||
|
||||
setEncoding(encoding, field, type);
|
||||
} else {
|
||||
// Remove encoding when "None" is selected
|
||||
setEncoding(encoding, '', '');
|
||||
}
|
||||
|
||||
updateChartBuilderPreview();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Encoding type toggle buttons
|
||||
const typeButtons = document.querySelectorAll('.type-toggle-group .btn-toggle');
|
||||
typeButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const encoding = btn.dataset.encoding;
|
||||
const type = btn.dataset.type;
|
||||
|
||||
// Update active state for this encoding's buttons
|
||||
const encodingButtons = document.querySelectorAll(`[data-encoding="${encoding}"][data-type]`);
|
||||
setActiveToggle(encodingButtons, btn);
|
||||
|
||||
// Update state
|
||||
if (window.chartBuilderState && window.chartBuilderState.spec.encoding[encoding]) {
|
||||
window.chartBuilderState.spec.encoding[encoding].type = type;
|
||||
updateChartBuilderPreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dimension inputs
|
||||
const widthInput = document.getElementById('chart-width');
|
||||
const heightInput = document.getElementById('chart-height');
|
||||
|
||||
if (widthInput) {
|
||||
widthInput.addEventListener('input', () => {
|
||||
updateChartBuilderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
if (heightInput) {
|
||||
heightInput.addEventListener('input', () => {
|
||||
updateChartBuilderPreview();
|
||||
});
|
||||
const chartBuilderView = document.getElementById('chart-builder-view');
|
||||
if (chartBuilderView && chartBuilderView._x_dataStack) {
|
||||
const component = chartBuilderView._x_dataStack[0];
|
||||
component.close();
|
||||
}
|
||||
}
|
||||
|
||||
108
src/js/config.js
108
src/js/config.js
@@ -1,10 +1,9 @@
|
||||
// Application version (update with each release)
|
||||
const APP_VERSION = '0.3.0';
|
||||
const APP_VERSION = '0.4.0';
|
||||
|
||||
// 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;
|
||||
@@ -145,63 +144,73 @@ const AppSettings = {
|
||||
}
|
||||
};
|
||||
|
||||
// Toast Notification System
|
||||
// Alpine.js Store for toast notifications
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('toasts', {
|
||||
items: [],
|
||||
counter: 0,
|
||||
DURATION: 4000,
|
||||
|
||||
add(message, type = 'info') {
|
||||
const id = ++this.counter;
|
||||
const toast = { id, message, type, visible: false };
|
||||
this.items.push(toast);
|
||||
|
||||
// Trigger show animation on next tick
|
||||
setTimeout(() => {
|
||||
const found = this.items.find(t => t.id === id);
|
||||
if (found) found.visible = true;
|
||||
}, 10);
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => this.remove(id), this.DURATION);
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
const toast = this.items.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
// Remove from array after animation
|
||||
setTimeout(() => {
|
||||
this.items = this.items.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
getIcon(type) {
|
||||
const icons = {
|
||||
error: '❌',
|
||||
success: '✓',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
});
|
||||
|
||||
// Preview panel fit mode store
|
||||
Alpine.store('preview', {
|
||||
fitMode: 'default' // 'default' | 'width' | 'full'
|
||||
});
|
||||
});
|
||||
|
||||
// Toast Notification System (now backed by Alpine store)
|
||||
const Toast = {
|
||||
// Auto-dismiss duration in milliseconds
|
||||
DURATION: 4000,
|
||||
|
||||
// Toast counter for unique IDs
|
||||
toastCounter: 0,
|
||||
|
||||
// Show toast notification
|
||||
show(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
const toastId = `toast-${++this.toastCounter}`;
|
||||
toast.id = toastId;
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
// Toast icon based on type
|
||||
const icons = {
|
||||
error: '❌',
|
||||
success: '✓',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${icons[type] || icons.info}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-close" onclick="Toast.dismiss('${toastId}')">×</button>
|
||||
`;
|
||||
|
||||
// Add to container
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('toast-show'), 10);
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => this.dismiss(toastId), this.DURATION);
|
||||
if (Alpine.store('toasts')) {
|
||||
Alpine.store('toasts').add(message, type);
|
||||
}
|
||||
},
|
||||
|
||||
// Dismiss specific toast
|
||||
dismiss(toastId) {
|
||||
const toast = document.getElementById(toastId);
|
||||
if (!toast) return;
|
||||
|
||||
toast.classList.remove('toast-show');
|
||||
toast.classList.add('toast-hide');
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
if (Alpine.store('toasts')) {
|
||||
Alpine.store('toasts').remove(toastId);
|
||||
}
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
@@ -323,4 +332,3 @@ const sampleSpec = {
|
||||
"y": { "field": "value", "type": "quantitative" }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
// Dataset management with IndexedDB
|
||||
|
||||
// Alpine.js store for dataset UI state
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('datasets', {
|
||||
currentDatasetId: null,
|
||||
currentDatasetData: null
|
||||
});
|
||||
});
|
||||
|
||||
// Alpine.js component for dataset list - thin wrapper around existing logic
|
||||
function datasetList() {
|
||||
return {
|
||||
datasets: [],
|
||||
|
||||
async init() {
|
||||
await this.loadDatasets();
|
||||
},
|
||||
|
||||
async loadDatasets() {
|
||||
this.datasets = await DatasetStorage.listDatasets();
|
||||
// Sort by modified date (most recent first) - keeping existing behavior
|
||||
this.datasets.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
||||
},
|
||||
|
||||
formatMeta(dataset) {
|
||||
const formatLabel = dataset.format ? dataset.format.toUpperCase() : 'UNKNOWN';
|
||||
if (dataset.source === 'url') {
|
||||
if (dataset.rowCount !== null && dataset.size !== null) {
|
||||
return `URL • ${dataset.rowCount} rows • ${formatLabel} • ${formatBytes(dataset.size)}`;
|
||||
} else {
|
||||
return `URL • ${formatLabel}`;
|
||||
}
|
||||
} else {
|
||||
return `${dataset.rowCount} rows • ${formatLabel} • ${formatBytes(dataset.size)}`;
|
||||
}
|
||||
},
|
||||
|
||||
getUsageCount(dataset) {
|
||||
return countSnippetUsage(dataset.name);
|
||||
},
|
||||
|
||||
selectDataset(datasetId) {
|
||||
window.selectDataset(datasetId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'astrolabe-datasets';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'datasets';
|
||||
@@ -268,7 +314,7 @@ const DatasetStorage = {
|
||||
|
||||
// Helper: Get currently selected dataset
|
||||
async function getCurrentDataset() {
|
||||
return window.currentDatasetId ? await DatasetStorage.getDataset(window.currentDatasetId) : null;
|
||||
return Alpine.store('datasets').currentDatasetId ? await DatasetStorage.getDataset(Alpine.store('datasets').currentDatasetId) : null;
|
||||
}
|
||||
|
||||
// Count how many snippets use a specific dataset
|
||||
@@ -348,55 +394,15 @@ async function fetchURLMetadata(url, format) {
|
||||
}
|
||||
|
||||
// Render dataset list in modal
|
||||
// Alpine.js now handles rendering, this just triggers a refresh
|
||||
async function renderDatasetList() {
|
||||
const datasets = await DatasetStorage.listDatasets();
|
||||
|
||||
if (datasets.length === 0) {
|
||||
document.getElementById('dataset-list').innerHTML = '<div class="dataset-empty">No datasets yet. Click "New Dataset" to create one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by modified date (most recent first)
|
||||
datasets.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
||||
|
||||
// Format individual dataset items
|
||||
const formatDatasetItem = (dataset) => {
|
||||
let metaText;
|
||||
const formatLabel = dataset.format ? dataset.format.toUpperCase() : 'UNKNOWN';
|
||||
|
||||
if (dataset.source === 'url') {
|
||||
// Show metadata if available, otherwise just URL and format
|
||||
if (dataset.rowCount !== null && dataset.size !== null) {
|
||||
metaText = `URL • ${dataset.rowCount} rows • ${formatLabel} • ${formatBytes(dataset.size)}`;
|
||||
} else {
|
||||
metaText = `URL • ${formatLabel}`;
|
||||
}
|
||||
} else {
|
||||
metaText = `${dataset.rowCount} rows • ${formatLabel} • ${formatBytes(dataset.size)}`;
|
||||
const listView = document.getElementById('dataset-list-view');
|
||||
if (listView && listView.__x) {
|
||||
const component = Alpine.$data(listView);
|
||||
if (component && component.loadDatasets) {
|
||||
await component.loadDatasets();
|
||||
}
|
||||
|
||||
// Count snippet usage and create badge
|
||||
const usageCount = countSnippetUsage(dataset.name);
|
||||
const usageBadge = usageCount > 0
|
||||
? `<div class="dataset-usage-badge" title="${usageCount} snippet${usageCount !== 1 ? 's' : ''} using this dataset">📄 ${usageCount}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="dataset-item" data-item-id="${dataset.id}">
|
||||
<div class="dataset-info">
|
||||
<div class="dataset-name">${dataset.name}</div>
|
||||
<div class="dataset-meta">${metaText}</div>
|
||||
</div>
|
||||
${usageBadge}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// Use generic list renderer
|
||||
renderGenericList('dataset-list', datasets, formatDatasetItem, selectDataset, {
|
||||
emptyMessage: 'No datasets yet. Click "New Dataset" to create one.',
|
||||
itemSelector: '.dataset-item'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Select a dataset and show details
|
||||
@@ -404,13 +410,9 @@ async function selectDataset(datasetId, updateURL = true) {
|
||||
const dataset = await DatasetStorage.getDataset(datasetId);
|
||||
if (!dataset) return;
|
||||
|
||||
// Update selection state
|
||||
document.querySelectorAll('.dataset-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const selectedItem = document.querySelector(`[data-item-id="${datasetId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
// Update Alpine store selection (Alpine handles highlighting via :class binding)
|
||||
if (typeof Alpine !== 'undefined' && Alpine.store('datasets')) {
|
||||
Alpine.store('datasets').currentDatasetId = datasetId;
|
||||
}
|
||||
|
||||
// Show details panel
|
||||
@@ -485,9 +487,9 @@ async function selectDataset(datasetId, updateURL = true) {
|
||||
showRawPreview(dataset);
|
||||
}
|
||||
|
||||
// Store current dataset ID and data
|
||||
window.currentDatasetId = datasetId;
|
||||
window.currentDatasetData = dataset;
|
||||
// Store current dataset ID and data in Alpine store
|
||||
Alpine.store('datasets').currentDatasetId = datasetId;
|
||||
Alpine.store('datasets').currentDatasetData = dataset;
|
||||
|
||||
// Update linked snippets display
|
||||
updateLinkedSnippets(dataset);
|
||||
@@ -559,8 +561,8 @@ async function loadURLPreview(dataset) {
|
||||
source: 'inline' // Treat as inline for preview purposes
|
||||
};
|
||||
|
||||
// Update current dataset data for preview
|
||||
window.currentDatasetData = previewDataset;
|
||||
// Update current dataset data for preview in Alpine store
|
||||
Alpine.store('datasets').currentDatasetData = previewDataset;
|
||||
|
||||
// Show toggle buttons now that we have data
|
||||
const toggleGroup = document.getElementById('preview-toggle-group');
|
||||
@@ -864,7 +866,7 @@ function openDatasetManager(updateURL = true) {
|
||||
function closeDatasetManager(updateURL = true) {
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
modal.style.display = 'none';
|
||||
window.currentDatasetId = null;
|
||||
Alpine.store('datasets').currentDatasetId = null;
|
||||
|
||||
// Hide dataset form if it's open (without updating URL to avoid double update)
|
||||
const formView = document.getElementById('dataset-form-view');
|
||||
@@ -874,8 +876,8 @@ function closeDatasetManager(updateURL = true) {
|
||||
|
||||
// Update URL state - restore snippet if one is selected
|
||||
if (updateURL) {
|
||||
if (window.currentSnippetId) {
|
||||
URLState.update({ view: 'snippets', snippetId: window.currentSnippetId, datasetId: null });
|
||||
if (Alpine.store('snippets').currentSnippetId) {
|
||||
URLState.update({ view: 'snippets', snippetId: Alpine.store('snippets').currentSnippetId, datasetId: null });
|
||||
} else {
|
||||
URLState.clear();
|
||||
}
|
||||
@@ -1445,7 +1447,7 @@ async function saveNewDataset() {
|
||||
Toast.success('Dataset updated successfully');
|
||||
|
||||
// Refresh visualization if a snippet is open
|
||||
if (window.currentSnippetId && typeof renderVisualization === 'function') {
|
||||
if (Alpine.store('snippets').currentSnippetId && typeof renderVisualization === 'function') {
|
||||
await renderVisualization();
|
||||
}
|
||||
|
||||
@@ -1476,7 +1478,7 @@ async function saveNewDataset() {
|
||||
Toast.success('Dataset created successfully');
|
||||
|
||||
// Refresh visualization if a snippet is open
|
||||
if (window.currentSnippetId && typeof renderVisualization === 'function') {
|
||||
if (Alpine.store('snippets').currentSnippetId && typeof renderVisualization === 'function') {
|
||||
await renderVisualization();
|
||||
}
|
||||
|
||||
@@ -1502,7 +1504,7 @@ async function deleteCurrentDataset() {
|
||||
confirmGenericDeletion(dataset.name, warningMessage, async () => {
|
||||
await DatasetStorage.deleteDataset(dataset.id);
|
||||
document.getElementById('dataset-details').style.display = 'none';
|
||||
window.currentDatasetId = null;
|
||||
Alpine.store('datasets').currentDatasetId = null;
|
||||
await renderDatasetList();
|
||||
|
||||
// Show success message
|
||||
@@ -1546,7 +1548,7 @@ async function refreshDatasetMetadata() {
|
||||
await renderDatasetList();
|
||||
|
||||
// Refresh visualization if a snippet is open
|
||||
if (window.currentSnippetId && typeof renderVisualization === 'function') {
|
||||
if (Alpine.store('snippets').currentSnippetId && typeof renderVisualization === 'function') {
|
||||
await renderVisualization();
|
||||
}
|
||||
|
||||
@@ -1859,7 +1861,7 @@ async function autoSaveDatasetMeta() {
|
||||
}
|
||||
|
||||
// Refresh visualization if a snippet is open
|
||||
if (window.currentSnippetId && typeof renderVisualization === 'function') {
|
||||
if (Alpine.store('snippets').currentSnippetId && typeof renderVisualization === 'function') {
|
||||
await renderVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,15 +194,14 @@ function updateRenderDebounce(newDebounce) {
|
||||
function setPreviewFitMode(mode) {
|
||||
previewFitMode = mode;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('preview-fit-default').classList.toggle('active', mode === 'default');
|
||||
document.getElementById('preview-fit-width').classList.toggle('active', mode === 'width');
|
||||
document.getElementById('preview-fit-full').classList.toggle('active', mode === 'full');
|
||||
|
||||
// Sync with Alpine store if available
|
||||
if (typeof Alpine !== 'undefined' && Alpine.store('preview')) {
|
||||
Alpine.store('preview').fitMode = mode;
|
||||
}
|
||||
|
||||
// Save to settings
|
||||
if (typeof updateSetting === 'function') {
|
||||
updateSetting('ui.previewFitMode', mode);
|
||||
}
|
||||
updateSetting('ui.previewFitMode', mode);
|
||||
|
||||
// Re-render with new fit mode
|
||||
renderVisualization();
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// Alpine.js Store for panel visibility state
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('panels', {
|
||||
snippetVisible: true,
|
||||
editorVisible: true,
|
||||
previewVisible: true
|
||||
});
|
||||
});
|
||||
|
||||
// Panel toggle and expansion functions
|
||||
function updatePanelMemory() {
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
@@ -19,26 +28,34 @@ function updatePanelMemory() {
|
||||
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
const button = document.getElementById(`toggle-${panelId}`);
|
||||
|
||||
if (!panel || !button) return;
|
||||
if (!panel) return;
|
||||
|
||||
if (panel.style.display === 'none') {
|
||||
const isVisible = panel.style.display !== 'none';
|
||||
const newVisibility = !isVisible;
|
||||
|
||||
// Update panel display
|
||||
if (newVisibility) {
|
||||
// Show panel
|
||||
panel.style.display = 'flex';
|
||||
button.classList.add('active');
|
||||
|
||||
// Restore from memory and redistribute
|
||||
redistributePanelWidths();
|
||||
} else {
|
||||
// Hide panel - DON'T update memory, just hide
|
||||
panel.style.display = 'none';
|
||||
button.classList.remove('active');
|
||||
|
||||
// Redistribute remaining panels
|
||||
redistributePanelWidths();
|
||||
}
|
||||
|
||||
// Update Alpine store for button states
|
||||
if (Alpine.store('panels')) {
|
||||
if (panelId === 'snippet-panel') {
|
||||
Alpine.store('panels').snippetVisible = newVisibility;
|
||||
} else if (panelId === 'editor-panel') {
|
||||
Alpine.store('panels').editorVisible = newVisibility;
|
||||
} else if (panelId === 'preview-panel') {
|
||||
Alpine.store('panels').previewVisible = newVisibility;
|
||||
}
|
||||
}
|
||||
|
||||
saveLayoutToStorage();
|
||||
}
|
||||
|
||||
@@ -113,10 +130,12 @@ function loadLayoutFromStorage() {
|
||||
editorPanel.style.display = layout.editorVisible !== false ? 'flex' : 'none';
|
||||
previewPanel.style.display = layout.previewVisible !== false ? 'flex' : 'none';
|
||||
|
||||
// Update toggle button states
|
||||
document.getElementById('toggle-snippet-panel').classList.toggle('active', layout.snippetVisible !== false);
|
||||
document.getElementById('toggle-editor-panel').classList.toggle('active', layout.editorVisible !== false);
|
||||
document.getElementById('toggle-preview-panel').classList.toggle('active', layout.previewVisible !== false);
|
||||
// Update Alpine store for button states
|
||||
if (Alpine.store('panels')) {
|
||||
Alpine.store('panels').snippetVisible = layout.snippetVisible !== false;
|
||||
Alpine.store('panels').editorVisible = layout.editorVisible !== false;
|
||||
Alpine.store('panels').previewVisible = layout.previewVisible !== false;
|
||||
}
|
||||
|
||||
// Restore widths and redistribute
|
||||
snippetPanel.style.width = layout.snippetWidth;
|
||||
|
||||
@@ -1,5 +1,111 @@
|
||||
// Snippet management and localStorage functionality
|
||||
|
||||
// Alpine.js Store for UI state only (selection tracking)
|
||||
// Business logic stays in SnippetStorage
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('snippets', {
|
||||
currentSnippetId: null,
|
||||
viewMode: 'draft' // 'draft' or 'published'
|
||||
});
|
||||
});
|
||||
|
||||
// Alpine.js Component for snippet list
|
||||
// Thin wrapper around SnippetStorage - Alpine handles reactivity, storage handles logic
|
||||
function snippetList() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
sortBy: AppSettings.get('sortBy') || 'modified',
|
||||
sortOrder: AppSettings.get('sortOrder') || 'desc',
|
||||
|
||||
// Meta fields for selected snippet
|
||||
snippetName: '',
|
||||
snippetComment: '',
|
||||
metaSaveTimeout: null,
|
||||
|
||||
// Computed property: calls SnippetStorage with current filters/sort
|
||||
get filteredSnippets() {
|
||||
return SnippetStorage.listSnippets(
|
||||
this.sortBy,
|
||||
this.sortOrder,
|
||||
this.searchQuery
|
||||
);
|
||||
},
|
||||
|
||||
toggleSort(sortType) {
|
||||
if (this.sortBy === sortType) {
|
||||
// Toggle order
|
||||
this.sortOrder = this.sortOrder === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
// Switch to new sort type with desc order
|
||||
this.sortBy = sortType;
|
||||
this.sortOrder = 'desc';
|
||||
}
|
||||
|
||||
// Save to settings
|
||||
AppSettings.set('sortBy', this.sortBy);
|
||||
AppSettings.set('sortOrder', this.sortOrder);
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.searchQuery = '';
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
if (searchInput) searchInput.focus();
|
||||
},
|
||||
|
||||
// Helper methods for display
|
||||
formatDate(snippet) {
|
||||
const date = this.sortBy === 'created' ? snippet.created : snippet.modified;
|
||||
return formatSnippetDate(date);
|
||||
},
|
||||
|
||||
getSize(snippet) {
|
||||
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
|
||||
return snippetSize / 1024; // KB
|
||||
},
|
||||
|
||||
hasDraft(snippet) {
|
||||
return JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
||||
},
|
||||
|
||||
// Load meta fields when a snippet is selected
|
||||
loadMetadata(snippet) {
|
||||
this.snippetName = snippet.name || '';
|
||||
this.snippetComment = snippet.comment || '';
|
||||
},
|
||||
|
||||
// Save meta fields with debouncing (called via x-model watchers)
|
||||
saveMetaDebounced() {
|
||||
clearTimeout(this.metaSaveTimeout);
|
||||
this.metaSaveTimeout = setTimeout(() => this.saveMeta(), 1000);
|
||||
},
|
||||
|
||||
// Save meta fields to storage
|
||||
saveMeta() {
|
||||
const snippet = getCurrentSnippet();
|
||||
if (snippet) {
|
||||
snippet.name = this.snippetName.trim() || generateSnippetName();
|
||||
snippet.comment = this.snippetComment;
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Update the snippet list display to reflect the new name
|
||||
renderSnippetList();
|
||||
|
||||
// Restore selection after re-render
|
||||
restoreSnippetSelection();
|
||||
}
|
||||
},
|
||||
|
||||
// Actions
|
||||
selectSnippet(snippetId) {
|
||||
window.selectSnippet(snippetId);
|
||||
},
|
||||
|
||||
createNewSnippet() {
|
||||
window.createNewSnippet();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Storage limits (5MB in bytes)
|
||||
const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -322,220 +428,24 @@ function formatFullDate(isoString) {
|
||||
}
|
||||
|
||||
// Render snippet list in the UI
|
||||
// With Alpine.js, the list is reactive - no manual rendering needed
|
||||
// This function kept as no-op for backwards compatibility
|
||||
function renderSnippetList(searchQuery = null) {
|
||||
// Get search query from input if not provided
|
||||
if (searchQuery === null) {
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
searchQuery = searchInput ? searchInput.value : '';
|
||||
}
|
||||
|
||||
const snippets = SnippetStorage.listSnippets(null, null, searchQuery);
|
||||
const placeholder = document.querySelector('.placeholder');
|
||||
|
||||
// Handle empty state with placeholder
|
||||
if (snippets.length === 0) {
|
||||
document.querySelector('.snippet-list').innerHTML = '';
|
||||
placeholder.style.display = 'block';
|
||||
placeholder.textContent = searchQuery && searchQuery.trim()
|
||||
? 'No snippets match your search'
|
||||
: 'No snippets found';
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.style.display = 'none';
|
||||
|
||||
const currentSort = AppSettings.get('sortBy');
|
||||
|
||||
// Format individual snippet items
|
||||
const formatSnippetItem = (snippet) => {
|
||||
// Show appropriate date based on current sort
|
||||
const dateText = currentSort === 'created'
|
||||
? formatSnippetDate(snippet.created)
|
||||
: formatSnippetDate(snippet.modified);
|
||||
|
||||
// Calculate snippet size
|
||||
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
|
||||
const sizeKB = snippetSize / 1024;
|
||||
const sizeHTML = sizeKB >= 1 ? `<span class="snippet-size">${sizeKB.toFixed(0)} KB</span>` : '';
|
||||
|
||||
// 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';
|
||||
|
||||
// Check if snippet uses external datasets
|
||||
const usesDatasets = snippet.datasetRefs && snippet.datasetRefs.length > 0;
|
||||
const datasetIconHTML = usesDatasets ? '<span class="snippet-dataset-icon" title="Uses external dataset">📁</span>' : '';
|
||||
|
||||
return `
|
||||
<li class="snippet-item" data-item-id="${snippet.id}">
|
||||
<div class="snippet-info">
|
||||
<div class="snippet-name">${snippet.name}${datasetIconHTML}</div>
|
||||
<div class="snippet-date">${dateText}</div>
|
||||
</div>
|
||||
${sizeHTML}
|
||||
<div class="snippet-status ${statusClass}"></div>
|
||||
</li>
|
||||
`;
|
||||
};
|
||||
|
||||
// Ghost card for creating new snippets
|
||||
const ghostCard = `
|
||||
<li class="snippet-item ghost-card" id="new-snippet-card">
|
||||
<div class="snippet-name">+ Create New Snippet</div>
|
||||
<div class="snippet-date">Click to create</div>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// Use generic list renderer
|
||||
renderGenericList('snippet-list', snippets, formatSnippetItem, selectSnippet, {
|
||||
ghostCard: ghostCard,
|
||||
onGhostCardClick: createNewSnippet,
|
||||
itemSelector: '.snippet-item'
|
||||
});
|
||||
// Alpine.js handles rendering automatically via reactive bindings
|
||||
}
|
||||
|
||||
// Initialize sort controls
|
||||
function initializeSortControls() {
|
||||
const sortButtons = document.querySelectorAll('.sort-btn');
|
||||
const currentSort = AppSettings.get('sortBy');
|
||||
const currentOrder = AppSettings.get('sortOrder');
|
||||
|
||||
// Update active button and arrow based on settings
|
||||
sortButtons.forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.sort === currentSort) {
|
||||
button.classList.add('active');
|
||||
updateSortArrow(button, currentOrder);
|
||||
} else {
|
||||
updateSortArrow(button, 'desc'); // Default to desc for inactive buttons
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
button.addEventListener('click', function() {
|
||||
const sortType = this.dataset.sort;
|
||||
toggleSort(sortType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update sort arrow display
|
||||
function updateSortArrow(button, direction) {
|
||||
const arrow = button.querySelector('.sort-arrow');
|
||||
if (arrow) {
|
||||
arrow.textContent = direction === 'desc' ? '⬇' : '⬆';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle sort method and direction
|
||||
function toggleSort(sortType) {
|
||||
const currentSort = AppSettings.get('sortBy');
|
||||
const currentOrder = AppSettings.get('sortOrder');
|
||||
|
||||
let newOrder;
|
||||
if (currentSort === sortType) {
|
||||
// Same button clicked - toggle direction
|
||||
newOrder = currentOrder === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
// Different button clicked - default to desc
|
||||
newOrder = 'desc';
|
||||
}
|
||||
|
||||
// Save to settings
|
||||
AppSettings.set('sortBy', sortType);
|
||||
AppSettings.set('sortOrder', newOrder);
|
||||
|
||||
// Update button states and arrows
|
||||
document.querySelectorAll('.sort-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.sort === sortType) {
|
||||
btn.classList.add('active');
|
||||
updateSortArrow(btn, newOrder);
|
||||
} else {
|
||||
updateSortArrow(btn, 'desc'); // Default for inactive buttons
|
||||
}
|
||||
});
|
||||
|
||||
// Re-render list
|
||||
renderSnippetList();
|
||||
|
||||
// Restore selection if there was one
|
||||
restoreSnippetSelection();
|
||||
}
|
||||
|
||||
// Initialize search controls
|
||||
function initializeSearchControls() {
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
const clearButton = document.getElementById('search-clear');
|
||||
|
||||
if (searchInput) {
|
||||
// Debounced search on input
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch();
|
||||
}, 300); // 300ms debounce
|
||||
});
|
||||
|
||||
// Update clear button state
|
||||
searchInput.addEventListener('input', updateClearButton);
|
||||
}
|
||||
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', clearSearch);
|
||||
// Initialize clear button state
|
||||
updateClearButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Perform search and update display
|
||||
function performSearch() {
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
if (!searchInput) return;
|
||||
|
||||
renderSnippetList(searchInput.value);
|
||||
|
||||
// Clear selection if current snippet is no longer visible
|
||||
if (window.currentSnippetId) {
|
||||
const selectedItem = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`);
|
||||
if (!selectedItem) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear search
|
||||
function clearSearch() {
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
performSearch();
|
||||
updateClearButton();
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Update clear button state
|
||||
function updateClearButton() {
|
||||
const searchInput = document.getElementById('snippet-search');
|
||||
const clearButton = document.getElementById('search-clear');
|
||||
|
||||
if (clearButton && searchInput) {
|
||||
clearButton.disabled = !searchInput.value.trim();
|
||||
}
|
||||
}
|
||||
// NOTE: Sort and search controls are now handled by Alpine.js via directives
|
||||
// No initialization needed - Alpine components are automatically initialized
|
||||
|
||||
// Helper: Get currently selected snippet
|
||||
function getCurrentSnippet() {
|
||||
return window.currentSnippetId ? SnippetStorage.getSnippet(window.currentSnippetId) : null;
|
||||
return Alpine.store('snippets').currentSnippetId ? SnippetStorage.getSnippet(Alpine.store('snippets').currentSnippetId) : null;
|
||||
}
|
||||
|
||||
// Helper: Restore visual selection state for current snippet
|
||||
function restoreSnippetSelection() {
|
||||
if (window.currentSnippetId) {
|
||||
const item = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`);
|
||||
if (Alpine.store('snippets').currentSnippetId) {
|
||||
const item = document.querySelector(`[data-item-id="${Alpine.store('snippets').currentSnippetId}"]`);
|
||||
if (item) {
|
||||
item.classList.add('selected');
|
||||
return item;
|
||||
@@ -546,7 +456,7 @@ function restoreSnippetSelection() {
|
||||
|
||||
// Clear current selection and hide meta panel
|
||||
function clearSelection() {
|
||||
window.currentSnippetId = null;
|
||||
Alpine.store('snippets').currentSnippetId = null;
|
||||
document.querySelectorAll('.snippet-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
@@ -573,13 +483,9 @@ function selectSnippet(snippetId, updateURL = true) {
|
||||
const snippet = SnippetStorage.getSnippet(snippetId);
|
||||
if (!snippet) return;
|
||||
|
||||
// Update visual selection
|
||||
document.querySelectorAll('.snippet-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const selectedItem = document.querySelector(`[data-item-id="${snippetId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
// Update Alpine store selection for UI highlighting
|
||||
if (typeof Alpine !== 'undefined' && Alpine.store('snippets')) {
|
||||
Alpine.store('snippets').currentSnippetId = snippetId;
|
||||
}
|
||||
|
||||
// Load spec based on current view mode
|
||||
@@ -588,16 +494,21 @@ function selectSnippet(snippetId, updateURL = true) {
|
||||
|
||||
// Show and populate meta fields
|
||||
const metaSection = document.getElementById('snippet-meta');
|
||||
const nameField = document.getElementById('snippet-name');
|
||||
const commentField = document.getElementById('snippet-comment');
|
||||
const createdField = document.getElementById('snippet-created');
|
||||
const modifiedField = document.getElementById('snippet-modified');
|
||||
const placeholder = document.querySelector('.placeholder');
|
||||
|
||||
if (metaSection && nameField && commentField) {
|
||||
if (metaSection) {
|
||||
metaSection.style.display = 'block';
|
||||
nameField.value = snippet.name || '';
|
||||
commentField.value = snippet.comment || '';
|
||||
|
||||
// Load metadata into Alpine component
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
if (snippetPanel && snippetPanel._x_dataStack) {
|
||||
const alpineData = snippetPanel._x_dataStack[0];
|
||||
if (alpineData && typeof alpineData.loadMetadata === 'function') {
|
||||
alpineData.loadMetadata(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
// Format and display dates
|
||||
if (createdField) {
|
||||
@@ -607,11 +518,13 @@ function selectSnippet(snippetId, updateURL = true) {
|
||||
modifiedField.textContent = formatFullDate(snippet.modified);
|
||||
}
|
||||
|
||||
placeholder.style.display = 'none';
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Store currently selected snippet ID globally
|
||||
window.currentSnippetId = snippetId;
|
||||
// Store currently selected snippet ID in Alpine store (redundant with Alpine.store update above)
|
||||
// Alpine.store('snippets').currentSnippetId is already updated above
|
||||
|
||||
// Update linked datasets display
|
||||
updateLinkedDatasets(snippet);
|
||||
@@ -676,10 +589,10 @@ window.isUpdatingEditor = false; // Global flag to prevent auto-save/debounce du
|
||||
|
||||
// Save current editor content as draft for the selected snippet
|
||||
function autoSaveDraft() {
|
||||
if (!window.currentSnippetId || !editor) return;
|
||||
if (!Alpine.store('snippets').currentSnippetId || !editor) return;
|
||||
|
||||
// Only save to draft if we're in draft mode
|
||||
if (currentViewMode !== 'draft') return;
|
||||
if (Alpine.store('snippets').viewMode !== 'draft') return;
|
||||
|
||||
try {
|
||||
const currentSpec = JSON.parse(editor.getValue());
|
||||
@@ -712,13 +625,13 @@ function debouncedAutoSave() {
|
||||
if (window.isUpdatingEditor) return;
|
||||
|
||||
// If viewing published and no draft exists, create draft automatically
|
||||
if (currentViewMode === 'published') {
|
||||
if (Alpine.store('snippets').viewMode === 'published') {
|
||||
const snippet = getCurrentSnippet();
|
||||
if (snippet) {
|
||||
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
||||
if (!hasDraft) {
|
||||
// No draft exists, automatically switch to draft mode
|
||||
currentViewMode = 'draft';
|
||||
Alpine.store('snippets').viewMode = 'draft';
|
||||
updateViewModeUI(snippet);
|
||||
editor.updateOptions({ readOnly: false });
|
||||
}
|
||||
@@ -731,21 +644,7 @@ function debouncedAutoSave() {
|
||||
|
||||
// Initialize auto-save on editor changes
|
||||
function initializeAutoSave() {
|
||||
// Initialize meta fields auto-save
|
||||
const nameField = document.getElementById('snippet-name');
|
||||
const commentField = document.getElementById('snippet-comment');
|
||||
|
||||
if (nameField) {
|
||||
nameField.addEventListener('input', () => {
|
||||
debouncedAutoSaveMeta();
|
||||
});
|
||||
}
|
||||
|
||||
if (commentField) {
|
||||
commentField.addEventListener('input', () => {
|
||||
debouncedAutoSaveMeta();
|
||||
});
|
||||
}
|
||||
// Meta fields auto-save now handled by Alpine.js in snippetList() component
|
||||
|
||||
// Initialize button event listeners
|
||||
const duplicateBtn = document.getElementById('duplicate-btn');
|
||||
@@ -753,48 +652,21 @@ function initializeAutoSave() {
|
||||
|
||||
if (duplicateBtn) {
|
||||
duplicateBtn.addEventListener('click', () => {
|
||||
if (window.currentSnippetId) {
|
||||
duplicateSnippet(window.currentSnippetId);
|
||||
if (Alpine.store('snippets').currentSnippetId) {
|
||||
duplicateSnippet(Alpine.store('snippets').currentSnippetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
if (window.currentSnippetId) {
|
||||
deleteSnippet(window.currentSnippetId);
|
||||
if (Alpine.store('snippets').currentSnippetId) {
|
||||
deleteSnippet(Alpine.store('snippets').currentSnippetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save meta fields (name and comment) for the selected snippet
|
||||
function autoSaveMeta() {
|
||||
const nameField = document.getElementById('snippet-name');
|
||||
const commentField = document.getElementById('snippet-comment');
|
||||
if (!nameField || !commentField) return;
|
||||
|
||||
const snippet = getCurrentSnippet();
|
||||
if (snippet) {
|
||||
snippet.name = nameField.value.trim() || generateSnippetName();
|
||||
snippet.comment = commentField.value;
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Update the snippet list display to reflect the new name
|
||||
renderSnippetList();
|
||||
|
||||
// Restore selection after re-render
|
||||
restoreSnippetSelection();
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced meta auto-save
|
||||
let metaAutoSaveTimeout;
|
||||
function debouncedAutoSaveMeta() {
|
||||
clearTimeout(metaAutoSaveTimeout);
|
||||
metaAutoSaveTimeout = setTimeout(autoSaveMeta, 1000);
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
// Create new snippet
|
||||
@@ -979,7 +851,7 @@ async function extractToDataset() {
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Update editor with new spec
|
||||
if (editor && currentViewMode === 'draft') {
|
||||
if (editor && Alpine.store('snippets').viewMode === 'draft') {
|
||||
window.isUpdatingEditor = true;
|
||||
editor.setValue(JSON.stringify(snippet.draftSpec, null, 2));
|
||||
window.isUpdatingEditor = false;
|
||||
@@ -1030,7 +902,7 @@ function deleteSnippet(snippetId) {
|
||||
SnippetStorage.deleteSnippet(snippetId);
|
||||
|
||||
// If we deleted the currently selected snippet, clear selection
|
||||
if (window.currentSnippetId === snippetId) {
|
||||
if (Alpine.store('snippets').currentSnippetId === snippetId) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -1055,7 +927,7 @@ function loadSnippetIntoEditor(snippet) {
|
||||
|
||||
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
||||
|
||||
if (currentViewMode === 'draft') {
|
||||
if (Alpine.store('snippets').viewMode === 'draft') {
|
||||
editor.setValue(JSON.stringify(snippet.draftSpec, null, 2));
|
||||
editor.updateOptions({ readOnly: false });
|
||||
} else {
|
||||
@@ -1069,24 +941,14 @@ function loadSnippetIntoEditor(snippet) {
|
||||
|
||||
// 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
|
||||
// Toggle button states are now handled by Alpine :class binding
|
||||
// This function only updates the action buttons (publish/revert)
|
||||
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
||||
|
||||
if (currentViewMode === 'draft') {
|
||||
if (Alpine.store('snippets').viewMode === 'draft') {
|
||||
// In draft mode: show both buttons, enable based on draft existence
|
||||
publishBtn.classList.add('visible');
|
||||
revertBtn.classList.add('visible');
|
||||
@@ -1101,7 +963,7 @@ function updateViewModeUI(snippet) {
|
||||
|
||||
// Switch view mode
|
||||
function switchViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
Alpine.store('snippets').viewMode = mode;
|
||||
const snippet = getCurrentSnippet();
|
||||
if (snippet) {
|
||||
loadSnippetIntoEditor(snippet);
|
||||
@@ -1146,7 +1008,7 @@ function revertDraft() {
|
||||
SnippetStorage.saveSnippet(snippet);
|
||||
|
||||
// Reload editor if in draft view
|
||||
if (currentViewMode === 'draft') {
|
||||
if (Alpine.store('snippets').viewMode === 'draft') {
|
||||
loadSnippetIntoEditor(snippet);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,3 +222,166 @@ function validateSetting(path, value) {
|
||||
|
||||
return rules[path] ? rules[path]() : [];
|
||||
}
|
||||
|
||||
// Alpine.js Component for settings panel
|
||||
// Thin wrapper - Alpine handles form state and reactivity, user-settings.js handles storage
|
||||
function settingsPanel() {
|
||||
return {
|
||||
// Form state (loaded from settings on open)
|
||||
uiTheme: 'light',
|
||||
fontSize: 12,
|
||||
editorTheme: 'vs-light',
|
||||
tabSize: 2,
|
||||
minimap: false,
|
||||
wordWrap: true,
|
||||
lineNumbers: true,
|
||||
renderDebounce: 1500,
|
||||
dateFormat: 'smart',
|
||||
customDateFormat: 'yyyy-MM-dd HH:mm',
|
||||
|
||||
// Original values for dirty checking
|
||||
originalSettings: null,
|
||||
|
||||
// Initialize component with current settings
|
||||
init() {
|
||||
this.loadSettings();
|
||||
},
|
||||
|
||||
// Load settings from storage into form
|
||||
loadSettings() {
|
||||
const settings = getSettings();
|
||||
this.uiTheme = settings.ui.theme;
|
||||
this.fontSize = settings.editor.fontSize;
|
||||
this.editorTheme = settings.editor.theme;
|
||||
this.tabSize = settings.editor.tabSize;
|
||||
this.minimap = settings.editor.minimap;
|
||||
this.wordWrap = settings.editor.wordWrap === 'on';
|
||||
this.lineNumbers = settings.editor.lineNumbers === 'on';
|
||||
this.renderDebounce = settings.performance.renderDebounce;
|
||||
this.dateFormat = settings.formatting.dateFormat;
|
||||
this.customDateFormat = settings.formatting.customDateFormat;
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.originalSettings = JSON.stringify(this.getCurrentFormState());
|
||||
},
|
||||
|
||||
// Get current form state as object
|
||||
getCurrentFormState() {
|
||||
return {
|
||||
uiTheme: this.uiTheme,
|
||||
fontSize: this.fontSize,
|
||||
editorTheme: this.editorTheme,
|
||||
tabSize: this.tabSize,
|
||||
minimap: this.minimap,
|
||||
wordWrap: this.wordWrap,
|
||||
lineNumbers: this.lineNumbers,
|
||||
renderDebounce: this.renderDebounce,
|
||||
dateFormat: this.dateFormat,
|
||||
customDateFormat: this.customDateFormat
|
||||
};
|
||||
},
|
||||
|
||||
// Check if settings have been modified
|
||||
get isDirty() {
|
||||
return this.originalSettings !== JSON.stringify(this.getCurrentFormState());
|
||||
},
|
||||
|
||||
// Show custom date format field when 'custom' is selected
|
||||
get showCustomDateFormat() {
|
||||
return this.dateFormat === 'custom';
|
||||
},
|
||||
|
||||
// Apply settings and save
|
||||
apply() {
|
||||
const newSettings = {
|
||||
'ui.theme': this.uiTheme,
|
||||
'editor.fontSize': parseInt(this.fontSize),
|
||||
'editor.theme': this.editorTheme,
|
||||
'editor.tabSize': parseInt(this.tabSize),
|
||||
'editor.minimap': this.minimap,
|
||||
'editor.wordWrap': this.wordWrap ? 'on' : 'off',
|
||||
'editor.lineNumbers': this.lineNumbers ? 'on' : 'off',
|
||||
'performance.renderDebounce': parseInt(this.renderDebounce),
|
||||
'formatting.dateFormat': this.dateFormat,
|
||||
'formatting.customDateFormat': this.customDateFormat
|
||||
};
|
||||
|
||||
// Validate settings
|
||||
let hasErrors = false;
|
||||
for (const [path, value] of Object.entries(newSettings)) {
|
||||
const errors = validateSetting(path, value);
|
||||
if (errors.length > 0) {
|
||||
Toast.show(errors.join(', '), 'error');
|
||||
hasErrors = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
// Save settings
|
||||
if (updateSettings(newSettings)) {
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', this.uiTheme);
|
||||
|
||||
// Sync editor theme with UI theme
|
||||
const editorTheme = this.uiTheme === 'experimental' ? 'vs-dark' : 'vs-light';
|
||||
newSettings['editor.theme'] = editorTheme;
|
||||
|
||||
// Apply editor settings immediately
|
||||
if (editor) {
|
||||
editor.updateOptions({
|
||||
fontSize: newSettings['editor.fontSize'],
|
||||
theme: editorTheme,
|
||||
tabSize: newSettings['editor.tabSize'],
|
||||
minimap: { enabled: newSettings['editor.minimap'] },
|
||||
wordWrap: newSettings['editor.wordWrap'],
|
||||
lineNumbers: newSettings['editor.lineNumbers']
|
||||
});
|
||||
}
|
||||
|
||||
// Update the editor theme in settings
|
||||
updateSetting('editor.theme', editorTheme);
|
||||
|
||||
// Update debounced render function
|
||||
if (typeof updateRenderDebounce === 'function') {
|
||||
updateRenderDebounce(newSettings['performance.renderDebounce']);
|
||||
}
|
||||
|
||||
// Re-render snippet list to reflect date format changes
|
||||
renderSnippetList();
|
||||
|
||||
// Update metadata display if a snippet is selected
|
||||
if (Alpine.store('snippets').currentSnippetId) {
|
||||
const snippet = SnippetStorage.getSnippet(Alpine.store('snippets').currentSnippetId);
|
||||
if (snippet) {
|
||||
document.getElementById('snippet-created').textContent = formatDate(snippet.created, true);
|
||||
document.getElementById('snippet-modified').textContent = formatDate(snippet.modified, true);
|
||||
}
|
||||
}
|
||||
|
||||
Toast.success('Settings applied successfully');
|
||||
closeSettingsModal();
|
||||
|
||||
// Track event
|
||||
Analytics.track('settings-apply', 'Applied settings');
|
||||
} else {
|
||||
Toast.error('Failed to save settings');
|
||||
}
|
||||
},
|
||||
|
||||
// Reset to defaults
|
||||
reset() {
|
||||
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
|
||||
resetSettings();
|
||||
this.loadSettings();
|
||||
Toast.success('Settings reset to defaults');
|
||||
}
|
||||
},
|
||||
|
||||
// Cancel changes and close modal
|
||||
cancel() {
|
||||
closeSettingsModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user