refactor: alpine.js first step migration

This commit is contained in:
2025-11-24 17:58:13 +02:00
parent aef9a7965a
commit ebdade0c7e
5 changed files with 1305 additions and 218 deletions

View File

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

View File

@@ -25,6 +25,9 @@
<!-- Monaco Editor -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
@@ -65,38 +68,86 @@
<div class="main-panels">
<!-- Snippet Library Panel -->
<div class="panel snippet-panel" id="snippet-panel">
<div class="panel snippet-panel" id="snippet-panel" x-data="snippetList()">
<div class="panel-header">
Snippets
</div>
<div class="sort-controls">
<span class="sort-label">Sort by:</span>
<button class="sort-btn active" data-sort="modified" title="Sort by last modified date">
<button class="sort-btn"
:class="{ 'active': sortBy === 'modified' }"
@click="toggleSort('modified')"
title="Sort by last modified date">
<span class="sort-text">Modified</span>
<span class="sort-arrow"></span>
<span class="sort-arrow" x-text="sortBy === 'modified' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" data-sort="created" title="Sort by creation date">
<button class="sort-btn"
:class="{ 'active': sortBy === 'created' }"
@click="toggleSort('created')"
title="Sort by creation date">
<span class="sort-text">Created</span>
<span class="sort-arrow"></span>
<span class="sort-arrow" x-text="sortBy === 'created' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" data-sort="name" title="Sort alphabetically by name">
<button class="sort-btn"
:class="{ 'active': sortBy === 'name' }"
@click="toggleSort('name')"
title="Sort alphabetically by name">
<span class="sort-text">Name</span>
<span class="sort-arrow"></span>
<span class="sort-arrow" x-text="sortBy === 'name' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" data-sort="size" title="Sort by snippet size">
<button class="sort-btn"
:class="{ 'active': sortBy === 'size' }"
@click="toggleSort('size')"
title="Sort by snippet size">
<span class="sort-text">Size</span>
<span class="sort-arrow"></span>
<span class="sort-arrow" x-text="sortBy === 'size' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
</div>
<div class="search-controls">
<input type="text" id="snippet-search" placeholder="Search snippets..." />
<button class="btn btn-icon" id="search-clear" title="Clear search">×</button>
<input type="text"
id="snippet-search"
x-model="searchQuery"
placeholder="Search snippets..." />
<button class="btn btn-icon"
@click="clearSearch()"
title="Clear search">×</button>
</div>
<div class="panel-content">
<ul class="snippet-list" id="snippet-list">
<!-- Dynamically populated by renderSnippetList() -->
<!-- Ghost card for creating new snippets -->
<li class="snippet-item ghost-card"
id="new-snippet-card"
@click="createNewSnippet()">
<div class="snippet-name">+ Create New Snippet</div>
<div class="snippet-date">Click to create</div>
</li>
<!-- Snippet items -->
<template x-for="snippet in filteredSnippets" :key="snippet.id">
<li class="snippet-item"
:data-item-id="snippet.id"
:class="{ 'selected': $store.snippets.currentSnippetId === snippet.id }"
@click="selectSnippet(snippet.id)">
<div class="snippet-info">
<div class="snippet-name">
<span x-text="snippet.name"></span>
<span x-show="snippet.datasetRefs && snippet.datasetRefs.length > 0"
class="snippet-dataset-icon"
title="Uses external dataset">📁</span>
</div>
<div class="snippet-date" x-text="formatDate(snippet)"></div>
</div>
<span x-show="getSize(snippet) >= 1"
class="snippet-size"
x-text="getSize(snippet).toFixed(0) + ' KB'"></span>
<div class="snippet-status"
:class="hasDraft(snippet) ? 'draft' : 'published'"></div>
</li>
</template>
</ul>
<div class="placeholder">
<div class="placeholder"
x-show="filteredSnippets.length === 0"
x-text="searchQuery.trim() ? 'No snippets match your search' : 'No snippets found'">
Click to select a snippet
</div>
<div class="snippet-meta" id="snippet-meta" style="display: none;">

File diff suppressed because it is too large Load Diff

View File

@@ -34,12 +34,13 @@ document.addEventListener('DOMContentLoaded', function () {
// Initialize snippet storage and render list (async)
initializeSnippetsStorage().then(() => {
// Initialize sort controls
// Initialize sort controls (now handled by Alpine)
initializeSortControls();
// Initialize search controls
// Initialize search controls (now handled by Alpine)
initializeSearchControls();
// Render snippet list (now handled reactively by Alpine)
renderSnippetList();
// Update storage monitor

View File

@@ -1,5 +1,77 @@
// 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)
const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
@@ -322,209 +394,21 @@ function formatFullDate(isoString) {
}
// Render snippet list in the UI
// With Alpine.js, the list is reactive - no manual rendering needed
// This function kept as no-op for backwards compatibility
function renderSnippetList(searchQuery = null) {
// Get search query from input if not provided
if (searchQuery === null) {
const searchInput = document.getElementById('snippet-search');
searchQuery = searchInput ? searchInput.value : '';
}
const snippets = SnippetStorage.listSnippets(null, null, searchQuery);
const placeholder = document.querySelector('.placeholder');
// Handle empty state with placeholder
if (snippets.length === 0) {
document.querySelector('.snippet-list').innerHTML = '';
placeholder.style.display = 'block';
placeholder.textContent = searchQuery && searchQuery.trim()
? 'No snippets match your search'
: 'No snippets found';
return;
}
placeholder.style.display = 'none';
const currentSort = AppSettings.get('sortBy');
// Format individual snippet items
const formatSnippetItem = (snippet) => {
// Show appropriate date based on current sort
const dateText = currentSort === 'created'
? formatSnippetDate(snippet.created)
: formatSnippetDate(snippet.modified);
// Calculate snippet size
const snippetSize = new Blob([JSON.stringify(snippet)]).size;
const sizeKB = snippetSize / 1024;
const sizeHTML = sizeKB >= 1 ? `<span class="snippet-size">${sizeKB.toFixed(0)} KB</span>` : '';
// Determine status: green if no draft changes, yellow if has draft
const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
const statusClass = hasDraft ? 'draft' : 'published';
// Check if snippet uses external datasets
const usesDatasets = snippet.datasetRefs && snippet.datasetRefs.length > 0;
const datasetIconHTML = usesDatasets ? '<span class="snippet-dataset-icon" title="Uses external dataset">📁</span>' : '';
return `
<li class="snippet-item" data-item-id="${snippet.id}">
<div class="snippet-info">
<div class="snippet-name">${snippet.name}${datasetIconHTML}</div>
<div class="snippet-date">${dateText}</div>
</div>
${sizeHTML}
<div class="snippet-status ${statusClass}"></div>
</li>
`;
};
// Ghost card for creating new snippets
const ghostCard = `
<li class="snippet-item ghost-card" id="new-snippet-card">
<div class="snippet-name">+ Create New Snippet</div>
<div class="snippet-date">Click to create</div>
</li>
`;
// Use generic list renderer
renderGenericList('snippet-list', snippets, formatSnippetItem, selectSnippet, {
ghostCard: ghostCard,
onGhostCardClick: createNewSnippet,
itemSelector: '.snippet-item'
});
// Alpine.js handles rendering automatically via reactive bindings
}
// Initialize sort controls
// 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() {
const sortButtons = document.querySelectorAll('.sort-btn');
const currentSort = AppSettings.get('sortBy');
const currentOrder = AppSettings.get('sortOrder');
// Update active button and arrow based on settings
sortButtons.forEach(button => {
button.classList.remove('active');
if (button.dataset.sort === currentSort) {
button.classList.add('active');
updateSortArrow(button, currentOrder);
} else {
updateSortArrow(button, 'desc'); // Default to desc for inactive buttons
}
// Add click handler
button.addEventListener('click', function() {
const sortType = this.dataset.sort;
toggleSort(sortType);
});
});
// Alpine.js handles this
}
// Update sort arrow display
function updateSortArrow(button, direction) {
const arrow = button.querySelector('.sort-arrow');
if (arrow) {
arrow.textContent = direction === 'desc' ? '⬇' : '⬆';
}
}
// Toggle sort method and direction
function toggleSort(sortType) {
const currentSort = AppSettings.get('sortBy');
const currentOrder = AppSettings.get('sortOrder');
let newOrder;
if (currentSort === sortType) {
// Same button clicked - toggle direction
newOrder = currentOrder === 'desc' ? 'asc' : 'desc';
} else {
// Different button clicked - default to desc
newOrder = 'desc';
}
// Save to settings
AppSettings.set('sortBy', sortType);
AppSettings.set('sortOrder', newOrder);
// Update button states and arrows
document.querySelectorAll('.sort-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.sort === sortType) {
btn.classList.add('active');
updateSortArrow(btn, newOrder);
} else {
updateSortArrow(btn, 'desc'); // Default for inactive buttons
}
});
// Re-render list
renderSnippetList();
// Restore selection if there was one
restoreSnippetSelection();
}
// Initialize search controls
function initializeSearchControls() {
const searchInput = document.getElementById('snippet-search');
const clearButton = document.getElementById('search-clear');
if (searchInput) {
// Debounced search on input
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch();
}, 300); // 300ms debounce
});
// Update clear button state
searchInput.addEventListener('input', updateClearButton);
}
if (clearButton) {
clearButton.addEventListener('click', clearSearch);
// Initialize clear button state
updateClearButton();
}
}
// Perform search and update display
function performSearch() {
const searchInput = document.getElementById('snippet-search');
if (!searchInput) return;
renderSnippetList(searchInput.value);
// Clear selection if current snippet is no longer visible
if (window.currentSnippetId) {
const selectedItem = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`);
if (!selectedItem) {
clearSelection();
} else {
selectedItem.classList.add('selected');
}
}
}
// Clear search
function clearSearch() {
const searchInput = document.getElementById('snippet-search');
if (searchInput) {
searchInput.value = '';
performSearch();
updateClearButton();
searchInput.focus();
}
}
// Update clear button state
function updateClearButton() {
const searchInput = document.getElementById('snippet-search');
const clearButton = document.getElementById('search-clear');
if (clearButton && searchInput) {
clearButton.disabled = !searchInput.value.trim();
}
// Alpine.js handles this
}
// Helper: Get currently selected snippet
@@ -573,13 +457,9 @@ function selectSnippet(snippetId, updateURL = true) {
const snippet = SnippetStorage.getSnippet(snippetId);
if (!snippet) return;
// Update visual selection
document.querySelectorAll('.snippet-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`[data-item-id="${snippetId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
// Update Alpine store selection for UI highlighting
if (typeof Alpine !== 'undefined' && Alpine.store('snippets')) {
Alpine.store('snippets').currentSnippetId = snippetId;
}
// Load spec based on current view mode