mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
Adds a save icon to the item-card action bar that POSTs to /readlater/save when the readlater plugin is installed. Button only renders if application.readlaterEndpoint is set. Includes JS handlers in both channel and timeline views.
212 lines
7.4 KiB
Plaintext
212 lines
7.4 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 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 %}
|