feat: implémenter système Read It Later natif basé sur tags avec bouton toggle interactif, badge visuel rouge "Read Later", synchronisation bidirectionnelle des tags (readitlater/readlater/toread), intégration dans sidebar et header de navigation, mise à jour automatique des pills de tags, et support complet thème clair/sombre avec états hover et loading

This commit is contained in:
Bruno Charest 2026-02-18 16:41:49 -05:00
parent 6b1f4b3e28
commit ab097cd1fe
5 changed files with 268 additions and 26 deletions

View File

@ -1233,6 +1233,35 @@ input:checked+.theme-slider:before {
text-decoration: none; text-decoration: none;
} }
.link-actions .readitlater-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.link-actions .readitlater-toggle-btn i {
font-size: 1.15rem;
color: inherit;
}
.link-actions .readitlater-toggle-btn.active {
color: #dc2626;
background: rgba(220, 38, 38, 0.08);
}
.link-actions .readitlater-toggle-btn.is-loading {
opacity: 0.7;
pointer-events: none;
}
.link-hover-btn:hover { .link-hover-btn:hover {
background: var(--primary); background: var(--primary);
border-color: var(--primary); border-color: var(--primary);
@ -1289,6 +1318,31 @@ input:checked+.theme-slider:before {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.link-readlater-badge {
position: absolute;
top: 0.75rem;
right: 3.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 0.6rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.92);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
z-index: 10;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.35);
}
[data-theme="dark"] .link-readlater-badge {
background: rgba(248, 113, 113, 0.9);
color: #1b0b0b;
}
.link-visibility-badge i { .link-visibility-badge i {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1; line-height: 1;
@ -1487,6 +1541,11 @@ input:checked+.theme-slider:before {
right: 1.5rem; right: 1.5rem;
} }
.view-list .link-readlater-badge {
top: 1.25rem;
right: 3.95rem;
}
/* List view - selection */ /* List view - selection */
.view-list .link-select-checkbox { .view-list .link-select-checkbox {
left: 1rem; left: 1rem;

View File

@ -16,7 +16,7 @@
{$index=""} {$index=""}
{/if} {/if}
{$batchModeValue=empty($batch_mode) ? '0' : '1'} {$batchModeValue=empty($batch_mode) ? '0' : '1'}
{function="($readLaterChecked = strpos(' ' . $link.tags . ' ', ' readlater ') != false || strpos(' ' . $link.tags . ' ', ' toread ') != false) ? '' : ''"} {function="($readLaterChecked = strpos(' ' . $link.tags . ' ', ' readitlater ') != false || strpos(' ' . $link.tags . ' ', ' readlater ') != false || strpos(' ' . $link.tags . ' ', ' toread ') != false) ? '' : ''"}
{function="($noteDefaultChecked = $link_is_new && empty($link.url)) ? '' : ''"} {function="($noteDefaultChecked = $link_is_new && empty($link.url)) ? '' : ''"}
{function="($noteChecked = strpos(' ' . $link.tags . ' ', ' note ') != false || $noteDefaultChecked) ? '' : ''"} {function="($noteChecked = strpos(' ' . $link.tags . ' ', ' note ') != false || $noteDefaultChecked) ? '' : ''"}
{$effectiveTags=$link.tags} {$effectiveTags=$link.tags}

View File

@ -1123,34 +1123,206 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
// ===== ReadItLater Plugin Integration ===== // ===== Read It Later (tag-based, no plugin dependency) =====
document.querySelectorAll('.link-plugin .readitlater-toggle').forEach(toggle => { const READ_IT_LATER_TAG = 'readitlater';
const iconSpan = toggle.querySelector('.readitlater-icon'); const READ_IT_LATER_ALIASES = ['readitlater', 'readlater', 'toread'];
if (!iconSpan) return;
const card = toggle.closest('.link-outer'); const normalizeTagValue = (tagValue) => (tagValue || '').trim().toLowerCase();
const isUnread = card?.classList.contains('readitlater-unread');
const titleText = toggle.getAttribute('title') || '';
// Replace text content with MDI icon const isReadItLaterTag = (tagValue) => READ_IT_LATER_ALIASES.includes(normalizeTagValue(tagValue));
const mdiIcon = document.createElement('i');
if (isUnread) { function getReadItLaterEditUrl(card) {
mdiIcon.className = 'mdi mdi-eye-off'; if (!card) return '';
} else {
mdiIcon.className = 'mdi mdi-eye-outline'; const id = card.dataset.id;
if (id) {
return `${shaarli.basePath}/admin/shaare/${encodeURIComponent(id)}`;
} }
iconSpan.innerHTML = '';
iconSpan.appendChild(mdiIcon);
// Set proper tooltip const editLink = card.querySelector('.link-actions a[href*="/admin/shaare/"]:not([href*="/pin"]):not([href*="/delete"])');
toggle.setAttribute('title', titleText || (isUnread ? 'Mark as Read' : 'Read it later')); return editLink ? editLink.href : '';
}
// Add "To Read" badge to unread cards function collectBookmarkFormData(form) {
if (isUnread && card && !card.querySelector('.readitlater-badge')) { const formData = new URLSearchParams();
const badge = document.createElement('div'); const inputs = form.querySelectorAll('input, textarea, select');
badge.className = 'readitlater-badge'; inputs.forEach((input) => {
badge.innerHTML = '<i class="mdi mdi-bookmark-check"></i> To Read'; if (!input.name || input.disabled) return;
if (input.type === 'checkbox') {
if (input.checked) {
formData.append(input.name, input.value || 'on');
}
return;
}
if (input.type === 'radio' && !input.checked) {
return;
}
formData.append(input.name, input.value || '');
});
return formData;
}
async function updateReadItLaterTag(editUrl, enableTag) {
const editResponse = await fetch(editUrl, {
method: 'GET',
credentials: 'same-origin',
});
if (!editResponse.ok) {
throw new Error('Unable to load bookmark edit form.');
}
const html = await editResponse.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const form = doc.querySelector('form[name="linkform"]');
if (!form) {
throw new Error('Bookmark edit form not found.');
}
const formData = collectBookmarkFormData(form);
const existingTags = (formData.get('lf_tags') || '')
.split(/[\s,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
.filter((tag) => !isReadItLaterTag(tag));
if (enableTag) {
existingTags.push(READ_IT_LATER_TAG);
}
formData.set('lf_tags', existingTags.join(' '));
formData.set('save_edit', '1');
const submitResponse = await fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
credentials: 'same-origin',
});
if (!submitResponse.ok) {
throw new Error('Unable to save bookmark tags.');
}
}
function syncReadItLaterTagPill(card, isActive) {
const tagList = card.querySelector('.link-tag-list');
if (!tagList) return;
const existingPill = Array.from(tagList.querySelectorAll('.link-tag')).find((tagEl) => {
const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || '';
return normalizeTagValue(rawTag) === READ_IT_LATER_TAG;
});
if (!isActive) {
existingPill?.remove();
return;
}
if (existingPill) return;
const pill = document.createElement('span');
pill.className = 'link-tag';
pill.dataset.tag = READ_IT_LATER_TAG;
const link = document.createElement('a');
link.className = 'link-tag-link';
link.href = `${shaarli.basePath}/add-tag/${encodeURIComponent(READ_IT_LATER_TAG)}`;
link.textContent = READ_IT_LATER_TAG;
pill.appendChild(link);
if (shaarli.isAuth) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'tag-remove-btn';
removeBtn.dataset.tag = READ_IT_LATER_TAG;
removeBtn.setAttribute('aria-label', 'Supprimer le tag readitlater');
removeBtn.title = 'Supprimer';
removeBtn.textContent = '×';
pill.appendChild(removeBtn);
}
tagList.appendChild(pill);
}
function syncReadItLaterCardUI(card, isActive) {
if (!card) return;
card.classList.toggle('readitlater-active', isActive);
let badge = card.querySelector('.link-readlater-badge');
if (isActive && !badge) {
badge = document.createElement('div');
badge.className = 'link-readlater-badge';
badge.textContent = 'Read Later';
card.appendChild(badge); card.appendChild(badge);
} else if (!isActive && badge) {
badge.remove();
}
const toggleBtn = card.querySelector('.readitlater-toggle-btn');
if (toggleBtn) {
const titleText = isActive ? 'Retirer de Read It Later' : 'Ajouter a Read It Later';
toggleBtn.classList.toggle('active', isActive);
toggleBtn.dataset.active = isActive ? '1' : '0';
toggleBtn.setAttribute('title', titleText);
toggleBtn.setAttribute('aria-label', titleText);
const icon = toggleBtn.querySelector('i');
if (icon) {
icon.className = `mdi ${isActive ? 'mdi-bookmark-clock' : 'mdi-bookmark-clock-outline'}`;
}
}
syncReadItLaterTagPill(card, isActive);
}
document.querySelectorAll('.link-outer').forEach((card) => {
const cardTags = Array.from(card.querySelectorAll('.link-tag')).map((tagEl) => {
const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || '';
return normalizeTagValue(rawTag);
});
const isActive = cardTags.some(isReadItLaterTag) || card.classList.contains('readitlater-active') || card.classList.contains('readitlater-unread');
syncReadItLaterCardUI(card, isActive);
});
document.addEventListener('click', async (event) => {
const toggleBtn = event.target.closest('.readitlater-toggle-btn');
if (!toggleBtn) return;
event.preventDefault();
event.stopPropagation();
const card = toggleBtn.closest('.link-outer');
if (!card) return;
const editUrl = getReadItLaterEditUrl(card);
if (!editUrl) return;
const isActive = card.classList.contains('readitlater-active') || toggleBtn.dataset.active === '1';
const nextState = !isActive;
if (toggleBtn.disabled) return;
toggleBtn.disabled = true;
toggleBtn.classList.add('is-loading');
try {
await updateReadItLaterTag(editUrl, nextState);
syncReadItLaterCardUI(card, nextState);
} catch (error) {
console.error('Failed to toggle readitlater tag:', error);
alert('Impossible de mettre a jour Read It Later pour ce bookmark.');
} finally {
toggleBtn.disabled = false;
toggleBtn.classList.remove('is-loading');
} }
}); });
@ -1252,7 +1424,7 @@ document.addEventListener('DOMContentLoaded', () => {
const syncReadLaterCheckbox = () => { const syncReadLaterCheckbox = () => {
if (!readLaterCheckbox) return; if (!readLaterCheckbox) return;
readLaterCheckbox.checked = tags.some((tag) => /^(readlater|toread)$/i.test(tag)); readLaterCheckbox.checked = tags.some((tag) => /^(readitlater|readlater|toread)$/i.test(tag));
}; };
const syncNoteCheckbox = () => { const syncNoteCheckbox = () => {
@ -1355,10 +1527,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (readLaterCheckbox) { if (readLaterCheckbox) {
readLaterCheckbox.addEventListener('change', () => { readLaterCheckbox.addEventListener('change', () => {
tags = tags.filter((tag) => !/^(readlater|toread)$/i.test(tag)); tags = tags.filter((tag) => !/^(readitlater|readlater|toread)$/i.test(tag));
if (readLaterCheckbox.checked) { if (readLaterCheckbox.checked) {
tags.push('readlater'); tags.push('readitlater');
} }
updateHiddenTags(); updateHiddenTags();

View File

@ -100,6 +100,9 @@
title="Supprimer" aria-label="Supprimer ce bookmark" title="Supprimer" aria-label="Supprimer ce bookmark"
onclick="return confirm('Supprimer ce bookmark ?');"><i onclick="return confirm('Supprimer ce bookmark ?');"><i
class="mdi mdi-delete" aria-hidden="true"></i></a> class="mdi mdi-delete" aria-hidden="true"></i></a>
<button type="button" class="readitlater-toggle-btn" data-tag="readitlater" data-active="0" title="Ajouter à Read It Later" aria-label="Ajouter à Read It Later">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>
</button>
{/if} {/if}
<!-- fullscreen button (in all 3 views) --> <!-- fullscreen button (in all 3 views) -->
<a href="#" class="view-desc-btn" data-id="{$value.id}" <a href="#" class="view-desc-btn" data-id="{$value.id}"

View File

@ -48,6 +48,10 @@ Bookmarklet detection logic
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i> <i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
<span>Notes</span> <span>Notes</span>
</a> </a>
<a href="{$base_path}/?searchtags=readitlater" class="sidebar-link{if="isset($search_tags)&&$search_tags=='readitlater'"} active{/if}" aria-label="Read It Later">
<i class="mdi mdi-bookmark-outline" aria-hidden="true"></i>
<span>Read It Later</span>
</a>
</div> </div>
{if="$is_logged_in"} {if="$is_logged_in"}
@ -133,6 +137,10 @@ Bookmarklet detection logic
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i> <i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
<span>NOTES</span> <span>NOTES</span>
</a> </a>
<a href="{$base_path}/?searchtags=readitlater" class="header-nav-link{if="isset($search_tags)&&$search_tags=='readitlater'"} active{/if}" aria-label="Read It Later">
<i class="mdi mdi-bookmark-outline" aria-hidden="true"></i>
<span>READ IT LATER</span>
</a>
<button class="header-nav-link" id="search-toggle-btn" aria-label="Rechercher (raccourci S)"> <button class="header-nav-link" id="search-toggle-btn" aria-label="Rechercher (raccourci S)">
<i class="mdi mdi-magnify" aria-hidden="true"></i> <i class="mdi mdi-magnify" aria-hidden="true"></i>
<span>RECHERCHE</span> <span>RECHERCHE</span>