Merge pull request #1 from olehomelchenko/refactor/alpinejs

Refactor/alpinejs
This commit is contained in:
Oleh Omelchenko
2025-12-08 16:05:41 +02:00
committed by GitHub
13 changed files with 1369 additions and 1255 deletions

View File

@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(node --check:*)"
"Bash(node --check:*)",
"WebSearch"
],
"deny": [],
"ask": []

View File

@@ -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

View File

@@ -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.
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>
@@ -692,26 +902,34 @@
<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">
<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.
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.
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
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.
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>
@@ -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>

View File

@@ -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

View File

@@ -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');
@@ -265,8 +202,8 @@ document.addEventListener('DOMContentLoaded', function () {
// Edit dataset button
if (editDatasetBtn) {
editDatasetBtn.addEventListener('click', async function () {
if (window.currentDatasetId) {
await showEditDatasetForm(window.currentDatasetId);
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);
}
});
}
@@ -337,15 +274,15 @@ document.addEventListener('DOMContentLoaded', function () {
const previewTableBtn = document.getElementById('preview-table-btn');
if (previewRawBtn) {
previewRawBtn.addEventListener('click', function () {
if (window.currentDatasetData) {
showRawPreview(window.currentDatasetData);
if (Alpine.store('datasets').currentDatasetData) {
showRawPreview(Alpine.store('datasets').currentDatasetData);
}
});
}
if (previewTableBtn) {
previewTableBtn.addEventListener('click', function () {
if (window.currentDatasetData) {
showTablePreview(window.currentDatasetData);
if (Alpine.store('datasets').currentDatasetData) {
showTablePreview(Alpine.store('datasets').currentDatasetData);
}
});
}
@@ -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);
@@ -480,7 +396,7 @@ const KeyboardActions = {
},
publishDraft: function () {
if (currentViewMode === 'draft' && window.currentSnippetId) {
if (Alpine.store('snippets').viewMode === 'draft' && Alpine.store('snippets').currentSnippetId) {
publishDraft();
}
},
@@ -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');
}
}

View File

@@ -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();
}
}

View File

@@ -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
const Toast = {
// Auto-dismiss duration in milliseconds
// Alpine.js Store for toast notifications
document.addEventListener('alpine:init', () => {
Alpine.store('toasts', {
items: [],
counter: 0,
DURATION: 4000,
// Toast counter for unique IDs
toastCounter: 0,
add(message, type = 'info') {
const id = ++this.counter;
const toast = { id, message, type, visible: false };
this.items.push(toast);
// Show toast notification
show(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
// Trigger show animation on next tick
setTimeout(() => {
const found = this.items.find(t => t.id === id);
if (found) found.visible = true;
}, 10);
// Create toast element
const toast = document.createElement('div');
const toastId = `toast-${++this.toastCounter}`;
toast.id = toastId;
toast.className = `toast toast-${type}`;
// Auto-dismiss
setTimeout(() => this.remove(id), this.DURATION);
},
// Toast icon based on type
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;
}
});
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>
`;
// Preview panel fit mode store
Alpine.store('preview', {
fitMode: 'default' // 'default' | 'width' | 'full'
});
});
// Add to container
container.appendChild(toast);
// Toast Notification System (now backed by Alpine store)
const Toast = {
// Auto-dismiss duration in milliseconds
DURATION: 4000,
// Trigger animation
setTimeout(() => toast.classList.add('toast-show'), 10);
// Auto-dismiss
setTimeout(() => this.dismiss(toastId), this.DURATION);
// Show toast notification
show(message, type = 'info') {
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);
if (Alpine.store('toasts')) {
Alpine.store('toasts').remove(toastId);
}
}, 300);
},
// Convenience methods
@@ -323,4 +332,3 @@ const sampleSpec = {
"y": { "field": "value", "type": "quantitative" }
}
};

View File

@@ -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;
const listView = document.getElementById('dataset-list-view');
if (listView && listView.__x) {
const component = Alpine.$data(listView);
if (component && component.loadDatasets) {
await component.loadDatasets();
}
// 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)}`;
}
// 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();
}
}

View File

@@ -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);
}
// Re-render with new fit mode
renderVisualization();

View File

@@ -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;

View File

@@ -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 : '';
// Alpine.js handles rendering automatically via reactive bindings
}
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'
});
}
// 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);
}
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);
}

View File

@@ -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();
}
};
}

2
sw.js
View File

@@ -1,5 +1,5 @@
// IMPORTANT: Update this version with each release (must match APP_VERSION in config.js)
const CACHE_NAME = 'astrolabe-v0.3.0';
const CACHE_NAME = 'astrolabe-v0.4.0';
const URLS_TO_CACHE = [
'/',
'/index.html',