fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -0,0 +1,468 @@
/**
* Bulk Operations Module for Admin Tables
*
* Handles bulk selection and actions for admin data tables
*/
export class BulkOperations {
constructor(tableElement) {
this.table = tableElement;
this.resource = tableElement.dataset.resource;
this.bulkActions = JSON.parse(tableElement.dataset.bulkActions || '[]');
this.selectedIds = new Set();
this.toolbar = document.querySelector(`[data-bulk-toolbar="${this.resource}"]`);
this.countElement = this.toolbar?.querySelector('[data-bulk-count]');
this.buttonsContainer = this.toolbar?.querySelector('[data-bulk-buttons]');
this.init();
}
init() {
if (!this.table.dataset.bulkOperations || this.table.dataset.bulkOperations !== 'true') {
return; // Bulk operations not enabled for this table
}
this.setupCheckboxes();
this.setupSelectAll();
this.setupBulkActions();
this.updateToolbar();
}
setupCheckboxes() {
const checkboxes = this.table.querySelectorAll('.bulk-select-item');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const id = e.target.dataset.bulkItemId;
if (e.target.checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
this.updateToolbar();
this.updateSelectAllState();
});
});
}
setupSelectAll() {
const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]');
if (!selectAllCheckbox) return;
selectAllCheckbox.addEventListener('change', (e) => {
const checked = e.target.checked;
const checkboxes = this.table.querySelectorAll('.bulk-select-item');
checkboxes.forEach(checkbox => {
checkbox.checked = checked;
const id = checkbox.dataset.bulkItemId;
if (checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
});
this.updateToolbar();
});
}
setupBulkActions() {
if (!this.buttonsContainer || this.bulkActions.length === 0) {
return;
}
// Clear existing buttons
this.buttonsContainer.innerHTML = '';
// Add buttons for each bulk action
this.bulkActions.forEach(action => {
const button = document.createElement('button');
button.className = `btn btn--${action.style || 'secondary'} btn--sm`;
button.textContent = action.label;
button.dataset.bulkAction = action.action;
button.dataset.bulkMethod = action.method || 'POST';
if (action.confirm) {
button.dataset.bulkConfirm = action.confirm;
}
button.addEventListener('click', (e) => {
e.preventDefault();
this.executeBulkAction(action);
});
this.buttonsContainer.appendChild(button);
});
}
async executeBulkAction(action) {
if (this.selectedIds.size === 0) {
return;
}
// Handle special actions that need custom modals
if (action.action === 'tag' && action.confirm === false) {
return this.showBulkTagModal(action);
}
// Show confirmation dialog if needed
if (action.confirm) {
const confirmed = await this.showConfirmationDialog(
action.confirm,
`This will affect ${this.selectedIds.size} item(s).`
);
if (!confirmed) {
return;
}
}
try {
// Show loading state
this.setLoadingState(true);
// Optimistic update: hide selected rows immediately
const selectedRows = Array.from(this.selectedIds).map(id => {
return this.table.querySelector(`tr[data-id="${id}"]`);
}).filter(Boolean);
const rollbackRows = selectedRows.map(row => {
const originalDisplay = row.style.display;
row.style.display = 'none';
row.classList.add('optimistic-delete');
return () => {
row.style.display = originalDisplay;
row.classList.remove('optimistic-delete');
};
});
const ids = Array.from(this.selectedIds);
const method = action.method || 'POST';
// Map action names to endpoints
const endpointMap = {
'assets': {
'delete': '/api/v1/assets/bulk/delete',
'tag': '/api/v1/assets/bulk/tags',
},
'contents': {
'delete': '/admin/api/contents/bulk/delete',
'publish': '/admin/api/contents/bulk/publish',
},
};
const endpoint = action.endpoint || endpointMap[this.resource]?.[action.action] || `/admin/api/${this.resource}/bulk/${action.action}`;
// Use safeFetch if available, otherwise regular fetch
const fetchFn = window.safeFetch || (async (url, options) => {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return await response.json();
});
// Prepare request body
let requestBody = { ids: Array.from(this.selectedIds) };
// Add tags if this is a tag action
if (action.action === 'tag' && action.tags) {
requestBody.tags = action.tags;
}
const result = await fetchFn(endpoint, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(requestBody),
});
// Show success message
const successCount = result.total_deleted || result.total_published || result.total_tagged || ids.length;
const actionLabel = action.action === 'tag' ? 'tagged' : action.label.toLowerCase();
this.showToast(`Successfully ${actionLabel} ${successCount} item(s)`, 'success');
// Clear selection
this.clearSelection();
// Reload table data if table has reload method
const tableInstance = this.table.adminDataTable;
if (tableInstance && typeof tableInstance.loadData === 'function') {
tableInstance.loadData();
} else {
// Fallback: reload page
window.location.reload();
}
} catch (error) {
console.error('Bulk action failed:', error);
// Rollback optimistic updates
if (window.rollbackRows) {
window.rollbackRows.forEach(rollback => rollback());
}
// Show error with retry option
const errorMessage = error.message || 'Unknown error occurred';
this.showToast(`Failed to ${action.label.toLowerCase()}: ${errorMessage}`, 'error');
// Show retry button
if (this.toolbar) {
const retryButton = document.createElement('button');
retryButton.className = 'retry-button';
retryButton.textContent = 'Retry';
retryButton.onclick = () => {
retryButton.remove();
this.executeBulkAction(action);
};
this.buttonsContainer?.appendChild(retryButton);
}
} finally {
this.setLoadingState(false);
}
}
updateToolbar() {
if (!this.toolbar) return;
const count = this.selectedIds.size;
if (count > 0) {
this.toolbar.style.display = 'flex';
if (this.countElement) {
this.countElement.textContent = count;
}
} else {
this.toolbar.style.display = 'none';
}
}
updateSelectAllState() {
const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]');
if (!selectAllCheckbox) return;
const checkboxes = this.table.querySelectorAll('.bulk-select-item');
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === checkboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
clearSelection() {
this.selectedIds.clear();
const checkboxes = this.table.querySelectorAll('.bulk-select-item');
checkboxes.forEach(checkbox => {
checkbox.checked = false;
});
const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
this.updateToolbar();
}
setLoadingState(loading) {
const buttons = this.buttonsContainer?.querySelectorAll('button');
if (buttons) {
buttons.forEach(button => {
button.disabled = loading;
if (loading) {
button.dataset.originalText = button.textContent;
button.textContent = 'Processing...';
} else {
button.textContent = button.dataset.originalText || button.textContent;
}
});
}
}
async showConfirmationDialog(title, message) {
return new Promise((resolve) => {
const dialog = document.createElement('div');
dialog.className = 'bulk-confirm-dialog';
dialog.innerHTML = `
<div class="bulk-confirm-dialog__overlay"></div>
<div class="bulk-confirm-dialog__content">
<h3>${title}</h3>
<p>${message}</p>
<div class="bulk-confirm-dialog__actions">
<button class="btn btn--secondary" data-confirm-cancel>Cancel</button>
<button class="btn btn--danger" data-confirm-ok>Confirm</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const cleanup = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('[data-confirm-ok]').addEventListener('click', () => {
cleanup();
resolve(true);
});
dialog.querySelector('[data-confirm-cancel]').addEventListener('click', () => {
cleanup();
resolve(false);
});
dialog.querySelector('.bulk-confirm-dialog__overlay').addEventListener('click', () => {
cleanup();
resolve(false);
});
});
}
showToast(message, type = 'info') {
// Use existing toast system if available
if (window.Toast) {
window.Toast[type](message);
} else {
// Fallback: simple alert
alert(message);
}
}
async showBulkTagModal(action) {
// Create or get modal
let modal = document.getElementById('bulk-tag-modal');
if (!modal) {
// Load modal HTML (should be included in page)
modal = document.createElement('div');
modal.id = 'bulk-tag-modal';
modal.innerHTML = await fetch('/admin/templates/bulk-tag-modal').then(r => r.text()).catch(() => '');
document.body.appendChild(modal);
}
// Update count
const countElement = modal.querySelector('#bulk-tag-count');
if (countElement) {
countElement.textContent = this.selectedIds.size;
}
// Show modal
modal.style.display = 'block';
// Setup tag input with autocomplete
const tagInput = modal.querySelector('#bulk-tag-input');
const suggestionsContainer = modal.querySelector('#bulk-tag-suggestions');
const suggestionsList = modal.querySelector('.bulk-tag-suggestions-list');
let tagSuggestions = [];
// Load tag suggestions
const loadSuggestions = async (search = '') => {
try {
const response = await fetch(`/api/v1/tags/suggestions${search ? '?search=' + encodeURIComponent(search) : ''}`);
const data = await response.json();
tagSuggestions = data.tags || [];
if (tagSuggestions.length > 0) {
suggestionsContainer.style.display = 'block';
suggestionsList.innerHTML = tagSuggestions.slice(0, 10).map(tag =>
`<button type="button" class="bulk-tag-suggestion" data-tag="${tag}">${tag}</button>`
).join('');
// Add click handlers to suggestions
suggestionsList.querySelectorAll('.bulk-tag-suggestion').forEach(btn => {
btn.addEventListener('click', () => {
const tag = btn.dataset.tag;
const currentTags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean);
if (!currentTags.includes(tag)) {
currentTags.push(tag);
tagInput.value = currentTags.join(', ');
}
});
});
} else {
suggestionsContainer.style.display = 'none';
}
} catch (error) {
console.error('Failed to load tag suggestions:', error);
}
};
// Load initial suggestions
await loadSuggestions();
// Setup input listener for suggestions
if (tagInput) {
let debounceTimer;
tagInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const search = e.target.value.split(',').pop().trim();
if (search.length > 1) {
loadSuggestions(search);
} else {
loadSuggestions();
}
}, 300);
});
}
// Setup submit handler
const submitButton = modal.querySelector('#bulk-tag-submit');
const closeModal = () => {
modal.style.display = 'none';
if (tagInput) tagInput.value = '';
suggestionsContainer.style.display = 'none';
};
submitButton?.addEventListener('click', async () => {
const tagsInput = tagInput?.value.trim();
if (!tagsInput) {
this.showToast('Please enter at least one tag', 'error');
return;
}
const tags = tagsInput.split(',').map(t => t.trim()).filter(Boolean);
// Execute tag action with tags
const tagAction = { ...action, tags };
closeModal();
await this.executeBulkAction(tagAction);
});
// Setup close handlers
modal.querySelectorAll('[data-close-modal]').forEach(el => {
el.addEventListener('click', closeModal);
});
}
}
// Auto-initialize bulk operations for tables with bulk operations enabled
document.addEventListener('DOMContentLoaded', () => {
// Initialize immediately
document.querySelectorAll('[data-bulk-operations="true"]').forEach(table => {
new BulkOperations(table);
});
// Also initialize after a short delay to catch dynamically loaded tables
setTimeout(() => {
document.querySelectorAll('[data-bulk-operations="true"]').forEach(table => {
// Check if already initialized
if (!table.dataset.bulkInitialized) {
table.dataset.bulkInitialized = 'true';
new BulkOperations(table);
}
});
}, 100);
});