mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
chore: move website files to web/ folder
This commit is contained in:
715
web/index.html
Normal file
715
web/index.html
Normal file
@@ -0,0 +1,715 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Astrolabe - Vega-Lite Snippet Manager</title>
|
||||
<link rel="stylesheet" href="src/styles.css">
|
||||
<link rel="icon" type="image/svg+xml" href="src/favicon.svg" />
|
||||
|
||||
|
||||
<!-- Google Fonts - IBM Plex Mono -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Monaco Editor -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<span class="header-icon">🔭</span>
|
||||
<span class="header-title">Astrolabe</span>
|
||||
</div>
|
||||
<div class="header-links">
|
||||
<span class="header-link" id="import-link" title="Import snippets from JSON file">Import</span>
|
||||
<span class="header-link" id="export-link" title="Export all snippets to JSON file">Export</span>
|
||||
<span class="header-link" id="datasets-link" title="Open dataset manager (Cmd/Ctrl+K)">Datasets</span>
|
||||
<span class="header-link" id="settings-link" title="Open settings (Cmd/Ctrl+,)">Settings</span>
|
||||
<span class="header-link" id="help-link" title="View keyboard shortcuts and help">Help</span>
|
||||
<span class="header-link" id="donate-link" title="Support Astrolabe creators">Donate 🇺🇦</span>
|
||||
<input type="file" id="import-file-input" accept=".json" style="display: none;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
📄
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge active" id="toggle-editor-panel" title="Toggle Editor Panel">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge active" id="toggle-preview-panel" title="Toggle Preview Panel">
|
||||
👁️
|
||||
</button>
|
||||
<button class="btn btn-icon xlarge" id="toggle-datasets" title="Datasets">
|
||||
📁
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="main-panels">
|
||||
<!-- Snippet Library Panel -->
|
||||
<div class="panel snippet-panel" id="snippet-panel">
|
||||
<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">
|
||||
<span class="sort-text">Modified</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="created" title="Sort by creation date">
|
||||
<span class="sort-text">Created</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="name" title="Sort alphabetically by name">
|
||||
<span class="sort-text">Name</span>
|
||||
<span class="sort-arrow">⬇</span>
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="size" title="Sort by snippet size">
|
||||
<span class="sort-text">Size</span>
|
||||
<span class="sort-arrow">⬇</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>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<ul class="snippet-list">
|
||||
<!-- Dynamically populated by renderSnippetList() -->
|
||||
</ul>
|
||||
<div class="placeholder">
|
||||
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..." />
|
||||
|
||||
<div class="meta-header">Comment</div>
|
||||
<textarea id="snippet-comment" class="input textarea medium" placeholder="Add a comment..." rows="3"></textarea>
|
||||
|
||||
<div class="meta-info">
|
||||
<div class="meta-info-item">
|
||||
<span class="meta-info-label">Created:</span>
|
||||
<span id="snippet-created"></span>
|
||||
</div>
|
||||
<div class="meta-info-item">
|
||||
<span class="meta-info-label">Modified:</span>
|
||||
<span id="snippet-modified"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="snippet-datasets-section" style="display: none;">
|
||||
<div class="meta-header">Linked Datasets</div>
|
||||
<div class="meta-info" id="snippet-datasets">
|
||||
<!-- Dynamically populated by updateLinkedDatasets() -->
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-monitor" id="storage-monitor">
|
||||
<div class="storage-info">
|
||||
<span class="storage-label">Storage:</span>
|
||||
<span class="storage-text" id="storage-text">0 KB / 5 MB</span>
|
||||
</div>
|
||||
<div class="storage-bar">
|
||||
<div class="storage-fill" id="storage-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle 1 -->
|
||||
<div class="resize-handle" id="resize-handle-1"></div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div class="panel editor-panel" id="editor-panel">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="monaco-editor" style="height: 100%; width: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle 2 -->
|
||||
<div class="resize-handle" id="resize-handle-2"></div>
|
||||
|
||||
<!-- Preview Panel -->
|
||||
<div class="panel preview-panel" id="preview-panel">
|
||||
<div class="panel-header">
|
||||
Preview
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="vega-preview" style="height: 100%; width: 100%; overflow: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dataset Manager Modal -->
|
||||
<div id="dataset-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Dataset Manager</span>
|
||||
<button class="btn btn-icon" id="dataset-modal-close" title="Close dataset manager (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- List View (default) -->
|
||||
<div id="dataset-list-view" class="dataset-view">
|
||||
<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;" />
|
||||
</div>
|
||||
<div class="dataset-container">
|
||||
<div class="dataset-list" id="dataset-list">
|
||||
<!-- Dynamically populated by renderDatasetList() -->
|
||||
</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="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..." />
|
||||
|
||||
<div class="dataset-detail-header">Comment</div>
|
||||
<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">Statistics</span>
|
||||
<button class="btn btn-icon large" id="refresh-metadata-btn" style="display: none;" title="Refresh metadata from URL">🔄</button>
|
||||
</div>
|
||||
<div class="stats-box">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Rows:</span>
|
||||
<span id="dataset-detail-rows">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Columns:</span>
|
||||
<span id="dataset-detail-columns">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Size:</span>
|
||||
<span id="dataset-detail-size">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dataset-detail-header">Timestamps</div>
|
||||
<div class="stats-box">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Created:</span>
|
||||
<span id="dataset-detail-created">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Modified:</span>
|
||||
<span id="dataset-detail-modified">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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-snippets-section" style="display: none;">
|
||||
<div class="dataset-detail-header">Linked Snippets</div>
|
||||
<div class="stats-box" id="dataset-snippets">
|
||||
<!-- Dynamically populated by updateLinkedSnippets() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form View (for creating new datasets) -->
|
||||
<div id="dataset-form-view" class="dataset-view" style="display: none;">
|
||||
<div class="dataset-form">
|
||||
<div class="dataset-form-header">Create New Dataset</div>
|
||||
|
||||
<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..." />
|
||||
</div>
|
||||
|
||||
<div class="dataset-form-group">
|
||||
<label class="dataset-form-label">Data or URL *</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Detection Confirmation UI -->
|
||||
<div id="dataset-detection-confirm" class="dataset-detection-confirm" style="display: none;">
|
||||
<div class="detection-header">
|
||||
<span class="detection-title">Detected:</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detection-preview-label">Preview:</div>
|
||||
<pre id="detected-preview" class="preview-box medium"></pre>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extract to Dataset Modal -->
|
||||
<div id="extract-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 600px; height: auto; max-height: 80vh;">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Extract to Dataset</span>
|
||||
<button class="btn btn-icon" id="extract-modal-close" title="Close modal (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<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..." />
|
||||
</div>
|
||||
|
||||
<div class="dataset-form-group">
|
||||
<label class="dataset-form-label">Data Preview</label>
|
||||
<pre id="extract-data-preview" class="preview-box large" style="max-height: 250px;"></pre>
|
||||
</div>
|
||||
|
||||
<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" id="extract-cancel-btn" title="Cancel extraction">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<div id="help-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 700px; height: auto; max-height: 85vh;">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Help</span>
|
||||
<button class="btn btn-icon" id="help-modal-close" title="Close help (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="help-content">
|
||||
<!-- About -->
|
||||
<section class="help-section">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Key Features -->
|
||||
<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>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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Quick Start -->
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Getting Started</h3>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Keyboard Shortcuts</h3>
|
||||
<table class="help-shortcuts-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="shortcut-key">Cmd/Ctrl+Shift+N</td>
|
||||
<td class="shortcut-desc">Create new snippet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="shortcut-key">Cmd/Ctrl+K</td>
|
||||
<td class="shortcut-desc">Toggle dataset manager</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="shortcut-key">Cmd/Ctrl+,</td>
|
||||
<td class="shortcut-desc">Open settings</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="shortcut-key">Cmd/Ctrl+S</td>
|
||||
<td class="shortcut-desc">Publish draft (save)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="shortcut-key">Escape</td>
|
||||
<td class="shortcut-desc">Close any open modal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Storage & Limits -->
|
||||
<section class="help-section">
|
||||
<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.
|
||||
</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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Tips & Tricks -->
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Privacy Policy -->
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Privacy & Data</h3>
|
||||
<p class="help-text">
|
||||
<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>
|
||||
</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.
|
||||
</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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Donate Modal -->
|
||||
<div id="donate-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="height: auto;">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title"></span>
|
||||
<button class="btn btn-icon" id="donate-modal-close" title="Close (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="help-content">
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Why Donate?</h3>
|
||||
<p class="help-text">
|
||||
Astrolabe is a free open-source project built by a Ukrainian in Kyiv, Ukraine.
|
||||
If you're thinking of donating, I should be transparent about where that money actually goes - it doesn't go to me or the project.
|
||||
It goes to something I care about much more. Right now, while I've been building this passion project, there are people I know - my relatives, friends, colleagues - who took arms and joined Ukraine's Armed Forces. They're defending their country against Russian invasion.
|
||||
Most of them were civilians before this. They're still civilians, really, just doing something they had to do.
|
||||
I feel deep gratitude to them. So my goal is to support these brave people however I can.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
You might wonder if the military really needs donations.
|
||||
The honest answer: yes. The government and Ministry of Defense cover basics, but when you're on the frontlines, every small thing matters.
|
||||
Better equipment, better protection, better conditions - these things can make a difference between life and death.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
If you have concerns about donating to the military: I get it. Not everyone is comfortable with that for various reasons, and I respect that.
|
||||
The good news is that many of the organizations below also run humanitarian projects for civilians affected by the war. So you have options.
|
||||
</p>
|
||||
</section>
|
||||
<div class="donate-two-column">
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Where to Donate</h3>
|
||||
<p class="help-text">
|
||||
<ul class="help-list">
|
||||
<li>
|
||||
|
||||
<strong><a href="https://bank.gov.ua/en/news/all/natsionalniy-bank-vidkriv-spetsrahunok-dlya-zboru-koshtiv-na-potrebi-armiyi">
|
||||
National Bank of Ukraine's special account for Ukraine's Armed Forces
|
||||
</a> - Direct government channel</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://savelife.in.ua/en/donate-en/#donate-army-card-once">
|
||||
Come Back Alive Foundation</a></strong> - One of the biggest and most trusted. They've been around since 2014 and share <a href="https://savelife.in.ua/en/reporting-en/">detailed reports</a> of where money goes.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://macpaw.foundation/">
|
||||
MacPaw Foundation</a></strong> - Founded by MacPaw (where I work). Started in 2016, shifted focus in 2022 to support the Defence Forces.
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://foundation.kse.ua/en/humanitarian-projects/">
|
||||
KSE Foundation</a></strong>. Kyiv School of Economics (where I teach). Focuses on both education and humanitarian support for people and defenders
|
||||
</li>
|
||||
<li>
|
||||
<strong><a href="https://standforukraine.com/">
|
||||
Stand for Ukraine</a></strong> - Not a foundation, but an aggregator of reliable organizations.
|
||||
The list of fundraisers goes beyond military and covers recovery of veterans & victims of war, shelter for the refugees and many more.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h3 class="help-heading">Other Ways to Support</h3>
|
||||
<p class="help-text">
|
||||
Not able to donate? You can still help by:
|
||||
</p>
|
||||
<ul class="help-list">
|
||||
<li>Sharing Astrolabe with colleagues and friends</li>
|
||||
<li>Reporting bugs and suggesting improvements</li>
|
||||
<li>Contributing code or documentation</li>
|
||||
<li>Writing about your experience using Astrolabe</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="help-section">
|
||||
<p class="help-text" style="text-align: center; font-style: italic;">
|
||||
Thank you for considering this.
|
||||
</p>
|
||||
<p class="help-text" style="text-align: center; font-style: italic;">
|
||||
Oleh Omelchenko
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 600px; height: auto; max-height: 85vh;">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Settings</span>
|
||||
<button class="btn btn-icon" id="settings-modal-close" title="Close settings (Escape)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="settings-content">
|
||||
<!-- Appearance Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-heading">Appearance</h3>
|
||||
|
||||
<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">
|
||||
<option value="light">Light</option>
|
||||
<option value="experimental">Dark (Experimental)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Editor Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-heading">Editor</h3>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
<option value="vs-light">Light</option>
|
||||
<option value="vs-dark">Dark</option>
|
||||
<option value="hc-black">High Contrast</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<option value="2">2 spaces</option>
|
||||
<option value="4">4 spaces</option>
|
||||
<option value="8">8 spaces</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-minimap" class="settings-checkbox" />
|
||||
Show minimap
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-word-wrap" class="settings-checkbox" checked />
|
||||
Enable word wrap
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item">
|
||||
<label class="settings-label">
|
||||
<input type="checkbox" id="setting-line-numbers" class="settings-checkbox" checked />
|
||||
Show line numbers
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Performance Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-heading">Performance</h3>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="settings-hint">Delay before visualization updates while typing</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Formatting Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-heading">Formatting</h3>
|
||||
|
||||
<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">
|
||||
<option value="smart">Smart (relative times)</option>
|
||||
<option value="locale">Locale (browser default)</option>
|
||||
<option value="iso">ISO 8601</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-item" id="custom-date-format-item" style="display: none;">
|
||||
<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" />
|
||||
</div>
|
||||
<div class="settings-hint">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="src/js/user-settings.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/panel-manager.js"></script>
|
||||
<script src="src/js/editor.js"></script>
|
||||
<script src="src/js/app.js"></script>
|
||||
|
||||
<!-- GoatCounter Analytics -->
|
||||
<script data-goatcounter="https://astrolabe.goatcounter.com/count"
|
||||
data-goatcounter-settings='{"allow_local": true}'
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
42
web/src/favicon.svg
Normal file
42
web/src/favicon.svg
Normal file
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<title>Astrolabe Icon (Centered & Shifted Up)</title>
|
||||
<g transform="translate(0,-28)">
|
||||
<circle cx="256" cy="284" r="256" fill="#ffffff"/>
|
||||
<g stroke="#000000" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<circle cx="256" cy="64" r="24"/>
|
||||
<path d="M236 96 L276 96 L276 120 L236 120 Z"/>
|
||||
<circle cx="256" cy="284" r="220"/>
|
||||
<circle cx="256" cy="284" r="180"/>
|
||||
<line x1="256" y1="104" x2="256" y2="464"/>
|
||||
<line x1="76" y1="284" x2="436" y2="284"/>
|
||||
<line x1="128" y1="156" x2="384" y2="412"/>
|
||||
<line x1="384" y1="156" x2="128" y2="412"/>
|
||||
<g transform="translate(256,284) rotate(-23)">
|
||||
<ellipse cx="0" cy="0" rx="150" ry="110"/>
|
||||
</g>
|
||||
<g transform="translate(256,284) rotate(-12)">
|
||||
<line x1="-150" y1="0" x2="150" y2="0"/>
|
||||
<circle cx="0" cy="0" r="18" fill="#ffffff" stroke="#000000" stroke-width="6"/>
|
||||
<path d="M150 0 L120 -12 L120 12 Z" fill="#000000" stroke="none"/>
|
||||
<circle cx="-110" cy="0" r="6" stroke-width="6"/>
|
||||
<circle cx="110" cy="0" r="6" stroke-width="6"/>
|
||||
</g>
|
||||
<g transform="translate(256,284)">
|
||||
<g>
|
||||
<line x1="0" y1="-200" x2="0" y2="-186"/>
|
||||
<line x1="100" y1="-173.2" x2="94" y2="-160"/>
|
||||
<line x1="173.2" y1="-100" x2="160" y2="-94"/>
|
||||
<line x1="200" y1="0" x2="186" y2="0"/>
|
||||
<line x1="173.2" y1="100" x2="160" y2="94"/>
|
||||
<line x1="100" y1="173.2" x2="94" y2="160"/>
|
||||
<line x1="0" y1="200" x2="0" y2="186"/>
|
||||
<line x1="-100" y1="173.2" x2="-94" y2="160"/>
|
||||
<line x1="-173.2" y1="100" x2="-160" y2="94"/>
|
||||
<line x1="-200" y1="0" x2="-186" y2="0"/>
|
||||
<line x1="-173.2" y1="-100" x2="-160" y2="-94"/>
|
||||
<line x1="-100" y1="-173.2" x2="-94" y2="-160"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
778
web/src/js/app.js
Normal file
778
web/src/js/app.js
Normal file
@@ -0,0 +1,778 @@
|
||||
// Application initialization and event handlers
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize user settings
|
||||
initSettings();
|
||||
|
||||
// Apply saved theme immediately on page load
|
||||
const theme = getSetting('ui.theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Initialize snippet storage and render list
|
||||
initializeSnippetsStorage();
|
||||
|
||||
// Initialize sort controls
|
||||
initializeSortControls();
|
||||
|
||||
// Initialize search controls
|
||||
initializeSearchControls();
|
||||
|
||||
renderSnippetList();
|
||||
|
||||
// Update storage monitor
|
||||
updateStorageMonitor();
|
||||
|
||||
// Auto-select first snippet on page load (only if no hash in URL)
|
||||
if (!window.location.hash) {
|
||||
const firstSnippet = SnippetStorage.listSnippets()[0];
|
||||
if (firstSnippet) {
|
||||
selectSnippet(firstSnippet.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved layout
|
||||
loadLayoutFromStorage();
|
||||
|
||||
// Initialize resize functionality
|
||||
initializeResize();
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
initializeKeyboardShortcuts();
|
||||
|
||||
// Initialize Monaco Editor
|
||||
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs' } });
|
||||
require(['vs/editor/editor.main'], async function () {
|
||||
// Fetch Vega-Lite schema for validation
|
||||
let vegaLiteSchema;
|
||||
try {
|
||||
const response = await fetch('https://vega.github.io/schema/vega-lite/v5.json');
|
||||
vegaLiteSchema = await response.json();
|
||||
} catch (error) {
|
||||
vegaLiteSchema = null;
|
||||
}
|
||||
|
||||
// Configure JSON language with schema
|
||||
if (vegaLiteSchema) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
schemas: [{
|
||||
uri: "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
fileMatch: ["*"],
|
||||
schema: vegaLiteSchema
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Load Vega libraries before creating editor
|
||||
await loadVegaLibraries();
|
||||
|
||||
// Get user settings for editor configuration
|
||||
const editorSettings = getSetting('editor') || {
|
||||
fontSize: 12,
|
||||
theme: 'vs-light',
|
||||
minimap: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
tabSize: 2
|
||||
};
|
||||
|
||||
// Create the editor
|
||||
editor = monaco.editor.create(document.getElementById('monaco-editor'), {
|
||||
value: JSON.stringify(sampleSpec, null, 2),
|
||||
language: 'json',
|
||||
theme: editorSettings.theme,
|
||||
fontSize: editorSettings.fontSize,
|
||||
minimap: { enabled: editorSettings.minimap },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
wordWrap: editorSettings.wordWrap,
|
||||
lineNumbers: editorSettings.lineNumbers,
|
||||
tabSize: editorSettings.tabSize,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true
|
||||
});
|
||||
|
||||
// Register custom keyboard shortcuts in Monaco
|
||||
registerMonacoKeyboardShortcuts();
|
||||
|
||||
// Add debounced auto-render on editor change
|
||||
editor.onDidChangeModelContent(() => {
|
||||
debouncedRender();
|
||||
debouncedAutoSave();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderVisualization();
|
||||
|
||||
// Initialize auto-save functionality
|
||||
initializeAutoSave();
|
||||
|
||||
// Initialize URL state management AFTER editor is ready
|
||||
initializeURLStateManagement();
|
||||
});
|
||||
|
||||
// Toggle panel buttons
|
||||
document.querySelectorAll('.toggle-btn').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const panelId = this.id.replace('toggle-', '');
|
||||
togglePanel(panelId);
|
||||
});
|
||||
});
|
||||
|
||||
// Header links
|
||||
const importLink = document.getElementById('import-link');
|
||||
const exportLink = document.getElementById('export-link');
|
||||
const helpLink = document.getElementById('help-link');
|
||||
const importFileInput = document.getElementById('import-file-input');
|
||||
|
||||
if (importLink && importFileInput) {
|
||||
importLink.addEventListener('click', function () {
|
||||
importFileInput.click();
|
||||
});
|
||||
|
||||
importFileInput.addEventListener('change', function () {
|
||||
importSnippets(this);
|
||||
});
|
||||
}
|
||||
|
||||
if (exportLink) {
|
||||
exportLink.addEventListener('click', function () {
|
||||
exportSnippets();
|
||||
});
|
||||
}
|
||||
|
||||
if (helpLink) {
|
||||
helpLink.addEventListener('click', function () {
|
||||
openHelpModal();
|
||||
});
|
||||
}
|
||||
|
||||
const donateLink = document.getElementById('donate-link');
|
||||
if (donateLink) {
|
||||
donateLink.addEventListener('click', function () {
|
||||
openDonateModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings Modal
|
||||
const settingsLink = document.getElementById('settings-link');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsModalClose = document.getElementById('settings-modal-close');
|
||||
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', function () {
|
||||
openSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsModalClose) {
|
||||
settingsModalClose.addEventListener('click', closeSettingsModal);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', function (e) {
|
||||
if (e.target === settingsModal) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dataset Manager
|
||||
const datasetsLink = document.getElementById('datasets-link');
|
||||
const toggleDatasetsBtn = document.getElementById('toggle-datasets');
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
const datasetModalClose = document.getElementById('dataset-modal-close');
|
||||
const newDatasetBtn = document.getElementById('new-dataset-btn');
|
||||
const cancelDatasetBtn = document.getElementById('cancel-dataset-btn');
|
||||
const saveDatasetBtn = document.getElementById('save-dataset-btn');
|
||||
const deleteDatasetBtn = document.getElementById('delete-dataset-btn');
|
||||
const copyReferenceBtn = document.getElementById('copy-reference-btn');
|
||||
|
||||
// Open dataset manager
|
||||
if (datasetsLink) {
|
||||
datasetsLink.addEventListener('click', openDatasetManager);
|
||||
}
|
||||
if (toggleDatasetsBtn) {
|
||||
toggleDatasetsBtn.addEventListener('click', openDatasetManager);
|
||||
}
|
||||
|
||||
// Close dataset manager
|
||||
if (datasetModalClose) {
|
||||
datasetModalClose.addEventListener('click', closeDatasetManager);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (datasetModal) {
|
||||
datasetModal.addEventListener('click', function (e) {
|
||||
if (e.target === datasetModal) {
|
||||
closeDatasetManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New dataset button
|
||||
if (newDatasetBtn) {
|
||||
newDatasetBtn.addEventListener('click', showNewDatasetForm);
|
||||
}
|
||||
|
||||
// Import dataset button and file input
|
||||
const importDatasetBtn = document.getElementById('import-dataset-btn');
|
||||
const importDatasetFile = document.getElementById('import-dataset-file');
|
||||
if (importDatasetBtn && importDatasetFile) {
|
||||
importDatasetBtn.addEventListener('click', function () {
|
||||
importDatasetFile.click();
|
||||
});
|
||||
|
||||
importDatasetFile.addEventListener('change', function () {
|
||||
importDatasetFromFile(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel dataset button
|
||||
if (cancelDatasetBtn) {
|
||||
cancelDatasetBtn.addEventListener('click', hideNewDatasetForm);
|
||||
}
|
||||
|
||||
// Save dataset button
|
||||
if (saveDatasetBtn) {
|
||||
saveDatasetBtn.addEventListener('click', saveNewDataset);
|
||||
}
|
||||
|
||||
// Delete dataset button
|
||||
if (deleteDatasetBtn) {
|
||||
deleteDatasetBtn.addEventListener('click', deleteCurrentDataset);
|
||||
}
|
||||
|
||||
// Copy reference button
|
||||
if (copyReferenceBtn) {
|
||||
copyReferenceBtn.addEventListener('click', copyDatasetReference);
|
||||
}
|
||||
|
||||
// Refresh metadata button
|
||||
const refreshMetadataBtn = document.getElementById('refresh-metadata-btn');
|
||||
if (refreshMetadataBtn) {
|
||||
refreshMetadataBtn.addEventListener('click', refreshDatasetMetadata);
|
||||
}
|
||||
|
||||
// New snippet from dataset button
|
||||
const newSnippetBtn = document.getElementById('new-snippet-btn');
|
||||
if (newSnippetBtn) {
|
||||
newSnippetBtn.addEventListener('click', createNewSnippetFromDataset);
|
||||
}
|
||||
|
||||
// Export dataset button
|
||||
const exportDatasetBtn = document.getElementById('export-dataset-btn');
|
||||
if (exportDatasetBtn) {
|
||||
exportDatasetBtn.addEventListener('click', exportCurrentDataset);
|
||||
}
|
||||
|
||||
// Preview toggle buttons
|
||||
const previewRawBtn = document.getElementById('preview-raw-btn');
|
||||
const previewTableBtn = document.getElementById('preview-table-btn');
|
||||
if (previewRawBtn) {
|
||||
previewRawBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showRawPreview(window.currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (previewTableBtn) {
|
||||
previewTableBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showTablePreview(window.currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Help Modal
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const helpModalClose = document.getElementById('help-modal-close');
|
||||
|
||||
if (helpModalClose) {
|
||||
helpModalClose.addEventListener('click', closeHelpModal);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (helpModal) {
|
||||
helpModal.addEventListener('click', function (e) {
|
||||
if (e.target === helpModal) {
|
||||
closeHelpModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Donate Modal
|
||||
const donateModal = document.getElementById('donate-modal');
|
||||
const donateModalClose = document.getElementById('donate-modal-close');
|
||||
|
||||
if (donateModalClose) {
|
||||
donateModalClose.addEventListener('click', closeDonateModal);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (donateModal) {
|
||||
donateModal.addEventListener('click', function (e) {
|
||||
if (e.target === donateModal) {
|
||||
closeDonateModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// View mode toggle buttons
|
||||
document.getElementById('view-draft').addEventListener('click', () => {
|
||||
switchViewMode('draft');
|
||||
});
|
||||
|
||||
document.getElementById('view-published').addEventListener('click', () => {
|
||||
switchViewMode('published');
|
||||
});
|
||||
|
||||
// Publish and Revert buttons
|
||||
document.getElementById('publish-btn').addEventListener('click', publishDraft);
|
||||
document.getElementById('revert-btn').addEventListener('click', revertDraft);
|
||||
|
||||
// Extract to Dataset button
|
||||
const extractBtn = document.getElementById('extract-btn');
|
||||
if (extractBtn) {
|
||||
extractBtn.addEventListener('click', showExtractModal);
|
||||
}
|
||||
|
||||
// Extract modal buttons
|
||||
const extractModalClose = document.getElementById('extract-modal-close');
|
||||
const extractCancelBtn = document.getElementById('extract-cancel-btn');
|
||||
const extractCreateBtn = document.getElementById('extract-create-btn');
|
||||
const extractModal = document.getElementById('extract-modal');
|
||||
|
||||
if (extractModalClose) {
|
||||
extractModalClose.addEventListener('click', hideExtractModal);
|
||||
}
|
||||
|
||||
if (extractCancelBtn) {
|
||||
extractCancelBtn.addEventListener('click', hideExtractModal);
|
||||
}
|
||||
|
||||
if (extractCreateBtn) {
|
||||
extractCreateBtn.addEventListener('click', extractToDataset);
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
if (extractModal) {
|
||||
extractModal.addEventListener('click', function (e) {
|
||||
if (e.target === extractModal) {
|
||||
hideExtractModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle URL hash changes (browser back/forward)
|
||||
function handleURLStateChange() {
|
||||
const state = URLState.parse();
|
||||
|
||||
if (state.view === 'datasets') {
|
||||
// Open dataset modal
|
||||
openDatasetManager(false); // Don't update URL
|
||||
|
||||
if (state.datasetId === 'new') {
|
||||
// Show new dataset form
|
||||
showNewDatasetForm(false);
|
||||
} else if (state.datasetId) {
|
||||
// Extract numeric ID from "dataset-123456"
|
||||
const numericId = parseFloat(state.datasetId.replace('dataset-', ''));
|
||||
selectDataset(numericId, false);
|
||||
}
|
||||
} else if (state.snippetId) {
|
||||
// Close dataset modal if open
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeDatasetManager(false);
|
||||
}
|
||||
|
||||
// Select snippet
|
||||
const numericId = parseFloat(state.snippetId.replace('snippet-', ''));
|
||||
selectSnippet(numericId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize URL state management
|
||||
function initializeURLStateManagement() {
|
||||
// Handle hashchange event for back/forward navigation
|
||||
window.addEventListener('hashchange', handleURLStateChange);
|
||||
|
||||
// Check if there's a hash in the URL on page load
|
||||
if (window.location.hash) {
|
||||
handleURLStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut action handlers (shared between Monaco and document)
|
||||
const KeyboardActions = {
|
||||
createNewSnippet: function() {
|
||||
createNewSnippet();
|
||||
},
|
||||
|
||||
toggleDatasetManager: function() {
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeDatasetManager();
|
||||
} else {
|
||||
openDatasetManager();
|
||||
}
|
||||
},
|
||||
|
||||
publishDraft: function() {
|
||||
if (currentViewMode === 'draft' && window.currentSnippetId) {
|
||||
publishDraft();
|
||||
}
|
||||
},
|
||||
|
||||
toggleSettings: function() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeSettingsModal();
|
||||
} else {
|
||||
openSettingsModal();
|
||||
}
|
||||
},
|
||||
|
||||
closeAnyModal: function() {
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
const extractModal = document.getElementById('extract-modal');
|
||||
const donateModal = document.getElementById('donate-modal');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
|
||||
if (helpModal && helpModal.style.display === 'flex') {
|
||||
closeHelpModal();
|
||||
return true;
|
||||
}
|
||||
if (datasetModal && datasetModal.style.display === 'flex') {
|
||||
closeDatasetManager();
|
||||
return true;
|
||||
}
|
||||
if (extractModal && extractModal.style.display === 'flex') {
|
||||
hideExtractModal();
|
||||
return true;
|
||||
}
|
||||
if (donateModal && donateModal.style.display === 'flex') {
|
||||
closeDonateModal();
|
||||
return true;
|
||||
}
|
||||
if (settingsModal && settingsModal.style.display === 'flex') {
|
||||
closeSettingsModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler (document-level)
|
||||
function initializeKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Escape: Close any open modal
|
||||
if (e.key === 'Escape') {
|
||||
if (KeyboardActions.closeAnyModal()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect modifier key: Cmd on Mac, Ctrl on Windows/Linux
|
||||
const modifierKey = e.metaKey || e.ctrlKey;
|
||||
|
||||
// Cmd/Ctrl+Shift+N: Create new snippet
|
||||
if (modifierKey && e.shiftKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.createNewSnippet();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+K: Toggle dataset manager
|
||||
if (modifierKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.toggleDatasetManager();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+,: Toggle settings
|
||||
if (modifierKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.toggleSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+S: Publish draft
|
||||
if (modifierKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.publishDraft();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register keyboard shortcuts in Monaco Editor
|
||||
function registerMonacoKeyboardShortcuts() {
|
||||
if (!editor) return;
|
||||
|
||||
// Cmd/Ctrl+Shift+N: Create new snippet
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyN,
|
||||
KeyboardActions.createNewSnippet);
|
||||
|
||||
// Cmd/Ctrl+K: Toggle dataset manager
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK,
|
||||
KeyboardActions.toggleDatasetManager);
|
||||
|
||||
// Cmd/Ctrl+S: Publish draft
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
||||
KeyboardActions.publishDraft);
|
||||
}
|
||||
|
||||
// Help modal functions
|
||||
function openHelpModal() {
|
||||
const modal = document.getElementById('help-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-help', 'Open Help modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeHelpModal() {
|
||||
const modal = document.getElementById('help-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Donate modal functions
|
||||
function openDonateModal() {
|
||||
const modal = document.getElementById('donate-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-donate', 'Open Donate modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDonateModal() {
|
||||
const modal = document.getElementById('donate-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Settings modal functions
|
||||
function openSettingsModal() {
|
||||
loadSettingsIntoUI();
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-settings', 'Open Settings modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
257
web/src/js/config.js
Normal file
257
web/src/js/config.js
Normal file
@@ -0,0 +1,257 @@
|
||||
// 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;
|
||||
let currentHandle = null;
|
||||
let startX = 0;
|
||||
let startWidths = [];
|
||||
|
||||
// Panel memory for toggle functionality
|
||||
let panelMemory = {
|
||||
snippetWidth: '25%',
|
||||
editorWidth: '50%',
|
||||
previewWidth: '25%'
|
||||
};
|
||||
|
||||
// URL State Management
|
||||
const URLState = {
|
||||
// Parse current hash into state object
|
||||
parse() {
|
||||
const hash = window.location.hash.slice(1); // Remove '#'
|
||||
if (!hash) return { view: 'snippets', snippetId: null, datasetId: null };
|
||||
|
||||
const parts = hash.split('/');
|
||||
|
||||
// #snippet-123456
|
||||
if (hash.startsWith('snippet-')) {
|
||||
return { view: 'snippets', snippetId: hash, datasetId: null };
|
||||
}
|
||||
|
||||
// #datasets
|
||||
if (parts[0] === 'datasets') {
|
||||
if (parts.length === 1) {
|
||||
return { view: 'datasets', snippetId: null, datasetId: null };
|
||||
}
|
||||
// #datasets/new
|
||||
if (parts[1] === 'new') {
|
||||
return { view: 'datasets', snippetId: null, datasetId: 'new' };
|
||||
}
|
||||
// #datasets/dataset-123456
|
||||
if (parts[1].startsWith('dataset-')) {
|
||||
return { view: 'datasets', snippetId: null, datasetId: parts[1] };
|
||||
}
|
||||
}
|
||||
|
||||
return { view: 'snippets', snippetId: null, datasetId: null };
|
||||
},
|
||||
|
||||
// Update URL hash without triggering hashchange
|
||||
update(state, replaceState = false) {
|
||||
let hash = '';
|
||||
|
||||
if (state.view === 'datasets') {
|
||||
if (state.datasetId === 'new') {
|
||||
hash = '#datasets/new';
|
||||
} else if (state.datasetId) {
|
||||
// Add 'dataset-' prefix if not already present
|
||||
const datasetId = typeof state.datasetId === 'string' && state.datasetId.startsWith('dataset-')
|
||||
? state.datasetId
|
||||
: `dataset-${state.datasetId}`;
|
||||
hash = `#datasets/${datasetId}`;
|
||||
} else {
|
||||
hash = '#datasets';
|
||||
}
|
||||
} else if (state.snippetId) {
|
||||
// Add 'snippet-' prefix if not already present
|
||||
const snippetId = typeof state.snippetId === 'string' && state.snippetId.startsWith('snippet-')
|
||||
? state.snippetId
|
||||
: `snippet-${state.snippetId}`;
|
||||
hash = `#${snippetId}`;
|
||||
}
|
||||
|
||||
if (replaceState) {
|
||||
window.history.replaceState(null, '', hash || '#');
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear hash
|
||||
clear() {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
// Settings storage
|
||||
const AppSettings = {
|
||||
STORAGE_KEY: 'astrolabe-settings',
|
||||
|
||||
// Default settings
|
||||
defaults: {
|
||||
sortBy: 'modified',
|
||||
sortOrder: 'desc'
|
||||
},
|
||||
|
||||
// Load settings from localStorage
|
||||
load() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? { ...this.defaults, ...JSON.parse(stored) } : this.defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return this.defaults;
|
||||
}
|
||||
},
|
||||
|
||||
// Save settings to localStorage
|
||||
save(settings) {
|
||||
try {
|
||||
const currentSettings = this.load();
|
||||
const updatedSettings = { ...currentSettings, ...settings };
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updatedSettings));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Get specific setting
|
||||
get(key) {
|
||||
const settings = this.load();
|
||||
return settings[key];
|
||||
},
|
||||
|
||||
// Set specific setting
|
||||
set(key, value) {
|
||||
const update = {};
|
||||
update[key] = value;
|
||||
return this.save(update);
|
||||
}
|
||||
};
|
||||
|
||||
// Toast Notification System
|
||||
const Toast = {
|
||||
// Auto-dismiss duration in milliseconds
|
||||
DURATION: 4000,
|
||||
|
||||
// Toast counter for unique IDs
|
||||
toastCounter: 0,
|
||||
|
||||
// Show toast notification
|
||||
show(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
const toastId = `toast-${++this.toastCounter}`;
|
||||
toast.id = toastId;
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
// Toast icon based on type
|
||||
const icons = {
|
||||
error: '❌',
|
||||
success: '✓',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${icons[type] || icons.info}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-close" onclick="Toast.dismiss('${toastId}')">×</button>
|
||||
`;
|
||||
|
||||
// Add to container
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('toast-show'), 10);
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => this.dismiss(toastId), this.DURATION);
|
||||
},
|
||||
|
||||
// Dismiss specific toast
|
||||
dismiss(toastId) {
|
||||
const toast = document.getElementById(toastId);
|
||||
if (!toast) return;
|
||||
|
||||
toast.classList.remove('toast-show');
|
||||
toast.classList.add('toast-hide');
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
error(message) {
|
||||
this.show(message, 'error');
|
||||
},
|
||||
|
||||
success(message) {
|
||||
this.show(message, 'success');
|
||||
},
|
||||
|
||||
warning(message) {
|
||||
this.show(message, 'warning');
|
||||
},
|
||||
|
||||
info(message) {
|
||||
this.show(message, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Analytics utility: Track events with GoatCounter
|
||||
const Analytics = {
|
||||
track(eventName, title) {
|
||||
// Only track if GoatCounter is loaded
|
||||
if (window.goatcounter && window.goatcounter.count) {
|
||||
window.goatcounter.count({
|
||||
path: eventName,
|
||||
title: title || eventName,
|
||||
event: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Shared utility: Format bytes for display
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
// Sample Vega-Lite specification
|
||||
const sampleSpec = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"description": "A simple bar chart with embedded data.",
|
||||
"data": {
|
||||
"values": [
|
||||
{ "category": "A", "value": 28 },
|
||||
{ "category": "B", "value": 55 },
|
||||
{ "category": "C", "value": 43 },
|
||||
{ "category": "D", "value": 91 },
|
||||
{ "category": "E", "value": 81 },
|
||||
{ "category": "F", "value": 53 },
|
||||
{ "category": "G", "value": 19 },
|
||||
{ "category": "H", "value": 87 }
|
||||
]
|
||||
},
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": { "field": "category", "type": "nominal", "axis": { "labelAngle": 0 } },
|
||||
"y": { "field": "value", "type": "quantitative" }
|
||||
}
|
||||
};
|
||||
|
||||
1418
web/src/js/dataset-manager.js
Normal file
1418
web/src/js/dataset-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
156
web/src/js/editor.js
Normal file
156
web/src/js/editor.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Resolve dataset references in a spec
|
||||
async function resolveDatasetReferences(spec) {
|
||||
// If spec has data.name, look it up
|
||||
if (spec.data && spec.data.name && typeof spec.data.name === 'string') {
|
||||
const datasetName = spec.data.name;
|
||||
const dataset = await DatasetStorage.getDatasetByName(datasetName);
|
||||
|
||||
if (dataset) {
|
||||
// Replace data reference with actual data in the format Vega-Lite expects
|
||||
if (dataset.source === 'url') {
|
||||
// For URL sources, pass the URL and format
|
||||
spec.data = {
|
||||
url: dataset.data,
|
||||
format: { type: dataset.format }
|
||||
};
|
||||
} else {
|
||||
// For inline sources
|
||||
if (dataset.format === 'json') {
|
||||
spec.data = { values: dataset.data };
|
||||
} else if (dataset.format === 'csv') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'csv' }
|
||||
};
|
||||
} else if (dataset.format === 'tsv') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'tsv' }
|
||||
};
|
||||
} else if (dataset.format === 'topojson') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'topojson' }
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Dataset "${datasetName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in layers (for layered specs)
|
||||
if (spec.layer && Array.isArray(spec.layer)) {
|
||||
for (let layer of spec.layer) {
|
||||
await resolveDatasetReferences(layer);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in concat/hconcat/vconcat
|
||||
if (spec.concat && Array.isArray(spec.concat)) {
|
||||
for (let view of spec.concat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
if (spec.hconcat && Array.isArray(spec.hconcat)) {
|
||||
for (let view of spec.hconcat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
if (spec.vconcat && Array.isArray(spec.vconcat)) {
|
||||
for (let view of spec.vconcat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in facet
|
||||
if (spec.spec) {
|
||||
await resolveDatasetReferences(spec.spec);
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Render function that takes spec from editor
|
||||
async function renderVisualization() {
|
||||
const previewContainer = document.getElementById('vega-preview');
|
||||
|
||||
try {
|
||||
// Get current content from editor
|
||||
const specText = editor.getValue();
|
||||
let spec = JSON.parse(specText);
|
||||
|
||||
// Resolve dataset references
|
||||
spec = await resolveDatasetReferences(spec);
|
||||
|
||||
// Render with Vega-Embed (use global variable)
|
||||
await window.vegaEmbed('#vega-preview', spec, {
|
||||
actions: false, // Hide action menu for cleaner look
|
||||
renderer: 'svg' // Use SVG for better quality
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Handle rendering errors gracefully
|
||||
previewContainer.innerHTML = `
|
||||
<div style="padding: 20px; color: #d32f2f; font-size: 12px; font-family: monospace;">
|
||||
<strong>Rendering Error:</strong><br>
|
||||
${error.message}
|
||||
<br><br>
|
||||
<em>Check your JSON syntax and Vega-Lite specification.</em>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced render function
|
||||
function debouncedRender() {
|
||||
// Don't debounce if we're programmatically updating - render immediately
|
||||
if (window.isUpdatingEditor) {
|
||||
renderVisualization();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(renderTimeout);
|
||||
const debounceTime = getSetting('performance.renderDebounce') || 1500;
|
||||
renderTimeout = setTimeout(renderVisualization, debounceTime);
|
||||
}
|
||||
|
||||
// Update render debounce setting (called when settings are changed)
|
||||
function updateRenderDebounce(newDebounce) {
|
||||
// The next render will automatically use the new debounce time
|
||||
// No need to do anything special here
|
||||
}
|
||||
|
||||
// Load Vega libraries dynamically with UMD builds
|
||||
function loadVegaLibraries() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Temporarily disable AMD define to avoid conflicts
|
||||
const originalDefine = window.define;
|
||||
window.define = undefined;
|
||||
|
||||
// Load Vega
|
||||
const vegaScript = document.createElement('script');
|
||||
vegaScript.src = 'https://unpkg.com/vega@5/build/vega.min.js';
|
||||
vegaScript.onload = () => {
|
||||
// Load Vega-Lite
|
||||
const vegaLiteScript = document.createElement('script');
|
||||
vegaLiteScript.src = 'https://unpkg.com/vega-lite@5/build/vega-lite.min.js';
|
||||
vegaLiteScript.onload = () => {
|
||||
// Load Vega-Embed
|
||||
const vegaEmbedScript = document.createElement('script');
|
||||
vegaEmbedScript.src = 'https://unpkg.com/vega-embed@6/build/vega-embed.min.js';
|
||||
vegaEmbedScript.onload = () => {
|
||||
// Restore AMD define
|
||||
window.define = originalDefine;
|
||||
resolve();
|
||||
};
|
||||
vegaEmbedScript.onerror = reject;
|
||||
document.head.appendChild(vegaEmbedScript);
|
||||
};
|
||||
vegaLiteScript.onerror = reject;
|
||||
document.head.appendChild(vegaLiteScript);
|
||||
};
|
||||
vegaScript.onerror = reject;
|
||||
document.head.appendChild(vegaScript);
|
||||
});
|
||||
}
|
||||
199
web/src/js/panel-manager.js
Normal file
199
web/src/js/panel-manager.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// Panel toggle and expansion functions
|
||||
function updatePanelMemory() {
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
// Only update memory for visible panels
|
||||
if (snippetPanel.style.display !== 'none') {
|
||||
panelMemory.snippetWidth = snippetPanel.style.width || '25%';
|
||||
}
|
||||
if (editorPanel.style.display !== 'none') {
|
||||
panelMemory.editorWidth = editorPanel.style.width || '50%';
|
||||
}
|
||||
if (previewPanel.style.display !== 'none') {
|
||||
panelMemory.previewWidth = previewPanel.style.width || '25%';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
const button = document.getElementById(`toggle-${panelId}`);
|
||||
|
||||
if (!panel || !button) return;
|
||||
|
||||
if (panel.style.display === 'none') {
|
||||
// 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();
|
||||
}
|
||||
|
||||
saveLayoutToStorage();
|
||||
}
|
||||
|
||||
function redistributePanelWidths() {
|
||||
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
const panels = [
|
||||
{ element: snippetPanel, id: 'snippet', memoryKey: 'snippetWidth' },
|
||||
{ element: editorPanel, id: 'editor', memoryKey: 'editorWidth' },
|
||||
{ element: previewPanel, id: 'preview', memoryKey: 'previewWidth' }
|
||||
];
|
||||
|
||||
const visiblePanels = panels.filter(panel => panel.element.style.display !== 'none');
|
||||
|
||||
if (visiblePanels.length === 0) return;
|
||||
|
||||
// Get total desired width from memory
|
||||
let totalMemoryWidth = 0;
|
||||
visiblePanels.forEach(panel => {
|
||||
const width = parseFloat(panelMemory[panel.memoryKey]);
|
||||
totalMemoryWidth += width;
|
||||
});
|
||||
|
||||
// Redistribute proportionally to fill 100%
|
||||
visiblePanels.forEach(panel => {
|
||||
const memoryWidth = parseFloat(panelMemory[panel.memoryKey]);
|
||||
const newWidth = (memoryWidth / totalMemoryWidth) * 100;
|
||||
panel.element.style.width = `${newWidth}%`;
|
||||
});
|
||||
}
|
||||
|
||||
function saveLayoutToStorage() {
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
// DON'T update memory here - it's already updated during manual resize
|
||||
|
||||
const layout = {
|
||||
snippetWidth: snippetPanel.style.width || '25%',
|
||||
editorWidth: editorPanel.style.width || '50%',
|
||||
previewWidth: previewPanel.style.width || '25%',
|
||||
snippetVisible: snippetPanel.style.display !== 'none',
|
||||
editorVisible: editorPanel.style.display !== 'none',
|
||||
previewVisible: previewPanel.style.display !== 'none',
|
||||
memory: panelMemory
|
||||
};
|
||||
|
||||
localStorage.setItem('astrolabe-layout', JSON.stringify(layout));
|
||||
}
|
||||
|
||||
function loadLayoutFromStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem('astrolabe-layout');
|
||||
if (saved) {
|
||||
const layout = JSON.parse(saved);
|
||||
|
||||
// Restore memory if available
|
||||
if (layout.memory) {
|
||||
panelMemory = layout.memory;
|
||||
}
|
||||
|
||||
// Restore panel visibility
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
snippetPanel.style.display = layout.snippetVisible !== false ? 'flex' : 'none';
|
||||
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);
|
||||
|
||||
// Restore widths and redistribute
|
||||
snippetPanel.style.width = layout.snippetWidth;
|
||||
editorPanel.style.width = layout.editorWidth;
|
||||
previewPanel.style.width = layout.previewWidth;
|
||||
|
||||
redistributePanelWidths();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors, use default layout
|
||||
}
|
||||
}
|
||||
function initializeResize() {
|
||||
const handles = document.querySelectorAll('.resize-handle');
|
||||
const panels = [
|
||||
document.getElementById('snippet-panel'),
|
||||
document.getElementById('editor-panel'),
|
||||
document.getElementById('preview-panel')
|
||||
];
|
||||
|
||||
handles.forEach((handle, index) => {
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
isResizing = true;
|
||||
currentHandle = index;
|
||||
startX = e.clientX;
|
||||
startWidths = panels.map(panel => panel.getBoundingClientRect().width);
|
||||
|
||||
handle.classList.add('dragging');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const containerWidth = document.querySelector('.main-panels').getBoundingClientRect().width;
|
||||
|
||||
if (currentHandle === 0) {
|
||||
// Resizing between snippet and editor panels
|
||||
const minWidth = 200;
|
||||
const newSnippetWidth = Math.max(minWidth, startWidths[0] + deltaX);
|
||||
const newEditorWidth = Math.max(minWidth, startWidths[1] - deltaX);
|
||||
|
||||
if (newSnippetWidth >= minWidth && newEditorWidth >= minWidth) {
|
||||
panels[0].style.width = `${(newSnippetWidth / containerWidth) * 100}%`;
|
||||
panels[1].style.width = `${(newEditorWidth / containerWidth) * 100}%`;
|
||||
}
|
||||
} else if (currentHandle === 1) {
|
||||
// Resizing between editor and preview panels
|
||||
const minWidth = 200;
|
||||
const newEditorWidth = Math.max(minWidth, startWidths[1] + deltaX);
|
||||
const newPreviewWidth = Math.max(minWidth, startWidths[2] - deltaX);
|
||||
|
||||
if (newEditorWidth >= minWidth && newPreviewWidth >= minWidth) {
|
||||
panels[1].style.width = `${(newEditorWidth / containerWidth) * 100}%`;
|
||||
panels[2].style.width = `${(newPreviewWidth / containerWidth) * 100}%`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
currentHandle = null;
|
||||
|
||||
document.querySelectorAll('.resize-handle').forEach(h => h.classList.remove('dragging'));
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Update memory ONLY after manual resize
|
||||
updatePanelMemory();
|
||||
|
||||
saveLayoutToStorage();
|
||||
}
|
||||
});
|
||||
}
|
||||
1331
web/src/js/snippet-manager.js
Normal file
1331
web/src/js/snippet-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
299
web/src/js/user-settings.js
Normal file
299
web/src/js/user-settings.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// user-settings.js - User preferences and configuration management
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'astrolabe-user-settings';
|
||||
|
||||
// Default settings configuration
|
||||
const DEFAULT_SETTINGS = {
|
||||
version: 1,
|
||||
|
||||
editor: {
|
||||
fontSize: 12, // 10-18px
|
||||
theme: 'vs-light', // 'vs-light' | 'vs-dark' | 'hc-black'
|
||||
minimap: false, // true | false
|
||||
wordWrap: 'on', // 'on' | 'off'
|
||||
lineNumbers: 'on', // 'on' | 'off'
|
||||
tabSize: 2 // 2-8 spaces
|
||||
},
|
||||
|
||||
performance: {
|
||||
renderDebounce: 1500 // 300-3000ms - delay before visualization renders
|
||||
},
|
||||
|
||||
ui: {
|
||||
theme: 'light' // 'light' | 'experimental'
|
||||
},
|
||||
|
||||
formatting: {
|
||||
dateFormat: 'smart', // 'smart' | 'locale' | 'iso' | 'custom'
|
||||
customDateFormat: 'yyyy-MM-dd HH:mm' // Used when dateFormat === 'custom'
|
||||
}
|
||||
};
|
||||
|
||||
// Current user settings (loaded from localStorage or defaults)
|
||||
let userSettings = null;
|
||||
|
||||
// Initialize settings - load from localStorage or use defaults
|
||||
function initSettings() {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge with defaults to handle version upgrades
|
||||
userSettings = mergeWithDefaults(parsed);
|
||||
} else {
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user settings:', error);
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
}
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
// Merge stored settings with defaults (handles new settings in updates)
|
||||
function mergeWithDefaults(stored) {
|
||||
const merged = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
|
||||
// Merge each section
|
||||
for (const section in stored) {
|
||||
if (merged[section] && typeof merged[section] === 'object') {
|
||||
Object.assign(merged[section], stored[section]);
|
||||
} else {
|
||||
merged[section] = stored[section];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(userSettings));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving user settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all current settings
|
||||
function getSettings() {
|
||||
if (!userSettings) {
|
||||
initSettings();
|
||||
}
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
// Get a specific setting by path (e.g., 'editor.fontSize')
|
||||
function getSetting(path) {
|
||||
const settings = getSettings();
|
||||
const parts = path.split('.');
|
||||
let value = settings;
|
||||
|
||||
for (const part of parts) {
|
||||
if (value && typeof value === 'object' && part in value) {
|
||||
value = value[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Update a specific setting by path
|
||||
function updateSetting(path, value) {
|
||||
const settings = getSettings();
|
||||
const parts = path.split('.');
|
||||
let target = settings;
|
||||
|
||||
// Navigate to the parent object
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part] || typeof target[part] !== 'object') {
|
||||
target[part] = {};
|
||||
}
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
// Set the value
|
||||
const lastPart = parts[parts.length - 1];
|
||||
target[lastPart] = value;
|
||||
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Update multiple settings at once
|
||||
function updateSettings(updates) {
|
||||
const settings = getSettings();
|
||||
|
||||
for (const path in updates) {
|
||||
const parts = path.split('.');
|
||||
let target = settings;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part] || typeof target[part] !== 'object') {
|
||||
target[part] = {};
|
||||
}
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
target[lastPart] = updates[path];
|
||||
}
|
||||
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
function resetSettings() {
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Export settings as JSON
|
||||
function exportSettings() {
|
||||
return JSON.stringify(getSettings(), null, 2);
|
||||
}
|
||||
|
||||
// Import settings from JSON string
|
||||
function importSettings(jsonString) {
|
||||
try {
|
||||
const imported = JSON.parse(jsonString);
|
||||
userSettings = mergeWithDefaults(imported);
|
||||
return saveSettings();
|
||||
} catch (error) {
|
||||
console.error('Error importing settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date according to user settings
|
||||
function formatDate(isoString, useFullFormat = false) {
|
||||
const date = new Date(isoString);
|
||||
const format = getSetting('formatting.dateFormat');
|
||||
|
||||
if (!useFullFormat && format === 'smart') {
|
||||
// Smart format: relative for recent dates
|
||||
const diffMs = new Date() - date;
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (format === 'locale') {
|
||||
return useFullFormat
|
||||
? date.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
: date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (format === 'iso') {
|
||||
return useFullFormat
|
||||
? date.toISOString()
|
||||
: date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (format === 'custom') {
|
||||
const customFormat = getSetting('formatting.customDateFormat');
|
||||
return formatCustomDate(date, customFormat);
|
||||
}
|
||||
|
||||
// Fallback to locale
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Format date using custom format tokens
|
||||
function formatCustomDate(date, format) {
|
||||
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
||||
|
||||
const tokens = {
|
||||
'yyyy': date.getFullYear(),
|
||||
'yy': String(date.getFullYear()).slice(-2),
|
||||
'MM': pad(date.getMonth() + 1),
|
||||
'M': date.getMonth() + 1,
|
||||
'dd': pad(date.getDate()),
|
||||
'd': date.getDate(),
|
||||
'HH': pad(date.getHours()),
|
||||
'H': date.getHours(),
|
||||
'hh': pad(date.getHours() % 12 || 12),
|
||||
'h': date.getHours() % 12 || 12,
|
||||
'mm': pad(date.getMinutes()),
|
||||
'm': date.getMinutes(),
|
||||
'ss': pad(date.getSeconds()),
|
||||
's': date.getSeconds(),
|
||||
'a': date.getHours() < 12 ? 'am' : 'pm',
|
||||
'A': date.getHours() < 12 ? 'AM' : 'PM'
|
||||
};
|
||||
|
||||
let result = format;
|
||||
// Sort by length descending to replace longer tokens first
|
||||
const sortedTokens = Object.keys(tokens).sort((a, b) => b.length - a.length);
|
||||
for (const token of sortedTokens) {
|
||||
result = result.replace(new RegExp(token, 'g'), tokens[token]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate setting value
|
||||
function validateSetting(path, value) {
|
||||
const errors = [];
|
||||
|
||||
if (path === 'editor.fontSize') {
|
||||
if (typeof value !== 'number' || value < 10 || value > 18) {
|
||||
errors.push('Font size must be between 10 and 18');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'editor.theme') {
|
||||
const validThemes = ['vs-light', 'vs-dark', 'hc-black'];
|
||||
if (!validThemes.includes(value)) {
|
||||
errors.push('Invalid theme value');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'editor.tabSize') {
|
||||
if (typeof value !== 'number' || value < 2 || value > 8) {
|
||||
errors.push('Tab size must be between 2 and 8');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'performance.renderDebounce') {
|
||||
if (typeof value !== 'number' || value < 300 || value > 3000) {
|
||||
errors.push('Render debounce must be between 300 and 3000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'formatting.dateFormat') {
|
||||
const validFormats = ['smart', 'locale', 'iso', 'custom'];
|
||||
if (!validFormats.includes(value)) {
|
||||
errors.push('Invalid date format value');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'ui.theme') {
|
||||
const validThemes = ['light', 'experimental'];
|
||||
if (!validThemes.includes(value)) {
|
||||
errors.push('Invalid theme value');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
459
web/src/styles.css
Normal file
459
web/src/styles.css
Normal file
@@ -0,0 +1,459 @@
|
||||
/* CSS Variables - Windows 2000 Light Theme (Default) */
|
||||
:root {
|
||||
--win-gray: #c0c0c0;
|
||||
--win-gray-light: #d4d0c8;
|
||||
--win-gray-dark: #808080;
|
||||
--win-gray-darker: #606060;
|
||||
--win-blue: #316ac5;
|
||||
--win-blue-dark: #0a246a;
|
||||
--win-blue-light: #4a7ac5;
|
||||
--win-blue-lighter: #6a9ad5;
|
||||
--bg-white: #fff;
|
||||
--bg-light: #f0f0f0;
|
||||
--bg-lighter: #e0e0e0;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #333;
|
||||
--font-main: 'IBM Plex Mono', 'Courier New', Consolas, monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Courier New', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* CSS Variables - Experimental Theme */
|
||||
:root[data-theme="experimental"] {
|
||||
--win-gray: #2a2a2a;
|
||||
--win-gray-light: #3a3a3a;
|
||||
--win-gray-dark: #1a1a1a;
|
||||
--win-gray-darker: #0f0f0f;
|
||||
--win-blue: #4a9eff;
|
||||
--win-blue-dark: #2a6acc;
|
||||
--win-blue-light: #6ab3ff;
|
||||
--win-blue-lighter: #7fbfff;
|
||||
--bg-white: #1e1e1e;
|
||||
--bg-light: #2d2d2d;
|
||||
--bg-lighter: #3a3a3a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #b0b0b0;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--font-main); height: 100vh; overflow: hidden; background: var(--win-gray); display: flex; flex-direction: column; }
|
||||
|
||||
/* Header */
|
||||
.header { background: var(--win-gray); border-bottom: 2px solid var(--win-gray-dark); padding: 6px 12px; display: flex; align-items: center; justify-content: space-between; height: 36px; flex-shrink: 0; }
|
||||
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { font-size: 20px; }
|
||||
.header-title { font-size: 14px; font-weight: bold; }
|
||||
.header-links { display: flex; gap: 16px; }
|
||||
.header-link { font-size: 12px; text-decoration: underline; cursor: pointer; color: var(--text-primary); }
|
||||
.header-link:hover { background: var(--win-blue); color: var(--bg-white); }
|
||||
|
||||
/* Layout */
|
||||
.app-container { display: flex; height: calc(100vh - 36px); width: 100vw; }
|
||||
.toggle-strip { display: flex; flex-direction: column; background: var(--win-gray); width: 32px; border-right: 2px solid var(--win-gray-dark); }
|
||||
.main-panels { display: flex; flex: 1; min-width: 0; }
|
||||
.panel { display: flex; flex-direction: column; border-right: 2px solid var(--win-gray-dark); background: var(--win-gray); min-width: 200px; }
|
||||
.panel:last-child { border-right: none; }
|
||||
.snippet-panel { width: 25%; flex: 0 1 auto; }
|
||||
.editor-panel { width: 50%; flex: 0 1 auto; }
|
||||
.preview-panel { width: 25%; flex: 0 1 auto; }
|
||||
|
||||
/* Resize Handle */
|
||||
.resize-handle { width: 4px; background: var(--win-gray-dark); cursor: col-resize; flex-shrink: 0; position: relative; border-left: 1px solid #a0a0a0; border-right: 1px solid var(--win-gray-darker); }
|
||||
:root[data-theme="experimental"] .resize-handle { border-left-color: #505050; }
|
||||
.resize-handle:hover { background: var(--win-gray-darker); }
|
||||
.resize-handle.dragging { background: var(--win-blue); }
|
||||
|
||||
/* Panel Header */
|
||||
.panel-header { padding: 6px 12px; background: var(--win-gray); border-bottom: 1px solid var(--win-gray-dark); font-size: 12px; display: flex; justify-content: space-between; align-items: center; height: 28px; color: var(--text-primary); }
|
||||
|
||||
/* Controls */
|
||||
.editor-controls { display: flex; align-items: center; gap: 6px; height: 20px; }
|
||||
.view-label { font-size: 10px; margin-right: 4px; }
|
||||
.view-toggle-group,
|
||||
.dataset-toggle-group { display: flex; }
|
||||
|
||||
/* Base Button Styles */
|
||||
.btn {
|
||||
background: var(--win-gray);
|
||||
border: 2px outset var(--win-gray);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-main);
|
||||
}
|
||||
.btn:hover { background: var(--win-gray-light); }
|
||||
.btn:active { border-style: inset; }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Toggle Buttons (small inline) */
|
||||
.btn-toggle { border: 1px solid var(--win-gray-dark); padding: 2px 8px; font-size: 10px; height: 20px; color: var(--text-primary); }
|
||||
.btn-toggle:first-child { border-right: 1px solid var(--win-gray-dark); }
|
||||
.btn-toggle:last-child { border-left: none; }
|
||||
.btn-toggle:hover:not(.active) { background: var(--win-gray-light); }
|
||||
.btn-toggle:active { background: var(--win-blue); color: var(--bg-white); }
|
||||
.btn-toggle.active {
|
||||
background: var(--win-blue);
|
||||
color: var(--bg-white);
|
||||
border-top: 1px solid var(--win-blue-dark);
|
||||
border-left: 1px solid var(--win-blue-dark);
|
||||
border-bottom: 1px solid var(--win-blue-light);
|
||||
border-right: 1px solid var(--win-blue-light);
|
||||
}
|
||||
.btn-toggle.active:first-child { border-right: 1px solid var(--win-blue-light); }
|
||||
.btn-toggle.active:last-child { border-left: 1px solid var(--win-blue-dark); }
|
||||
.btn-toggle:not(:first-child) { border-left: none; }
|
||||
.btn-toggle.active:not(:first-child) { border-left: 1px solid var(--win-blue-dark); }
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-action { padding: 2px 8px; font-size: 10px; display: none; height: 20px; }
|
||||
.btn-action.visible { display: block; }
|
||||
.btn-action:hover { filter: brightness(1.1); }
|
||||
.btn-action.publish { background: #90ee90; }
|
||||
.btn-action.publish:hover { background: #a0ffa0; }
|
||||
:root[data-theme="experimental"] .btn-action.publish { background: #3a6a3a; color: #88ff88; }
|
||||
:root[data-theme="experimental"] .btn-action.publish:hover { background: #4a8a4a; }
|
||||
.btn-action.revert { background: #ffb080; }
|
||||
.btn-action.revert:hover { background: #ffc090; }
|
||||
:root[data-theme="experimental"] .btn-action.revert { background: #6a4a2a; color: #ffcc99; }
|
||||
:root[data-theme="experimental"] .btn-action.revert:hover { background: #8a6a4a; }
|
||||
|
||||
/* Icon Buttons */
|
||||
.btn-icon { padding: 0; width: 20px; height: 20px; font-size: 14px; display: flex; align-items: center; justify-content: center; border: 1px outset var(--win-gray); }
|
||||
.btn-icon:active { border: 1px inset var(--win-gray); }
|
||||
.btn-icon.large { width: 24px; height: 24px; font-size: 12px; }
|
||||
.btn-icon.xlarge { width: 32px; height: 32px; font-size: 16px; padding: 6px; }
|
||||
.btn-icon.xlarge.active { background: var(--win-gray-light); border: 2px inset var(--win-gray); }
|
||||
|
||||
/* Standard Buttons */
|
||||
.btn-standard { padding: 4px 8px; font-size: 11px; }
|
||||
.btn-standard.flex { flex: 1; }
|
||||
.btn-standard.primary { background: #90ee90; }
|
||||
.btn-standard.primary:hover { background: #a0ffa0; }
|
||||
:root[data-theme="experimental"] .btn-standard.primary { background: #3a6a3a; color: #88ff88; }
|
||||
:root[data-theme="experimental"] .btn-standard.primary:hover { background: #4a8a4a; }
|
||||
.btn-standard.danger { background: #f88; border: 2px outset #f88; }
|
||||
.btn-standard.danger:hover { background: #f99; }
|
||||
:root[data-theme="experimental"] .btn-standard.danger { background: #6a2a2a; color: #ff9999; border-color: #6a2a2a; }
|
||||
:root[data-theme="experimental"] .btn-standard.danger:hover { background: #8a4a4a; }
|
||||
.btn-standard.danger:active { border: 2px inset #f88; }
|
||||
|
||||
/* Modal Buttons */
|
||||
.btn-modal { padding: 6px 12px; font-size: 11px; }
|
||||
.btn-modal.primary { background: #90ee90; }
|
||||
.btn-modal.primary:hover { background: #a0ffa0; }
|
||||
:root[data-theme="experimental"] .btn-modal.primary { background: #3a6a3a; color: #88ff88; }
|
||||
:root[data-theme="experimental"] .btn-modal.primary:hover { background: #4a8a4a; }
|
||||
.btn-modal.danger { background: #f88; border: 2px outset #f88; }
|
||||
.btn-modal.danger:hover { background: #f99; }
|
||||
:root[data-theme="experimental"] .btn-modal.danger { background: #6a2a2a; color: #ff9999; border-color: #6a2a2a; }
|
||||
:root[data-theme="experimental"] .btn-modal.danger:hover { background: #8a4a4a; }
|
||||
.btn-modal.danger:active { border: 2px inset #f88; }
|
||||
|
||||
/* Sort Controls */
|
||||
.sort-controls { padding: 6px 12px; background: var(--win-gray-light); border-bottom: 2px solid var(--win-gray-dark); display: flex; align-items: center; gap: 6px; font-size: 11px; }
|
||||
.sort-label { font-size: 10px; margin-right: 4px; }
|
||||
.sort-btn { border: 1px outset var(--win-gray); padding: 2px 6px; font-size: 10px; display: flex; align-items: center; gap: 3px; }
|
||||
.sort-btn:hover { background: var(--win-gray-light); }
|
||||
.sort-btn:active { border: 1px inset var(--win-gray); }
|
||||
.sort-text { flex: 1; }
|
||||
.sort-arrow { font-size: 9px; opacity: 0.7; }
|
||||
.sort-btn.active {
|
||||
background: var(--win-blue);
|
||||
color: var(--bg-white);
|
||||
border: 1px inset var(--win-blue);
|
||||
border-top: 1px solid var(--win-blue-dark);
|
||||
border-left: 1px solid var(--win-blue-dark);
|
||||
border-bottom: 1px solid var(--win-blue-light);
|
||||
border-right: 1px solid var(--win-blue-light);
|
||||
}
|
||||
|
||||
/* Search Controls */
|
||||
.search-controls { padding: 6px 12px; background: var(--win-gray-light); border-bottom: 2px solid var(--win-gray-dark); display: flex; align-items: center; gap: 4px; }
|
||||
#snippet-search { flex: 1; font-family: var(--font-main); font-size: 11px; border: 2px inset var(--win-gray); padding: 3px 6px; height: 20px; background: var(--bg-white); color: var(--text-primary); }
|
||||
|
||||
/* Panel Content */
|
||||
.panel-content { flex: 1; padding: 8px; overflow: hidden; background: var(--bg-white); border: 1px inset var(--win-gray); display: flex; flex-direction: column; color: var(--text-primary); }
|
||||
|
||||
/* Placeholder */
|
||||
.placeholder { color: var(--win-gray-dark); font-style: italic; text-align: center; margin-top: 40px; font-size: 12px; }
|
||||
:root[data-theme="experimental"] .placeholder { color: var(--win-gray-light); }
|
||||
|
||||
/* Snippet List */
|
||||
.snippet-list { list-style: none; flex: 1; overflow-y: auto; overflow-x: hidden; margin-bottom: 8px; }
|
||||
.snippet-item { padding: 4px 8px; border: 1px solid var(--win-gray-dark); margin-bottom: 2px; cursor: pointer; background: var(--bg-white); color: var(--text-primary); display: flex; justify-content: space-between; align-items: center; }
|
||||
.snippet-info { flex: 1; }
|
||||
.snippet-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 8px; }
|
||||
.snippet-status.published { background: #0f0; box-shadow: 0 0 2px #0c0; }
|
||||
:root[data-theme="experimental"] .snippet-status.published { background: #66ff66; box-shadow: 0 0 2px #44dd44; }
|
||||
.snippet-status.draft { background: #ff0; box-shadow: 0 0 2px #cc0; }
|
||||
:root[data-theme="experimental"] .snippet-status.draft { background: #ffff66; box-shadow: 0 0 2px #dddd44; }
|
||||
.snippet-item:hover { background: var(--win-blue-lighter); color: var(--bg-white); }
|
||||
.snippet-item.selected { background: var(--win-blue); color: var(--bg-white); }
|
||||
.snippet-name { font-size: 12px; }
|
||||
.snippet-date { font-size: 11px; color: inherit; margin-top: 1px; }
|
||||
.snippet-size { font-size: 10px; color: var(--win-gray-dark); margin-left: auto; margin-right: 8px; flex-shrink: 0; }
|
||||
:root[data-theme="experimental"] .snippet-size { color: var(--win-gray-light); }
|
||||
.snippet-dataset-icon { margin-left: 4px; font-size: 10px; opacity: 0.7; }
|
||||
.snippet-item.selected .snippet-dataset-icon,
|
||||
.snippet-item:hover .snippet-dataset-icon { opacity: 1; }
|
||||
|
||||
/* Placeholders */
|
||||
.editor-placeholder,
|
||||
.preview-placeholder { background: var(--bg-white); border: 2px inset var(--win-gray); height: 300px; display: flex; align-items: center; justify-content: center; flex-direction: column; margin: 8px; }
|
||||
|
||||
/* Snippet Meta */
|
||||
.snippet-meta { margin-top: 12px; padding: 8px 8px 16px; border-top: 1px solid var(--win-gray-dark); background: var(--bg-light); border: 1px inset var(--win-gray); margin-left: -8px; margin-right: -8px; margin-bottom: 0; flex-shrink: 0; color: var(--text-primary); }
|
||||
.meta-header { font-size: 11px; font-weight: bold; margin-bottom: 4px; color: var(--text-primary); }
|
||||
|
||||
/* Base Input Styles */
|
||||
.input {
|
||||
width: 100%;
|
||||
font-family: var(--font-main);
|
||||
font-size: 11px;
|
||||
border: 2px inset var(--win-gray);
|
||||
padding: 4px;
|
||||
background: var(--bg-white);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.input.textarea { resize: vertical; }
|
||||
.input.small { height: 20px; }
|
||||
.input.medium { min-height: 40px; }
|
||||
|
||||
/* Meta Info */
|
||||
.meta-info { margin: 8px 0; padding: 6px; background: var(--bg-lighter); border: 1px inset var(--win-gray); font-size: 10px; color: var(--text-primary); }
|
||||
.meta-info-item { display: flex; justify-content: space-between; margin-bottom: 2px; }
|
||||
.meta-info-item:last-child { margin-bottom: 0; }
|
||||
.meta-info-label { font-weight: bold; }
|
||||
.meta-info-value { color: var(--win-gray-darker); }
|
||||
:root[data-theme="experimental"] .meta-info-value { color: var(--win-gray-light); }
|
||||
.dataset-link { color: var(--win-blue); text-decoration: underline; cursor: pointer; }
|
||||
.dataset-link:hover { color: var(--win-blue-dark); background: #e0e8f0; }
|
||||
:root[data-theme="experimental"] .dataset-link:hover { background: #2a3a5a; }
|
||||
.snippet-link { color: var(--win-blue); text-decoration: underline; cursor: pointer; font-size: 10px; }
|
||||
.snippet-link:hover { color: var(--win-blue-dark); background: #e0e8f0; }
|
||||
:root[data-theme="experimental"] .snippet-link:hover { background: #2a3a5a; }
|
||||
|
||||
/* Meta Actions */
|
||||
.meta-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||
|
||||
/* Ghost Card */
|
||||
.ghost-card { border: 2px dashed var(--win-gray-dark) !important; background: var(--bg-light) !important; font-style: italic; opacity: 0.8; }
|
||||
.ghost-card:hover { background: var(--bg-lighter) !important; border-color: var(--win-gray-darker) !important; opacity: 1; }
|
||||
.ghost-card .snippet-name { color: var(--win-gray-darker); }
|
||||
:root[data-theme="experimental"] .ghost-card .snippet-name { color: var(--win-gray-light); }
|
||||
.ghost-card .snippet-date { color: var(--win-gray-dark); }
|
||||
:root[data-theme="experimental"] .ghost-card .snippet-date { color: var(--win-gray-light); }
|
||||
|
||||
/* Storage Monitor */
|
||||
.storage-monitor { padding: 8px; background: var(--bg-light); border-top: 1px solid var(--win-gray-dark); margin: 0 -8px -8px; flex-shrink: 0; }
|
||||
.storage-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 10px; }
|
||||
.storage-label { font-weight: bold; color: var(--text-primary); }
|
||||
.storage-text { color: #0066cc; }
|
||||
:root[data-theme="experimental"] .storage-text { color: #4a9eff; }
|
||||
.storage-bar { width: 100%; height: 12px; background: var(--bg-white); border: 1px inset var(--win-gray); position: relative; }
|
||||
.storage-fill { height: 100%; background: #0a0; transition: width 0.3s ease, background-color 0.3s ease; }
|
||||
:root[data-theme="experimental"] .storage-fill { background: #66ff66; }
|
||||
.storage-fill.warning { background: #f80; }
|
||||
:root[data-theme="experimental"] .storage-fill.warning { background: #ffaa44; }
|
||||
.storage-fill.critical { background: #f00; }
|
||||
:root[data-theme="experimental"] .storage-fill.critical { background: #ff6666; }
|
||||
|
||||
/* Modal */
|
||||
.modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal-content { background: var(--win-gray); border: 2px outset var(--win-gray); width: 95%; max-width: 1200px; height: 85vh; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 4px 4px 8px rgba(0,0,0,0.3); }
|
||||
.modal-header { background: #008; color: var(--bg-white); padding: 4px 8px; display: flex; justify-content: space-between; align-items: center; height: 24px; border-bottom: 2px solid var(--win-gray-dark); }
|
||||
:root[data-theme="experimental"] .modal-header { background: #1a4a7a; }
|
||||
.modal-title { font-size: 12px; font-weight: bold; }
|
||||
.modal-body { flex: 1; overflow: auto; background: var(--bg-white); border: 2px inset var(--win-gray); margin: 8px; min-height: 0; color: var(--text-primary); }
|
||||
|
||||
/* Dataset Views */
|
||||
.dataset-view { min-height: 100%; display: flex; flex-direction: column; }
|
||||
.dataset-list-header { padding: 8px; background: var(--win-gray-light); border-bottom: 2px solid var(--win-gray-dark); }
|
||||
.dataset-container { display: flex; flex: 1; overflow: hidden; }
|
||||
.dataset-list { width: 300px; overflow-y: auto; border-right: 2px solid var(--win-gray-dark); background: var(--bg-white); color: var(--text-primary); }
|
||||
.dataset-item { padding: 8px; border-bottom: 1px solid #d0d0d0; cursor: pointer; background: var(--bg-white); color: var(--text-primary); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
:root[data-theme="experimental"] .dataset-item { border-bottom-color: var(--win-gray-dark); }
|
||||
.dataset-item:hover { background: var(--win-blue-lighter); color: var(--bg-white); }
|
||||
.dataset-item.selected { background: var(--win-blue); color: var(--bg-white); }
|
||||
.dataset-info { flex: 1; min-width: 0; }
|
||||
.dataset-name { font-size: 12px; font-weight: bold; margin-bottom: 2px; }
|
||||
.dataset-meta { font-size: 10px; color: var(--win-gray-darker); }
|
||||
:root[data-theme="experimental"] .dataset-meta { color: var(--win-gray-light); }
|
||||
.dataset-item.selected .dataset-meta,
|
||||
.dataset-item:hover .dataset-meta { color: inherit; opacity: 0.9; }
|
||||
.dataset-usage-badge { background: var(--win-blue); color: var(--bg-white); padding: 2px 6px; font-size: 10px; font-weight: bold; border-radius: 3px; white-space: nowrap; flex-shrink: 0; }
|
||||
.dataset-item.selected .dataset-usage-badge { background: var(--win-blue-dark); }
|
||||
.dataset-item:hover .dataset-usage-badge { background: var(--win-blue-dark); }
|
||||
.dataset-empty { padding: 32px; text-align: center; color: var(--win-gray-dark); font-style: italic; font-size: 12px; }
|
||||
:root[data-theme="experimental"] .dataset-empty { color: var(--win-gray-light); }
|
||||
|
||||
/* Dataset Details */
|
||||
.dataset-details { flex: 1; overflow-y: auto; background: var(--bg-white); color: var(--text-primary); }
|
||||
.dataset-detail-section { padding: 16px; }
|
||||
.dataset-detail-header { font-size: 11px; font-weight: bold; margin-bottom: 6px; margin-top: 12px; color: var(--text-primary); }
|
||||
.dataset-detail-header:first-child { margin-top: 0; }
|
||||
.dataset-detail-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; margin-top: 12px; color: var(--text-primary); }
|
||||
|
||||
/* Stats & Preview Boxes */
|
||||
.stats-box { background: var(--bg-light); border: 1px inset var(--win-gray); padding: 8px; font-size: 10px; color: var(--text-primary); }
|
||||
.stat-item { display: flex; justify-content: space-between; margin-bottom: 4px; }
|
||||
.stat-item:last-child { margin-bottom: 0; }
|
||||
.stat-label { font-weight: bold; }
|
||||
|
||||
.preview-box { background: var(--bg-light); border: 2px inset var(--win-gray); padding: 8px; font-family: var(--font-mono); font-size: 10px; overflow: auto; margin: 0; color: var(--text-primary); }
|
||||
.preview-box.medium { max-height: 150px; }
|
||||
.preview-box.large { max-height: 200px; }
|
||||
|
||||
/* Preview Toggle */
|
||||
.preview-toggle-group { display: flex; }
|
||||
.btn-toggle.small { padding: 2px 6px; font-size: 9px; height: 18px; }
|
||||
|
||||
/* Preview Table */
|
||||
.preview-table-container { background: var(--bg-light); border: 2px inset var(--win-gray); overflow: auto; max-height: 200px; }
|
||||
.preview-table { width: 100%; border-collapse: collapse; font-size: 10px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; }
|
||||
.preview-table th { background: var(--win-gray-light); border: 1px solid var(--win-gray-dark); padding: 4px 6px; text-align: left; font-weight: bold; position: sticky; top: 0; color: var(--text-primary); }
|
||||
.preview-table td { border: 1px solid #d0d0d0; padding: 3px 6px; background: var(--bg-white); color: var(--text-primary); }
|
||||
:root[data-theme="experimental"] .preview-table td { border-color: var(--win-gray-dark); }
|
||||
.preview-table tr:hover td { background: var(--bg-lighter); }
|
||||
.preview-table-info { padding: 8px; font-size: 10px; color: var(--win-gray-darker); font-style: italic; text-align: center; }
|
||||
:root[data-theme="experimental"] .preview-table-info { color: var(--win-gray-light); }
|
||||
|
||||
/* Type-specific cell formatting */
|
||||
.type-icon { font-size: 11px; margin-right: 3px; }
|
||||
.cell-number { font-style: italic; text-align: right; color: #0066cc; }
|
||||
:root[data-theme="experimental"] .cell-number { color: #66b3ff; }
|
||||
.cell-date { font-style: italic; color: #228b22; }
|
||||
:root[data-theme="experimental"] .cell-date { color: #66dd66; }
|
||||
.cell-boolean { font-weight: bold; text-align: center; color: #ff6600; }
|
||||
:root[data-theme="experimental"] .cell-boolean { color: #ffaa44; }
|
||||
.cell-text { color: var(--text-primary); }
|
||||
.cell-null { color: var(--win-gray-dark); font-style: italic; text-align: center; }
|
||||
:root[data-theme="experimental"] .cell-null { color: var(--win-gray-light); }
|
||||
|
||||
.dataset-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* Dataset Form */
|
||||
.dataset-form { padding: 16px; height: 100%; overflow-y: auto; color: var(--text-primary); }
|
||||
.dataset-form-header { font-size: 14px; font-weight: bold; margin-bottom: 16px; color: var(--text-primary); }
|
||||
.dataset-form-group { margin-bottom: 12px; }
|
||||
.dataset-form-label { display: block; font-size: 11px; font-weight: bold; margin-bottom: 4px; }
|
||||
.dataset-toggle-row { display: flex; gap: 24px; padding: 8px; background: var(--bg-light); border: 1px inset var(--win-gray); flex-wrap: wrap; }
|
||||
.dataset-toggle-section { display: flex; align-items: center; gap: 8px; }
|
||||
.dataset-toggle-label { font-size: 10px; color: var(--text-primary); }
|
||||
.dataset-format-hint { font-size: 10px; color: var(--win-gray-darker); font-style: italic; margin-bottom: 4px; padding: 4px; background: #fffacd; border: 1px solid #e0e0a0; }
|
||||
:root[data-theme="experimental"] .dataset-format-hint { background: #4a4a2a; color: var(--text-secondary); border-color: #666644; }
|
||||
|
||||
/* Detection Confirmation */
|
||||
.dataset-detection-confirm { margin: 12px 0; padding: 12px; background: #e8f4f8; border: 2px solid #4a90c5; border-radius: 4px; }
|
||||
:root[data-theme="experimental"] .dataset-detection-confirm { background: #2a3a4a; border-color: #4a7acc; }
|
||||
.detection-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.detection-title { font-size: 11px; font-weight: bold; color: var(--text-primary); }
|
||||
.detection-badges { display: flex; gap: 6px; align-items: center; }
|
||||
.detection-badge { background: var(--win-blue); color: var(--bg-white); padding: 2px 8px; font-size: 10px; font-weight: bold; border: 1px solid var(--win-blue-dark); border-radius: 2px; }
|
||||
.detected-confidence { font-size: 9px; padding: 2px 6px; border-radius: 2px; }
|
||||
.detected-confidence.high { background: #90ee90; border: 1px solid #60c060; }
|
||||
:root[data-theme="experimental"] .detected-confidence.high { background: #3a6a3a; color: #88ff88; border-color: #66dd66; }
|
||||
.detected-confidence.medium { background: #ffff90; border: 1px solid #d0d060; }
|
||||
:root[data-theme="experimental"] .detected-confidence.medium { background: #6a6a2a; color: #ffff99; border-color: #dddd66; }
|
||||
.detected-confidence.low { background: #ffb080; border: 1px solid #d08050; }
|
||||
:root[data-theme="experimental"] .detected-confidence.low { background: #6a4a2a; color: #ffcc99; border-color: #dd8866; }
|
||||
.detection-preview-label { font-size: 10px; font-weight: bold; margin-bottom: 4px; color: var(--text-primary); }
|
||||
.dataset-form-error { color: #f00; font-size: 11px; margin-bottom: 12px; min-height: 16px; }
|
||||
:root[data-theme="experimental"] .dataset-form-error { color: #ff6666; }
|
||||
.dataset-form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* Help Modal */
|
||||
.help-content { padding: 20px; }
|
||||
.help-section { margin-bottom: 24px; }
|
||||
.help-section:last-child { margin-bottom: 0; }
|
||||
.help-heading { font-size: 14px; font-weight: bold; margin: 0 0 12px 0; color: var(--win-blue-dark); border-bottom: 1px solid var(--win-gray-dark); padding-bottom: 4px; }
|
||||
:root[data-theme="experimental"] .help-heading { color: var(--win-blue); }
|
||||
.help-text { font-size: 12px; line-height: 1.5; margin: 0 0 8px 0; color: var(--text-secondary); }
|
||||
.help-text:last-child { margin-bottom: 0; }
|
||||
.help-warning { background: #fff3cd; border: 2px solid #ffc107; padding: 12px; margin-bottom: 16px; font-size: 12px; line-height: 1.5; color: #856404; border-radius: 2px; }
|
||||
:root[data-theme="experimental"] .help-warning { background: #4a3a0a; border-color: #996600; color: #ffaa66; }
|
||||
.help-warning strong { color: #d39e00; }
|
||||
:root[data-theme="experimental"] .help-warning strong { color: #ffcc88; }
|
||||
.help-list { margin: 0; padding-left: 20px; font-size: 12px; line-height: 1.6; }
|
||||
.help-list li { margin-bottom: 8px; color: var(--text-secondary); }
|
||||
.help-list li:last-child { margin-bottom: 0; }
|
||||
.help-workflow { display: flex; flex-direction: column; gap: 12px; }
|
||||
.help-step { background: var(--bg-light); border: 1px solid var(--win-gray-dark); padding: 10px 12px; border-radius: 2px; }
|
||||
.help-step strong { display: block; font-size: 12px; margin-bottom: 4px; color: var(--win-blue-dark); }
|
||||
:root[data-theme="experimental"] .help-step strong { color: var(--win-blue); }
|
||||
.help-step p { font-size: 11px; line-height: 1.5; margin: 0; color: var(--text-secondary); }
|
||||
.help-step code { font-family: var(--font-mono); font-size: 10px; background: var(--bg-white); border: 1px solid var(--win-gray-dark); padding: 2px 4px; border-radius: 2px; }
|
||||
.help-shortcuts-table { width: 100%; border-collapse: collapse; }
|
||||
.help-shortcuts-table tbody tr { border-bottom: 1px solid var(--bg-lighter); }
|
||||
.help-shortcuts-table tbody tr:last-child { border-bottom: none; }
|
||||
.help-shortcuts-table td { padding: 12px 8px; font-size: 12px; }
|
||||
.shortcut-key { font-family: var(--font-mono); font-weight: bold; background: var(--bg-light); border: 1px solid var(--win-gray-dark); padding: 6px 10px; border-radius: 2px; white-space: nowrap; width: 180px; }
|
||||
.shortcut-desc { color: var(--win-gray-darker); }
|
||||
:root[data-theme="experimental"] .shortcut-desc { color: var(--win-gray-light); }
|
||||
|
||||
/* Two-column layout for donate sections */
|
||||
.donate-two-column { display: flex; gap: 20px; margin-bottom: 24px; }
|
||||
.donate-two-column .help-section { flex: 1; margin-bottom: 0; }
|
||||
|
||||
/* Settings Modal */
|
||||
.settings-content { padding: 20px; }
|
||||
.settings-section { margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--win-gray-dark); }
|
||||
.settings-section:last-of-type { border-bottom: none; padding-bottom: 0; }
|
||||
.settings-heading { font-size: 14px; font-weight: bold; margin: 0 0 16px 0; color: var(--win-blue-dark); }
|
||||
:root[data-theme="experimental"] .settings-heading { color: var(--win-blue); }
|
||||
|
||||
.settings-item { margin-bottom: 16px; }
|
||||
.settings-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.settings-label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.settings-label input[type="checkbox"] { margin-right: 6px; vertical-align: middle; }
|
||||
|
||||
.settings-control { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.settings-slider { flex: 1; height: 4px; background: var(--win-gray-dark); outline: none; border-radius: 2px; -webkit-appearance: none; appearance: none; }
|
||||
.settings-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: var(--win-blue); border: 2px solid var(--win-blue-dark); border-radius: 50%; cursor: pointer; }
|
||||
.settings-slider::-moz-range-thumb { width: 16px; height: 16px; background: var(--win-blue); border: 2px solid var(--win-blue-dark); border-radius: 50%; cursor: pointer; }
|
||||
|
||||
.settings-value { font-size: 12px; font-family: var(--font-mono); color: var(--win-gray-darker); min-width: 60px; text-align: right; }
|
||||
:root[data-theme="experimental"] .settings-value { color: var(--win-gray-light); }
|
||||
|
||||
.settings-select { flex: 1; padding: 4px 8px; font-size: 12px; font-family: var(--font-mono); background: var(--bg-white); border: 2px inset var(--win-gray); color: var(--text-primary); }
|
||||
.settings-select:focus { outline: 1px dotted var(--text-primary); }
|
||||
|
||||
.settings-input { width: 100%; padding: 4px 8px; font-size: 12px; font-family: var(--font-mono); background: var(--bg-white); border: 2px inset var(--win-gray); color: var(--text-primary); }
|
||||
.settings-input:focus { outline: 1px dotted var(--text-primary); }
|
||||
|
||||
.settings-checkbox { width: 14px; height: 14px; cursor: pointer; }
|
||||
|
||||
.settings-hint { font-size: 11px; color: var(--win-gray-darker); margin-top: 4px; line-height: 1.4; }
|
||||
:root[data-theme="experimental"] .settings-hint { color: var(--text-secondary); }
|
||||
|
||||
.settings-actions { display: flex; gap: 8px; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--win-gray-dark); }
|
||||
.settings-actions .btn-modal { flex: 1; }
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column-reverse; gap: 10px; max-width: 400px; }
|
||||
.toast { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--win-gray); border: 2px outset var(--win-gray); box-shadow: 4px 4px 8px rgba(0,0,0,0.3); font-size: 12px; min-width: 300px; opacity: 0; transform: translateX(400px); transition: all 0.3s ease; }
|
||||
.toast-show { opacity: 1; transform: translateX(0); }
|
||||
.toast-hide { opacity: 0; transform: translateX(400px); }
|
||||
.toast-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.toast-message { flex: 1; line-height: 1.4; color: var(--text-primary); }
|
||||
.toast-close { background: none; border: none; font-size: 18px; cursor: pointer; padding: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; color: var(--win-gray-darker); flex-shrink: 0; }
|
||||
.toast-close:hover { background: var(--win-gray-dark); color: var(--bg-white); }
|
||||
.toast-error { background: #ffebee; border-color: #c62828; }
|
||||
.toast-error .toast-message { color: #b71c1c; }
|
||||
.toast-success { background: #e8f5e9; border-color: #2e7d32; }
|
||||
.toast-success .toast-message { color: #1b5e20; }
|
||||
.toast-warning { background: #fff3cd; border-color: #ffc107; }
|
||||
.toast-warning .toast-message { color: #856404; }
|
||||
.toast-info { background: #e3f2fd; border-color: #1976d2; }
|
||||
.toast-info .toast-message { color: #0d47a1; }
|
||||
|
||||
/* Dark theme toast variations */
|
||||
:root[data-theme="experimental"] .toast-error { background: #5a2a2a; border-color: #ff6666; }
|
||||
:root[data-theme="experimental"] .toast-error .toast-message { color: #ff8888; }
|
||||
:root[data-theme="experimental"] .toast-success { background: #2a4a2a; border-color: #66ff66; }
|
||||
:root[data-theme="experimental"] .toast-success .toast-message { color: #88ff88; }
|
||||
:root[data-theme="experimental"] .toast-warning { background: #5a4a2a; border-color: #ffcc66; }
|
||||
:root[data-theme="experimental"] .toast-warning .toast-message { color: #ffdd99; }
|
||||
:root[data-theme="experimental"] .toast-info { background: #2a3a5a; border-color: #6699ff; }
|
||||
:root[data-theme="experimental"] .toast-info .toast-message { color: #88bbff; }
|
||||
Reference in New Issue
Block a user