Add asset pipeline and professionalize theme
CI / build (push) Failing after 6m2s

Introduce a Node build system for minified CSS/JS with content hashes,
add PWA support, i18n helpers, accessibility improvements, and more
translatable strings. Move theme configuration from bookmarks to
localStorage, add Playwright tests, and set up CI workflows.
This commit is contained in:
2026-08-09 09:25:03 -04:00
parent 10af62dd6c
commit f753b27542
68 changed files with 4743 additions and 1989 deletions
+275
View File
@@ -0,0 +1,275 @@
// =====================================================================
// Bulk actions (multi-select) for Shaarli Pro.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
const selectModeBtn = document.getElementById('select-mode-btn');
const bulkActionsBar = document.getElementById('bulk-actions-bar');
const bulkCount = document.getElementById('bulk-count');
const bulkSelectAll = document.getElementById('bulk-select-all');
const bulkCancel = document.getElementById('bulk-cancel');
const bulkDelete = document.getElementById('bulk-delete');
const bulkPublic = document.getElementById('bulk-public');
const bulkPrivate = document.getElementById('bulk-private');
const bulkModal = document.getElementById('bulk-confirm-modal');
const bulkModalTitle = document.getElementById('bulk-modal-title');
const bulkModalSubtitle = document.getElementById('bulk-modal-subtitle');
const bulkModalList = document.getElementById('bulk-modal-list');
const bulkModalConfirm = document.getElementById('bulk-modal-confirm');
const bulkModalCancel = document.getElementById('bulk-modal-cancel');
const bulkModalClose = document.getElementById('bulk-modal-close');
let selectionMode = false;
let selectedIds = new Set();
let pendingBulkAction = null;
let bulkFocusCleanup = null;
const a11y = window.ShaarliProA11y || { focusFirst: function () {}, trapFocus: function () { return function () {}; } };
const BULK_ACTION_COPY = {
delete: {
title: (count) => i18n.t('Are you sure to delete {count} link{plural}?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be irretrievably deleted:'),
confirm: (count) => i18n.t('DELETE {count} LINK{plural}', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-delete'
},
public: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} public?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as public:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PUBLIC', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-public'
},
private: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} private?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as private:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PRIVATE', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-private'
}
};
function updateBulkUI() {
if (bulkCount) {
bulkCount.textContent = selectedIds.size;
}
document.querySelectorAll('.link-outer').forEach(card => {
const id = card.dataset.id;
if (selectedIds.has(id)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
document.querySelectorAll('.link-checkbox').forEach(cb => {
cb.checked = selectedIds.has(cb.dataset.id);
});
}
function enterSelectionMode() {
selectionMode = true;
document.body.classList.add('selection-mode');
bulkActionsBar?.classList.add('show');
selectModeBtn?.classList.add('active');
}
function exitSelectionMode() {
selectionMode = false;
selectedIds.clear();
document.body.classList.remove('selection-mode');
bulkActionsBar?.classList.remove('show');
selectModeBtn?.classList.remove('active');
updateBulkUI();
}
function toggleSelection(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
if (selectedIds.size > 0 && !selectionMode) {
enterSelectionMode();
} else if (selectedIds.size === 0 && selectionMode) {
exitSelectionMode();
}
updateBulkUI();
}
selectModeBtn?.addEventListener('click', () => {
if (selectionMode) {
exitSelectionMode();
} else {
enterSelectionMode();
}
});
bulkCancel?.addEventListener('click', exitSelectionMode);
bulkSelectAll?.addEventListener('click', () => {
document.querySelectorAll('.link-outer').forEach(card => {
if (card.dataset.id) {
selectedIds.add(card.dataset.id);
}
});
if (!selectionMode && selectedIds.size > 0) {
enterSelectionMode();
}
updateBulkUI();
});
document.addEventListener('change', (e) => {
if (e.target.classList.contains('link-checkbox')) {
toggleSelection(e.target.dataset.id);
}
});
document.addEventListener('click', (e) => {
if (!selectionMode) return;
const card = e.target.closest('.link-outer');
if (card && card.dataset.id) {
if (e.target.closest('.link-actions') ||
e.target.closest('.link-hover-actions') ||
e.target.tagName === 'A' ||
e.target.tagName === 'INPUT' ||
e.target.closest('label')) {
return;
}
toggleSelection(card.dataset.id);
}
});
function getSelectedIds() {
return Array.from(selectedIds);
}
function populateBulkModalList(ids) {
if (!bulkModalList) return;
bulkModalList.innerHTML = '';
ids.forEach((id) => {
const card = document.querySelector(`.link-outer[data-id="${id}"]`);
const title = card?.querySelector('.link-title')?.textContent?.trim() ||
card?.querySelector('.link-url')?.textContent?.trim() ||
i18n.t('Untitled link');
const item = document.createElement('div');
item.className = 'bulk-modal-item';
const idSpan = document.createElement('span');
idSpan.className = 'bulk-modal-item-id';
idSpan.textContent = `#${id}`;
const titleSpan = document.createElement('span');
titleSpan.className = 'bulk-modal-item-title';
titleSpan.textContent = title;
item.appendChild(idSpan);
item.appendChild(titleSpan);
bulkModalList.appendChild(item);
});
}
function openBulkModal(actionType) {
if (!bulkModal || selectedIds.size === 0) return;
const meta = BULK_ACTION_COPY[actionType];
if (!meta) return;
const ids = getSelectedIds();
pendingBulkAction = actionType;
if (bulkModalTitle) {
bulkModalTitle.textContent = meta.title(ids.length);
}
if (bulkModalSubtitle) {
bulkModalSubtitle.textContent = meta.subtitle;
}
if (bulkModalConfirm) {
bulkModalConfirm.textContent = meta.confirm(ids.length);
bulkModalConfirm.className = `bulk-modal-btn bulk-modal-confirm ${meta.confirmClass}`;
}
populateBulkModalList(ids);
bulkModal.classList.add('show');
bulkModal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
a11y.focusFirst(bulkModal);
bulkFocusCleanup = a11y.trapFocus(bulkModal);
}
function closeBulkModal() {
if (!bulkModal) return;
pendingBulkAction = null;
bulkModal.classList.remove('show');
bulkModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (typeof bulkFocusCleanup === 'function') {
bulkFocusCleanup();
bulkFocusCleanup = null;
}
}
function buildIdsParam(ids) {
return ids.map(id => encodeURIComponent(id)).join('+');
}
function executeBulkAction() {
if (!pendingBulkAction || selectedIds.size === 0) return;
const ids = getSelectedIds();
const token = (window.shaarli?.token || '').trim() ||
document.querySelector('input[name="token"]')?.value || '';
if (!token) {
alert(i18n.t('Missing security token. Please refresh the page and try again.'));
return;
}
const basePath = window.shaarli?.basePath || '';
const idsParam = buildIdsParam(ids);
let targetUrl = '';
if (pendingBulkAction === 'delete') {
targetUrl = `${basePath}/admin/shaare/delete?id=${idsParam}&token=${encodeURIComponent(token)}`;
} else {
const visibility = pendingBulkAction === 'public' ? 'public' : 'private';
targetUrl = `${basePath}/admin/shaare/visibility?token=${encodeURIComponent(token)}&newVisibility=${visibility}&id=${idsParam}`;
}
closeBulkModal();
window.location.href = targetUrl;
}
bulkDelete?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('delete');
});
bulkPublic?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('public');
});
bulkPrivate?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('private');
});
bulkModalConfirm?.addEventListener('click', executeBulkAction);
bulkModalCancel?.addEventListener('click', closeBulkModal);
bulkModalClose?.addEventListener('click', closeBulkModal);
bulkModal?.addEventListener('click', (event) => {
if (event.target === bulkModal) {
closeBulkModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && bulkModal?.classList.contains('show')) {
closeBulkModal();
}
});
});