chore: move website files to web/ folder

This commit is contained in:
2025-10-17 23:23:09 +03:00
parent fd356185e2
commit 35db118ec7
12 changed files with 55 additions and 25 deletions

715
web/index.html Normal file
View 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
View 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 &amp; 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
View 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
View 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" }
}
};

File diff suppressed because it is too large Load Diff

156
web/src/js/editor.js Normal file
View 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
View 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();
}
});
}

File diff suppressed because it is too large Load Diff

299
web/src/js/user-settings.js Normal file
View 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
View 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; }