Files
indiekit-endpoint-microsub/views/channel.njk
Ricardo e48335da2c 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
2026-03-11 16:08:53 +01:00

290 lines
10 KiB
Plaintext

{% extends "layouts/reader.njk" %}
{% block reader %}
<div class="channel">
<header class="channel__header">
<h1>{{ channel.name }}</h1>
<div class="channel__actions">
{% if not showRead and items.length > 0 %}
<form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
<input type="hidden" name="channel" value="{{ channel.uid }}">
<input type="hidden" name="entry" value="last-read-entry">
<button type="submit" class="button button--secondary button--small">
{{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
</button>
</form>
{% endif %}
{% if showRead %}
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary button--small">
{{ icon("hide") }} {{ __("microsub.reader.hideRead") }}
</a>
{% elif readCount > 0 %}
<a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary button--small">
{{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
</a>
{% endif %}
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
{{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
</a>
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
{{ icon("updatePost") }} {{ __("microsub.channels.settings") }}
</a>
</div>
</header>
{% if items.length > 0 %}
<div class="timeline" id="timeline" data-channel="{{ channel.uid }}">
{% for item in items %}
{% include "partials/item-card.njk" %}
{% endfor %}
</div>
{% if paging %}
<nav class="timeline__paging" aria-label="Pagination">
{% if paging.before %}
<a href="?before={{ paging.before }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
{{ icon("previous") }} {{ __("microsub.reader.newer") }}
</a>
{% else %}
<span></span>
{% endif %}
{% if paging.after %}
<a href="?after={{ paging.after }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
{{ __("microsub.reader.older") }} {{ icon("next") }}
</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="reader__empty">
{% if readCount > 0 and not showRead %}
{{ icon("checkboxChecked") }}
<p>{{ __("microsub.reader.allRead") }}</p>
<a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary">
{{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
</a>
{% else %}
{{ icon("syndicate") }}
<p>{{ __("microsub.timeline.empty") }}</p>
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary">
{{ __("microsub.feeds.subscribe") }}
</a>
{% endif %}
</div>
{% endif %}
</div>
<script type="module">
// Keyboard navigation (j/k for items, o to open)
const timeline = document.getElementById('timeline');
if (timeline) {
const items = Array.from(timeline.querySelectorAll('.item-card'));
let currentIndex = -1;
function focusItem(index) {
if (items[currentIndex]) {
items[currentIndex].classList.remove('item-card--focused');
}
currentIndex = Math.max(0, Math.min(index, items.length - 1));
if (items[currentIndex]) {
items[currentIndex].classList.add('item-card--focused');
items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch(e.key) {
case 'j':
e.preventDefault();
focusItem(currentIndex + 1);
break;
case 'k':
e.preventDefault();
focusItem(currentIndex - 1);
break;
case 'o':
case 'Enter':
e.preventDefault();
if (items[currentIndex]) {
const link = items[currentIndex].querySelector('.item-card__link');
if (link) link.click();
}
break;
}
});
// Handle individual mark-read buttons
const channelUid = timeline.dataset.channel;
// Microsub API is at the parent of /reader (e.g., /microsub not /microsub/reader)
const microsubApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__mark-read');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const itemId = button.dataset.itemId;
if (!itemId) return;
// Disable button while processing
button.disabled = true;
try {
const formData = new URLSearchParams();
formData.append('action', 'timeline');
formData.append('method', 'mark_read');
formData.append('channel', channelUid);
formData.append('entry', itemId);
const response = await fetch(microsubApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
credentials: 'same-origin'
});
if (response.ok) {
// Hide the item with animation
const card = button.closest('.item-card');
if (card) {
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'translateX(-20px)';
setTimeout(() => {
card.remove();
// Check if timeline is now empty
if (timeline.querySelectorAll('.item-card').length === 0) {
location.reload();
}
}, 300);
}
} else {
console.error('Failed to mark item as read');
button.disabled = false;
}
} catch (error) {
console.error('Error marking item as read:', error);
button.disabled = false;
}
});
// 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;
if (!feedId) return;
button.disabled = true;
try {
const formData = new URLSearchParams();
formData.append('action', 'timeline');
formData.append('method', 'mark_read_source');
formData.append('channel', channelUid);
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]) {
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');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const url = button.dataset.url;
const title = button.dataset.title;
if (!url) return;
button.disabled = true;
try {
const response = await fetch('/readlater/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, title: title || url, source: 'microsub' }),
credentials: 'same-origin'
});
if (response.ok) {
button.classList.add('item-actions__save-later--saved');
button.title = 'Saved';
} else {
button.disabled = false;
}
} catch {
button.disabled = false;
}
});
}
</script>
{% endblock %}