feat: mark source as read — split button with popover

Add ability to mark all items from a specific feed/source as read at once,
instead of clicking each item individually. The mark-read button becomes a
split button group with a caret that opens a popover offering "Mark [source]
as read". Items without a feedId (AP items) keep the simple button.

Confab-Link: http://localhost:8080/sessions/a477883d-4aef-4013-983c-ce3d3157cfba
This commit is contained in:
Ricardo
2026-03-11 16:08:53 +01:00
parent 5037ff3d8f
commit e48335da2c
7 changed files with 294 additions and 2 deletions

View File

@@ -152,6 +152,88 @@
}
});
// Handle caret toggle for mark-source-read popover
timeline.addEventListener('click', (e) => {
const caret = e.target.closest('.item-actions__mark-read-caret');
if (!caret) return;
e.preventDefault();
e.stopPropagation();
// Close other open popovers
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
if (p !== caret.nextElementSibling) p.hidden = true;
}
const popover = caret.nextElementSibling;
if (popover) popover.hidden = !popover.hidden;
});
// Handle mark-source-read button
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__mark-source-read');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const feedId = button.dataset.feedId;
const channelUid = button.dataset.channelUid;
const channelId = button.dataset.channelId;
if (!feedId || (!channelUid && !channelId)) return;
button.disabled = true;
try {
const formData = new URLSearchParams();
formData.append('action', 'timeline');
formData.append('method', 'mark_read_source');
formData.append('channel', channelUid || channelId);
formData.append('feed', feedId);
const response = await fetch(microsubApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
credentials: 'same-origin'
});
if (response.ok) {
// Animate out all cards from this feed
const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
for (const card of cards) {
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'translateX(-20px)';
}
setTimeout(() => {
for (const card of [...cards]) {
const wrapper = card.closest('.timeline-view__item');
if (wrapper) wrapper.remove();
else card.remove();
}
if (timeline.querySelectorAll('.item-card').length === 0) {
location.reload();
}
}, 300);
} else {
button.disabled = false;
}
} catch (error) {
console.error('Error marking source as read:', error);
button.disabled = false;
}
});
// Close popovers on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.item-actions__mark-read-group')) {
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
p.hidden = true;
}
}
});
// Handle save-for-later buttons
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__save-later');