mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
refactor: alpine.js first step migration
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(node --check:*)"
|
"Bash(node --check:*)",
|
||||||
|
"WebSearch"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
77
index.html
77
index.html
@@ -25,6 +25,9 @@
|
|||||||
|
|
||||||
<!-- Monaco Editor -->
|
<!-- Monaco Editor -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -65,38 +68,86 @@
|
|||||||
|
|
||||||
<div class="main-panels">
|
<div class="main-panels">
|
||||||
<!-- Snippet Library Panel -->
|
<!-- Snippet Library Panel -->
|
||||||
<div class="panel snippet-panel" id="snippet-panel">
|
<div class="panel snippet-panel" id="snippet-panel" x-data="snippetList()">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
Snippets
|
Snippets
|
||||||
</div>
|
</div>
|
||||||
<div class="sort-controls">
|
<div class="sort-controls">
|
||||||
<span class="sort-label">Sort by:</span>
|
<span class="sort-label">Sort by:</span>
|
||||||
<button class="sort-btn active" data-sort="modified" title="Sort by last modified date">
|
<button class="sort-btn"
|
||||||
|
:class="{ 'active': sortBy === 'modified' }"
|
||||||
|
@click="toggleSort('modified')"
|
||||||
|
title="Sort by last modified date">
|
||||||
<span class="sort-text">Modified</span>
|
<span class="sort-text">Modified</span>
|
||||||
<span class="sort-arrow">⬇</span>
|
<span class="sort-arrow" x-text="sortBy === 'modified' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="sort-btn" data-sort="created" title="Sort by creation date">
|
<button class="sort-btn"
|
||||||
|
:class="{ 'active': sortBy === 'created' }"
|
||||||
|
@click="toggleSort('created')"
|
||||||
|
title="Sort by creation date">
|
||||||
<span class="sort-text">Created</span>
|
<span class="sort-text">Created</span>
|
||||||
<span class="sort-arrow">⬇</span>
|
<span class="sort-arrow" x-text="sortBy === 'created' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="sort-btn" data-sort="name" title="Sort alphabetically by name">
|
<button class="sort-btn"
|
||||||
|
:class="{ 'active': sortBy === 'name' }"
|
||||||
|
@click="toggleSort('name')"
|
||||||
|
title="Sort alphabetically by name">
|
||||||
<span class="sort-text">Name</span>
|
<span class="sort-text">Name</span>
|
||||||
<span class="sort-arrow">⬇</span>
|
<span class="sort-arrow" x-text="sortBy === 'name' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="sort-btn" data-sort="size" title="Sort by snippet size">
|
<button class="sort-btn"
|
||||||
|
:class="{ 'active': sortBy === 'size' }"
|
||||||
|
@click="toggleSort('size')"
|
||||||
|
title="Sort by snippet size">
|
||||||
<span class="sort-text">Size</span>
|
<span class="sort-text">Size</span>
|
||||||
<span class="sort-arrow">⬇</span>
|
<span class="sort-arrow" x-text="sortBy === 'size' && sortOrder === 'desc' ? '⬇' : '⬆'">⬇</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-controls">
|
<div class="search-controls">
|
||||||
<input type="text" id="snippet-search" placeholder="Search snippets..." />
|
<input type="text"
|
||||||
<button class="btn btn-icon" id="search-clear" title="Clear search">×</button>
|
id="snippet-search"
|
||||||
|
x-model="searchQuery"
|
||||||
|
placeholder="Search snippets..." />
|
||||||
|
<button class="btn btn-icon"
|
||||||
|
@click="clearSearch()"
|
||||||
|
title="Clear search">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<ul class="snippet-list" id="snippet-list">
|
<ul class="snippet-list" id="snippet-list">
|
||||||
<!-- Dynamically populated by renderSnippetList() -->
|
<!-- Ghost card for creating new snippets -->
|
||||||
|
<li class="snippet-item ghost-card"
|
||||||
|
id="new-snippet-card"
|
||||||
|
@click="createNewSnippet()">
|
||||||
|
<div class="snippet-name">+ Create New Snippet</div>
|
||||||
|
<div class="snippet-date">Click to create</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Snippet items -->
|
||||||
|
<template x-for="snippet in filteredSnippets" :key="snippet.id">
|
||||||
|
<li class="snippet-item"
|
||||||
|
:data-item-id="snippet.id"
|
||||||
|
:class="{ 'selected': $store.snippets.currentSnippetId === snippet.id }"
|
||||||
|
@click="selectSnippet(snippet.id)">
|
||||||
|
<div class="snippet-info">
|
||||||
|
<div class="snippet-name">
|
||||||
|
<span x-text="snippet.name"></span>
|
||||||
|
<span x-show="snippet.datasetRefs && snippet.datasetRefs.length > 0"
|
||||||
|
class="snippet-dataset-icon"
|
||||||
|
title="Uses external dataset">📁</span>
|
||||||
|
</div>
|
||||||
|
<div class="snippet-date" x-text="formatDate(snippet)"></div>
|
||||||
|
</div>
|
||||||
|
<span x-show="getSize(snippet) >= 1"
|
||||||
|
class="snippet-size"
|
||||||
|
x-text="getSize(snippet).toFixed(0) + ' KB'"></span>
|
||||||
|
<div class="snippet-status"
|
||||||
|
:class="hasDraft(snippet) ? 'draft' : 'published'"></div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="placeholder">
|
<div class="placeholder"
|
||||||
|
x-show="filteredSnippets.length === 0"
|
||||||
|
x-text="searchQuery.trim() ? 'No snippets match your search' : 'No snippets found'">
|
||||||
Click to select a snippet
|
Click to select a snippet
|
||||||
</div>
|
</div>
|
||||||
<div class="snippet-meta" id="snippet-meta" style="display: none;">
|
<div class="snippet-meta" id="snippet-meta" style="display: none;">
|
||||||
|
|||||||
1154
project-docs/alpine-migration-plan.md
Normal file
1154
project-docs/alpine-migration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,12 +34,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
// Initialize snippet storage and render list (async)
|
// Initialize snippet storage and render list (async)
|
||||||
initializeSnippetsStorage().then(() => {
|
initializeSnippetsStorage().then(() => {
|
||||||
// Initialize sort controls
|
// Initialize sort controls (now handled by Alpine)
|
||||||
initializeSortControls();
|
initializeSortControls();
|
||||||
|
|
||||||
// Initialize search controls
|
// Initialize search controls (now handled by Alpine)
|
||||||
initializeSearchControls();
|
initializeSearchControls();
|
||||||
|
|
||||||
|
// Render snippet list (now handled reactively by Alpine)
|
||||||
renderSnippetList();
|
renderSnippetList();
|
||||||
|
|
||||||
// Update storage monitor
|
// Update storage monitor
|
||||||
|
|||||||
@@ -1,5 +1,77 @@
|
|||||||
// Snippet management and localStorage functionality
|
// Snippet management and localStorage functionality
|
||||||
|
|
||||||
|
// Alpine.js Store for UI state only (selection tracking)
|
||||||
|
// Business logic stays in SnippetStorage
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.store('snippets', {
|
||||||
|
currentSnippetId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alpine.js Component for snippet list
|
||||||
|
// Thin wrapper around SnippetStorage - Alpine handles reactivity, storage handles logic
|
||||||
|
function snippetList() {
|
||||||
|
return {
|
||||||
|
searchQuery: '',
|
||||||
|
sortBy: AppSettings.get('sortBy') || 'modified',
|
||||||
|
sortOrder: AppSettings.get('sortOrder') || 'desc',
|
||||||
|
|
||||||
|
// Computed property: calls SnippetStorage with current filters/sort
|
||||||
|
get filteredSnippets() {
|
||||||
|
return SnippetStorage.listSnippets(
|
||||||
|
this.sortBy,
|
||||||
|
this.sortOrder,
|
||||||
|
this.searchQuery
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSort(sortType) {
|
||||||
|
if (this.sortBy === sortType) {
|
||||||
|
// Toggle order
|
||||||
|
this.sortOrder = this.sortOrder === 'desc' ? 'asc' : 'desc';
|
||||||
|
} else {
|
||||||
|
// Switch to new sort type with desc order
|
||||||
|
this.sortBy = sortType;
|
||||||
|
this.sortOrder = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to settings
|
||||||
|
AppSettings.set('sortBy', this.sortBy);
|
||||||
|
AppSettings.set('sortOrder', this.sortOrder);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchQuery = '';
|
||||||
|
const searchInput = document.getElementById('snippet-search');
|
||||||
|
if (searchInput) searchInput.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper methods for display
|
||||||
|
formatDate(snippet) {
|
||||||
|
const date = this.sortBy === 'created' ? snippet.created : snippet.modified;
|
||||||
|
return formatSnippetDate(date);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSize(snippet) {
|
||||||
|
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
|
||||||
|
return snippetSize / 1024; // KB
|
||||||
|
},
|
||||||
|
|
||||||
|
hasDraft(snippet) {
|
||||||
|
return JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
selectSnippet(snippetId) {
|
||||||
|
window.selectSnippet(snippetId);
|
||||||
|
},
|
||||||
|
|
||||||
|
createNewSnippet() {
|
||||||
|
window.createNewSnippet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Storage limits (5MB in bytes)
|
// Storage limits (5MB in bytes)
|
||||||
const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
|
const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -322,209 +394,21 @@ function formatFullDate(isoString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render snippet list in the UI
|
// Render snippet list in the UI
|
||||||
|
// With Alpine.js, the list is reactive - no manual rendering needed
|
||||||
|
// This function kept as no-op for backwards compatibility
|
||||||
function renderSnippetList(searchQuery = null) {
|
function renderSnippetList(searchQuery = null) {
|
||||||
// Get search query from input if not provided
|
// Alpine.js handles rendering automatically via reactive bindings
|
||||||
if (searchQuery === null) {
|
|
||||||
const searchInput = document.getElementById('snippet-search');
|
|
||||||
searchQuery = searchInput ? searchInput.value : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippets = SnippetStorage.listSnippets(null, null, searchQuery);
|
|
||||||
const placeholder = document.querySelector('.placeholder');
|
|
||||||
|
|
||||||
// Handle empty state with placeholder
|
|
||||||
if (snippets.length === 0) {
|
|
||||||
document.querySelector('.snippet-list').innerHTML = '';
|
|
||||||
placeholder.style.display = 'block';
|
|
||||||
placeholder.textContent = searchQuery && searchQuery.trim()
|
|
||||||
? 'No snippets match your search'
|
|
||||||
: 'No snippets found';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
placeholder.style.display = 'none';
|
|
||||||
|
|
||||||
const currentSort = AppSettings.get('sortBy');
|
|
||||||
|
|
||||||
// Format individual snippet items
|
|
||||||
const formatSnippetItem = (snippet) => {
|
|
||||||
// Show appropriate date based on current sort
|
|
||||||
const dateText = currentSort === 'created'
|
|
||||||
? formatSnippetDate(snippet.created)
|
|
||||||
: formatSnippetDate(snippet.modified);
|
|
||||||
|
|
||||||
// Calculate snippet size
|
|
||||||
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
|
|
||||||
const sizeKB = snippetSize / 1024;
|
|
||||||
const sizeHTML = sizeKB >= 1 ? `<span class="snippet-size">${sizeKB.toFixed(0)} KB</span>` : '';
|
|
||||||
|
|
||||||
// Determine status: green if no draft changes, yellow if has draft
|
|
||||||
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
|
|
||||||
const statusClass = hasDraft ? 'draft' : 'published';
|
|
||||||
|
|
||||||
// Check if snippet uses external datasets
|
|
||||||
const usesDatasets = snippet.datasetRefs && snippet.datasetRefs.length > 0;
|
|
||||||
const datasetIconHTML = usesDatasets ? '<span class="snippet-dataset-icon" title="Uses external dataset">📁</span>' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<li class="snippet-item" data-item-id="${snippet.id}">
|
|
||||||
<div class="snippet-info">
|
|
||||||
<div class="snippet-name">${snippet.name}${datasetIconHTML}</div>
|
|
||||||
<div class="snippet-date">${dateText}</div>
|
|
||||||
</div>
|
|
||||||
${sizeHTML}
|
|
||||||
<div class="snippet-status ${statusClass}"></div>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ghost card for creating new snippets
|
|
||||||
const ghostCard = `
|
|
||||||
<li class="snippet-item ghost-card" id="new-snippet-card">
|
|
||||||
<div class="snippet-name">+ Create New Snippet</div>
|
|
||||||
<div class="snippet-date">Click to create</div>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Use generic list renderer
|
|
||||||
renderGenericList('snippet-list', snippets, formatSnippetItem, selectSnippet, {
|
|
||||||
ghostCard: ghostCard,
|
|
||||||
onGhostCardClick: createNewSnippet,
|
|
||||||
itemSelector: '.snippet-item'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sort controls
|
// Initialize sort controls
|
||||||
|
// NOTE: Alpine.js now handles all sort/search controls via directives
|
||||||
|
// These functions kept as no-ops for backwards compatibility with app.js
|
||||||
function initializeSortControls() {
|
function initializeSortControls() {
|
||||||
const sortButtons = document.querySelectorAll('.sort-btn');
|
// Alpine.js handles this
|
||||||
const currentSort = AppSettings.get('sortBy');
|
|
||||||
const currentOrder = AppSettings.get('sortOrder');
|
|
||||||
|
|
||||||
// Update active button and arrow based on settings
|
|
||||||
sortButtons.forEach(button => {
|
|
||||||
button.classList.remove('active');
|
|
||||||
if (button.dataset.sort === currentSort) {
|
|
||||||
button.classList.add('active');
|
|
||||||
updateSortArrow(button, currentOrder);
|
|
||||||
} else {
|
|
||||||
updateSortArrow(button, 'desc'); // Default to desc for inactive buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add click handler
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const sortType = this.dataset.sort;
|
|
||||||
toggleSort(sortType);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sort arrow display
|
|
||||||
function updateSortArrow(button, direction) {
|
|
||||||
const arrow = button.querySelector('.sort-arrow');
|
|
||||||
if (arrow) {
|
|
||||||
arrow.textContent = direction === 'desc' ? '⬇' : '⬆';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle sort method and direction
|
|
||||||
function toggleSort(sortType) {
|
|
||||||
const currentSort = AppSettings.get('sortBy');
|
|
||||||
const currentOrder = AppSettings.get('sortOrder');
|
|
||||||
|
|
||||||
let newOrder;
|
|
||||||
if (currentSort === sortType) {
|
|
||||||
// Same button clicked - toggle direction
|
|
||||||
newOrder = currentOrder === 'desc' ? 'asc' : 'desc';
|
|
||||||
} else {
|
|
||||||
// Different button clicked - default to desc
|
|
||||||
newOrder = 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to settings
|
|
||||||
AppSettings.set('sortBy', sortType);
|
|
||||||
AppSettings.set('sortOrder', newOrder);
|
|
||||||
|
|
||||||
// Update button states and arrows
|
|
||||||
document.querySelectorAll('.sort-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
if (btn.dataset.sort === sortType) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
updateSortArrow(btn, newOrder);
|
|
||||||
} else {
|
|
||||||
updateSortArrow(btn, 'desc'); // Default for inactive buttons
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-render list
|
|
||||||
renderSnippetList();
|
|
||||||
|
|
||||||
// Restore selection if there was one
|
|
||||||
restoreSnippetSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize search controls
|
|
||||||
function initializeSearchControls() {
|
function initializeSearchControls() {
|
||||||
const searchInput = document.getElementById('snippet-search');
|
// Alpine.js handles this
|
||||||
const clearButton = document.getElementById('search-clear');
|
|
||||||
|
|
||||||
if (searchInput) {
|
|
||||||
// Debounced search on input
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener('input', function() {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
performSearch();
|
|
||||||
}, 300); // 300ms debounce
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update clear button state
|
|
||||||
searchInput.addEventListener('input', updateClearButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clearButton) {
|
|
||||||
clearButton.addEventListener('click', clearSearch);
|
|
||||||
// Initialize clear button state
|
|
||||||
updateClearButton();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform search and update display
|
|
||||||
function performSearch() {
|
|
||||||
const searchInput = document.getElementById('snippet-search');
|
|
||||||
if (!searchInput) return;
|
|
||||||
|
|
||||||
renderSnippetList(searchInput.value);
|
|
||||||
|
|
||||||
// Clear selection if current snippet is no longer visible
|
|
||||||
if (window.currentSnippetId) {
|
|
||||||
const selectedItem = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`);
|
|
||||||
if (!selectedItem) {
|
|
||||||
clearSelection();
|
|
||||||
} else {
|
|
||||||
selectedItem.classList.add('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
function clearSearch() {
|
|
||||||
const searchInput = document.getElementById('snippet-search');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = '';
|
|
||||||
performSearch();
|
|
||||||
updateClearButton();
|
|
||||||
searchInput.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update clear button state
|
|
||||||
function updateClearButton() {
|
|
||||||
const searchInput = document.getElementById('snippet-search');
|
|
||||||
const clearButton = document.getElementById('search-clear');
|
|
||||||
|
|
||||||
if (clearButton && searchInput) {
|
|
||||||
clearButton.disabled = !searchInput.value.trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Get currently selected snippet
|
// Helper: Get currently selected snippet
|
||||||
@@ -573,13 +457,9 @@ function selectSnippet(snippetId, updateURL = true) {
|
|||||||
const snippet = SnippetStorage.getSnippet(snippetId);
|
const snippet = SnippetStorage.getSnippet(snippetId);
|
||||||
if (!snippet) return;
|
if (!snippet) return;
|
||||||
|
|
||||||
// Update visual selection
|
// Update Alpine store selection for UI highlighting
|
||||||
document.querySelectorAll('.snippet-item').forEach(item => {
|
if (typeof Alpine !== 'undefined' && Alpine.store('snippets')) {
|
||||||
item.classList.remove('selected');
|
Alpine.store('snippets').currentSnippetId = snippetId;
|
||||||
});
|
|
||||||
const selectedItem = document.querySelector(`[data-item-id="${snippetId}"]`);
|
|
||||||
if (selectedItem) {
|
|
||||||
selectedItem.classList.add('selected');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load spec based on current view mode
|
// Load spec based on current view mode
|
||||||
|
|||||||
Reference in New Issue
Block a user