feat: implémenter modal confirmation actions bulk avec preview liste liens (ID + titre), styles dédiés (max-width 520px, list scrollable 320px, boutons colorés par action delete/public/private), synchronisation mode thème vers bookmark via fetch/POST formulaire, refactoring actions bulk (delete/visibility) avec URLs GET params au lieu de forms POST, ajout token shaarli dans includes.html, correction tag config bookmark (shaarit_config→themes), et support fermeture modal ESC/overlay-click

This commit is contained in:
Bruno Charest 2026-04-20 13:54:33 -04:00
parent 1c469b2329
commit bf1c2c6172
5 changed files with 387 additions and 71 deletions

View File

@ -797,6 +797,105 @@ input:checked+.theme-slider:before {
margin-top: 1.75rem;
}
.bulk-modal {
max-width: 520px;
}
.bulk-modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: var(--text-main);
}
.bulk-modal-header p {
margin: 0.3rem 0 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
.bulk-modal-list {
margin: 1.5rem 0;
border: 1px solid var(--border-light);
border-radius: 0.65rem;
max-height: 320px;
overflow-y: auto;
background: var(--bg-body);
}
.bulk-modal-item {
display: flex;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-light);
font-size: 0.95rem;
align-items: center;
}
.bulk-modal-item:last-child {
border-bottom: none;
}
.bulk-modal-item-id {
font-weight: 600;
color: var(--primary);
min-width: 70px;
}
.bulk-modal-item-title {
color: var(--text-main);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bulk-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.bulk-modal-btn {
min-width: 140px;
padding: 0.75rem 1.2rem;
border-radius: 0.5rem;
border: none;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.bulk-modal-btn:active {
transform: translateY(1px);
}
.bulk-modal-cancel {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.bulk-modal-confirm {
color: #fff;
}
.bulk-modal-confirm.bulk-confirm-delete {
background: var(--danger);
}
.bulk-modal-confirm.bulk-confirm-public {
background: var(--primary);
}
.bulk-modal-confirm.bulk-confirm-private {
background: var(--info);
color: #0f172a;
}
.bulk-modal-btn:hover {
opacity: 0.9;
}
@keyframes fadeIn {
from {
opacity: 0;

View File

@ -10,10 +10,22 @@
<!-- Prevent dark mode flash (FOUC) - must run before CSS loads -->
<script>
(function() {
// Try to load theme config from bookmark first, fallback to localStorage
var themeMode = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
var themeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
// Try to fetch theme config from bookmark (async, but we'll use localStorage as fallback)
// This is a simplified check - the full sync happens in script.js
var bookmarkConfig = sessionStorage.getItem('themes_config');
if (bookmarkConfig) {
try {
var config = JSON.parse(bookmarkConfig);
if (config.default) themeId = config.default;
if (config.mode) themeMode = config.mode;
} catch(e) {}
}
// Disable light mode for dark-only themes
var darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
if (darkOnlyThemes.indexOf(themeId) !== -1) {
@ -74,6 +86,7 @@ var shaarli = {
basePath: '{$base_path}',
rootPath: '{$root_path}',
assetPath: '{$base_path}{$asset_path}',
token: '{$token}',
isAuth: (function(){/*{if="$is_logged_in"}*/return true;/*{else}*/return false;/*{/if}*/})(),
pageName: '{$pageName}',
visibility: '{$visibility}',

View File

@ -65,6 +65,77 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
async function syncModeToBookmark(mode) {
try {
if (!window.shaarli || !window.shaarli.basePath) return;
// Fetch current config bookmark
const res = await fetch(window.shaarli.basePath + '/?searchtags=themes');
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
let config = { version: 2, themes: [], default: 'DEFAULT', mode: mode };
let editUrl = null;
// Find existing config
const linkCard = doc.querySelector('.linklist-item');
if (linkCard) {
const descEl = linkCard.querySelector('.link-description p');
if (descEl) {
try {
const parsed = JSON.parse(descEl.textContent.trim());
// Preserve existing data
if (parsed.themes) config.themes = parsed.themes;
if (parsed.default) config.default = parsed.default;
// Update mode
config.mode = mode;
} catch(e) {}
}
const editBtn = linkCard.querySelector('.link-action-edit');
if (editBtn) editUrl = editBtn.getAttribute('href');
}
// Prepare form data
const formData = new FormData();
formData.append('lf_title', 'themes');
formData.append('lf_url', 'https://shaarit.app/config/themes');
formData.append('lf_tags', 'themes');
formData.append('lf_description', JSON.stringify(config));
formData.append('lf_private', 'on');
formData.append('save_edit', 'Save');
let token = '';
if (editUrl) {
// Update existing
const editRes = await fetch(editUrl);
const editDoc = parser.parseFromString(await editRes.text(), 'text/html');
const tokenEl = editDoc.querySelector('input[name="token"]');
const idEl = editDoc.querySelector('input[name="lf_id"]');
if (tokenEl) token = tokenEl.value;
if (idEl) formData.append('lf_id', idEl.value);
} else {
// Create new
const addRes = await fetch(window.shaarli.basePath + '/admin/add-shaare');
const addDoc = parser.parseFromString(await addRes.text(), 'text/html');
const tokenEl = addDoc.querySelector('input[name="token"]');
if (tokenEl) token = tokenEl.value;
}
if (token) {
formData.append('token', token);
await fetch(window.shaarli.basePath + '/admin/shaare', {
method: 'POST',
body: formData
});
console.log('[shaarit] Mode synced to bookmark:', mode);
}
} catch (e) {
console.error('[shaarit] Failed to sync mode to bookmark', e);
}
}
// Handle themeChanged event fired by tools.html
window.addEventListener('themeChanged', () => {
updateTheme(localStorage.getItem('theme') || 'dark');
@ -80,6 +151,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (checkThemeRestrictions()) return; // Extra safety
const next = themeCheckbox.checked ? 'dark' : 'light';
updateTheme(next);
// Sync mode to bookmark in background
syncModeToBookmark(next);
});
}
@ -1041,9 +1114,38 @@ document.addEventListener('DOMContentLoaded', () => {
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;
const BULK_ACTION_COPY = {
delete: {
title: (count) => `Are you sure to delete ${count} link${count > 1 ? 's' : ''}?`,
subtitle: 'The following links will be irretrievably deleted:',
confirm: (count) => `DELETE ${count} LINK${count > 1 ? 'S' : ''}`,
confirmClass: 'bulk-confirm-delete'
},
public: {
title: (count) => `Are you sure to set ${count} link${count > 1 ? 's' : ''} public?`,
subtitle: 'The following links will be set as public:',
confirm: (count) => `SET ${count} LINK${count > 1 ? 'S' : ''} PUBLIC`,
confirmClass: 'bulk-confirm-public'
},
private: {
title: (count) => `Are you sure to set ${count} link${count > 1 ? 's' : ''} private?`,
subtitle: 'The following links will be set as private:',
confirm: (count) => `SET ${count} LINK${count > 1 ? 'S' : ''} PRIVATE`,
confirmClass: 'bulk-confirm-private'
}
};
function updateBulkUI() {
if (bulkCount) {
@ -1114,6 +1216,9 @@ document.addEventListener('DOMContentLoaded', () => {
selectedIds.add(card.dataset.id);
}
});
if (!selectionMode && selectedIds.size > 0) {
enterSelectionMode();
}
updateBulkUI();
});
@ -1140,72 +1245,129 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Bulk actions
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() ||
'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';
}
function closeBulkModal() {
if (!bulkModal) return;
pendingBulkAction = null;
bulkModal.classList.remove('show');
bulkModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
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('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;
}
// Bulk actions with confirmation modal
bulkDelete?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
if (!confirm(`Delete ${selectedIds.size} bookmark(s)?`)) return;
// Submit form with selected IDs
const form = document.createElement('form');
form.method = 'POST';
form.action = shaarli.basePath + '/admin/shaare/delete';
selectedIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'id[]';
input.value = id;
form.appendChild(input);
});
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'token';
tokenInput.value = document.querySelector('input[name="token"]')?.value || '';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
openBulkModal('delete');
});
bulkPublic?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
bulkVisibilityChange('public');
openBulkModal('public');
});
bulkPrivate?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
bulkVisibilityChange('private');
openBulkModal('private');
});
function bulkVisibilityChange(visibility) {
const form = document.createElement('form');
form.method = 'POST';
form.action = shaarli.basePath + '/admin/shaare/visibility';
selectedIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'id[]';
input.value = id;
form.appendChild(input);
});
const visInput = document.createElement('input');
visInput.type = 'hidden';
visInput.name = 'visibility';
visInput.value = visibility;
form.appendChild(visInput);
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'token';
tokenInput.value = document.querySelector('input[name="token"]')?.value || '';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
}
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();
}
});
// ===== Thumbnail Update =====
const thumbnailsPage = document.querySelector('.page-thumbnails');
@ -3313,7 +3475,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Run sync after a slight delay to avoid blocking initial load
if (!window.shaarli || !window.shaarli.basePath) return;
fetch(window.shaarli.basePath + '/?searchtags=shaarit_config')
fetch(window.shaarli.basePath + '/?searchtags=themes')
.then(res => res.text())
.then(text => {
const parser = new DOMParser();
@ -3324,23 +3486,34 @@ document.addEventListener('DOMContentLoaded', () => {
if (descEl) {
try {
const config = JSON.parse(descEl.textContent.trim());
const remoteThemeId = config.theme;
const remoteThemeId = config.default;
const remoteMode = config.mode;
const localThemeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
const localMode = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
// Sync theme if different
if (remoteThemeId && remoteThemeId !== localThemeId) {
localStorage.setItem('shaarit_theme_id', remoteThemeId);
document.documentElement.setAttribute('data-theme-id', remoteThemeId);
// Enforce light/dark constraints based on new theme
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
if (darkOnlyThemes.indexOf(remoteThemeId) !== -1) {
localStorage.setItem('theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
window.dispatchEvent(new Event('themeChanged'));
console.log('[shaarit] Theme synced from bookmark:', remoteThemeId);
}
// Sync mode if different
if (remoteMode && remoteMode !== localMode) {
localStorage.setItem('theme', remoteMode);
document.documentElement.setAttribute('data-theme', remoteMode);
console.log('[shaarit] Mode synced from bookmark:', remoteMode);
}
// Enforce light/dark constraints based on synced theme
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
const syncedThemeId = remoteThemeId || localThemeId;
if (darkOnlyThemes.indexOf(syncedThemeId) !== -1) {
localStorage.setItem('theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
window.dispatchEvent(new Event('themeChanged'));
} catch(e) {}
}
}

View File

@ -56,6 +56,22 @@
</div>
</div>
<!-- Bulk confirmation modal -->
<div class="modal-overlay" id="bulk-confirm-modal" aria-hidden="true">
<div class="modal-content bulk-modal" role="dialog" aria-modal="true" aria-labelledby="bulk-modal-title">
<button class="modal-close" id="bulk-modal-close" type="button" aria-label="Close confirmation">&times;</button>
<div class="bulk-modal-header">
<h3 id="bulk-modal-title"></h3>
<p id="bulk-modal-subtitle"></p>
</div>
<div class="bulk-modal-list" id="bulk-modal-list"></div>
<div class="bulk-modal-actions">
<button class="bulk-modal-btn bulk-modal-cancel" id="bulk-modal-cancel" type="button">Cancel</button>
<button class="bulk-modal-btn bulk-modal-confirm" id="bulk-modal-confirm" type="button"></button>
</div>
</div>
</div>
<!-- Persistent Media Player -->
<div class="media-player-bar" id="media-player-bar">
<audio id="media-player-audio" preload="metadata"></audio>

View File

@ -655,12 +655,20 @@
async function syncThemeToBookmark(themeId) {
try {
// We fetch the current config bookmark if any
const res = await fetch(shaarli.basePath + '/?searchtags=shaarit_config');
const res = await fetch(shaarli.basePath + '/?searchtags=themes');
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
let config = { version: 1, theme: themeId, collections: [] };
// Get current mode from localStorage
const currentMode = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
let config = {
version: 2,
themes: THEMES,
default: themeId,
mode: currentMode
};
let editUrl = null;
// Find existing config
@ -670,7 +678,13 @@
if (descEl) {
try {
const parsed = JSON.parse(descEl.textContent.trim());
config = Object.assign({}, parsed, { theme: themeId });
// Preserve existing themes list if it exists and is valid
if (parsed.themes && Array.isArray(parsed.themes)) {
config.themes = parsed.themes;
}
// Update default and mode
config.default = themeId;
config.mode = currentMode;
} catch(e) {}
}
const editBtn = linkCard.querySelector('.link-action-edit');
@ -679,10 +693,11 @@
// Prepare form data
const formData = new FormData();
formData.append('lf_title', 'collections');
formData.append('lf_url', 'https://shaarit.app/config/collections');
formData.append('lf_tags', 'shaarit_config');
formData.append('lf_title', 'themes');
formData.append('lf_url', 'https://shaarit.app/config/themes');
formData.append('lf_tags', 'themes');
formData.append('lf_description', JSON.stringify(config));
formData.append('lf_private', 'on'); // Private bookmark
formData.append('save_edit', 'Save');
let token = '';