// Generic Storage UI Utilities
// Provides reusable patterns for list management, item selection, and linked item display
// Used by both snippet-manager.js and dataset-manager.js
/**
* Generic list rendering function with support for ghost cards and custom selectors
* @param {string} containerId - ID of container to render list into
* @param {Array} items - Array of items to render
* @param {Function} formatItem - Function that takes item and returns HTML string
* @param {Function} onSelectItem - Callback when item is selected, receives item ID
* @param {Object} options - Optional configuration
* - emptyMessage: Message when list is empty
* - ghostCard: HTML string for "create new" card (prepended to list)
* - onGhostCardClick: Callback for ghost card click
* - itemSelector: CSS selector for clickable items (default: '[data-item-id]')
* - ghostCardSelector: CSS selector for ghost card (default: '.ghost-card')
* - parseId: Function to parse ID from string (default: parseFloat)
*/
function renderGenericList(containerId, items, formatItem, onSelectItem, options = {}) {
const {
emptyMessage = 'No items found',
ghostCard = null,
onGhostCardClick = null,
itemSelector = '[data-item-id]',
ghostCardSelector = '.ghost-card',
parseId = parseFloat
} = options;
const container = document.getElementById(containerId);
if (!container) return;
if (items.length === 0 && !ghostCard) {
container.innerHTML = `
${emptyMessage}
`;
return;
}
// Render ghost card + items
const itemsHtml = items.map(formatItem).join('');
container.innerHTML = (ghostCard || '') + itemsHtml;
// Attach click handler to ghost card
if (ghostCard && onGhostCardClick) {
const ghostElement = container.querySelector(ghostCardSelector);
if (ghostElement) {
ghostElement.addEventListener('click', onGhostCardClick);
}
}
// Attach click handlers to regular items
container.querySelectorAll(itemSelector).forEach(item => {
// Skip ghost cards
if (item.matches(ghostCardSelector)) return;
item.addEventListener('click', function() {
const itemId = parseId(this.dataset.itemId);
onSelectItem(itemId);
});
});
}
/**
* Generic item selector function
* Updates visual selection and populates details panel
* @param {string|number} itemId - ID of item to select
* @param {string} itemContainerSelector - CSS selector for item elements (e.g., '.snippet-item')
* @param {string} dataAttributeName - Name of data attribute holding ID (e.g., 'snippetId', 'datasetId')
*/
function selectGenericItem(itemId, itemContainerSelector, dataAttributeName) {
// Clear previous selection
document.querySelectorAll(itemContainerSelector).forEach(item => {
item.classList.remove('selected');
});
// Set new selection
const selector = `${itemContainerSelector}[data-${dataAttributeName}="${itemId}"]`;
const selectedItem = document.querySelector(selector);
if (selectedItem) {
selectedItem.classList.add('selected');
}
}
/**
* Generic linked items viewer
* Displays items that reference the current item
* @param {Array} linkedItems - Array of items to display
* @param {string} containerId - ID of container for the list
* @param {string} sectionId - ID of section wrapper (shown/hidden based on count)
* @param {Function} formatLinkedItem - Function that returns HTML for each linked item
* @param {Function} onLinkedItemClick - Callback when a linked item is clicked
*/
function updateGenericLinkedItems(linkedItems, containerId, sectionId, formatLinkedItem, onLinkedItemClick) {
const section = document.getElementById(sectionId);
const container = document.getElementById(containerId);
if (!section || !container) return;
if (linkedItems.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
const html = linkedItems.map(formatLinkedItem).join('');
container.innerHTML = html;
// Attach click handlers
container.querySelectorAll('[data-linked-item-id]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const linkedItemId = this.dataset.linkedItemId;
onLinkedItemClick(linkedItemId);
});
});
}
/**
* Generic modal helper
* @param {string} modalId - ID of modal element
* @param {boolean} show - Whether to show (true) or hide (false)
* @param {Function} onShow - Optional callback when showing
* @param {Function} onHide - Optional callback when hiding
*/
function toggleGenericModal(modalId, show, onShow, onHide) {
const modal = document.getElementById(modalId);
if (!modal) return;
if (show) {
modal.style.display = 'flex';
if (onShow) onShow();
} else {
modal.style.display = 'none';
if (onHide) onHide();
}
}
/**
* Generic form show/hide helper
* @param {string} viewId - ID of view/form container
* @param {boolean} show - Whether to show (true) or hide (false)
* @param {Function} onShow - Optional callback when showing
* @param {Function} onHide - Optional callback when hiding
*/
function toggleGenericForm(viewId, show, onShow, onHide) {
const view = document.getElementById(viewId);
if (!view) return;
if (show) {
view.style.display = show === 'block' ? 'block' : 'flex';
if (onShow) onShow();
} else {
view.style.display = 'none';
if (onHide) onHide();
}
}
/**
* Generic element visibility toggler
* @param {string} elementId - ID of element to toggle
* @param {boolean} show - Whether to show or hide
*/
function setElementVisibility(elementId, show) {
const element = document.getElementById(elementId);
if (element) {
element.style.display = show ? 'block' : 'none';
}
}
/**
* Generic field populator for details panel
* @param {Object} data - Object with field data
* @param {Array} fieldMappings - Array of {fieldId, value} objects
*/
function populateDetailsPanel(data, fieldMappings) {
fieldMappings.forEach(({ fieldId, getValue }) => {
const element = document.getElementById(fieldId);
if (element) {
element.value = getValue ? getValue(data) : '';
}
});
}
/**
* Generic item deletion with confirmation
* @param {string} itemName - Name of item for confirmation message
* @param {string} warningMessage - Optional warning if item is in use
* @param {Function} onConfirm - Callback if user confirms deletion
* @returns {boolean} - Whether deletion was confirmed
*/
function confirmGenericDeletion(itemName, warningMessage = null, onConfirm) {
let message = `Delete "${itemName}"?`;
if (warningMessage) {
message = `⚠️ ${warningMessage}\n\nAre you sure you want to delete it?`;
} else {
message += ' This action cannot be undone.';
}
if (confirm(message)) {
if (onConfirm) onConfirm();
return true;
}
return false;
}
/**
* Generic file download helper
* @param {string|Blob} content - Content to download
* @param {string} filename - Filename for download
* @param {string} mimeType - MIME type (default: 'text/plain')
*/
function downloadFile(content, filename, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Clear file input helper
* @param {string} inputId - ID of file input element
*/
function clearFileInput(inputId) {
const input = document.getElementById(inputId);
if (input) {
input.value = '';
}
}
/**
* Generic text field update helper with debounce
* @param {string} fieldId - ID of input field
* @param {Function} onUpdate - Callback with new value
* @param {number} debounceMs - Debounce delay in milliseconds
*/
function setupDebouncedFieldUpdate(fieldId, onUpdate, debounceMs = 1000) {
const field = document.getElementById(fieldId);
if (!field) return;
let timeout;
field.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(() => {
onUpdate(this.value);
}, debounceMs);
});
}
/**
* Generic toggle button state updater
* @param {Array} buttons - Array of {id, isActive} objects
*/
function updateToggleButtons(buttons) {
buttons.forEach(({ id, isActive }) => {
const button = document.getElementById(id);
if (button) {
if (isActive) {
button.classList.add('active');
} else {
button.classList.remove('active');
}
}
});
}
/**
* Generic URL state updater with fallback
* Only updates if URLState global is available
* @param {Object} state - State object to pass to URLState.update
*/
function updateURLStateIfAvailable(state) {
if (typeof URLState !== 'undefined' && URLState.update) {
URLState.update(state);
}
}
/**
* Generic analytics tracker with fallback
* Only tracks if Analytics global is available
* @param {string} event - Event name
* @param {string} label - Event label
*/
function trackEventIfAvailable(event, label) {
if (typeof Analytics !== 'undefined' && Analytics.track) {
Analytics.track(event, label);
}
}