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.
189 lines
6.3 KiB
Plaintext
189 lines
6.3 KiB
Plaintext
{% extends "layouts/reader.njk" %}
|
|
|
|
{% block reader %}
|
|
<div class="timeline-view">
|
|
<header class="timeline-view__header">
|
|
<h1>{{ __("microsub.views.timeline") }}</h1>
|
|
<div class="timeline-view__actions">
|
|
{% if channels.length > 0 %}
|
|
<details class="timeline-view__filter">
|
|
<summary class="button button--secondary button--small">
|
|
Filter channels
|
|
</summary>
|
|
<form action="{{ baseUrl }}/timeline" method="GET" class="timeline-view__filter-form">
|
|
{% for ch in channels %}
|
|
{% if ch.uid !== "notifications" %}
|
|
<label class="timeline-view__filter-label">
|
|
<input type="checkbox" name="exclude" value="{{ ch._id }}"
|
|
{% if excludeIds and ch._id.toString() in excludeIds %}checked{% endif %}>
|
|
<span class="timeline-view__filter-color" style="background: {{ ch.color }}"></span>
|
|
{{ ch.name }}
|
|
</label>
|
|
{% endif %}
|
|
{% endfor %}
|
|
<button type="submit" class="button button--primary button--small">Apply</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
</div>
|
|
</header>
|
|
|
|
{% if items.length > 0 %}
|
|
<div class="timeline" id="timeline">
|
|
{% for item in items %}
|
|
<div class="timeline-view__item">
|
|
{% if item._channelName %}
|
|
<span class="timeline-view__channel-badge" style="background: {{ item._channelColor or '#888' }}">
|
|
{{ item._channelName }}
|
|
</span>
|
|
{% endif %}
|
|
{% include "partials/item-card.njk" %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% if paging %}
|
|
<nav class="timeline__paging" aria-label="Pagination">
|
|
{% if paging.before %}
|
|
<a href="?before={{ paging.before }}" class="button button--secondary">
|
|
{{ __("microsub.reader.newer") }}
|
|
</a>
|
|
{% else %}
|
|
<span></span>
|
|
{% endif %}
|
|
{% if paging.after %}
|
|
<a href="?after={{ paging.after }}" class="button button--secondary">
|
|
{{ __("microsub.reader.older") }}
|
|
</a>
|
|
{% endif %}
|
|
</nav>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<div class="reader__empty">
|
|
<p>{{ __("microsub.reader.empty") }}</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script type="module">
|
|
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 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;
|
|
const channelUid = button.dataset.channelUid;
|
|
if (!itemId || !channelUid) return;
|
|
|
|
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) {
|
|
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(() => {
|
|
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 {
|
|
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 %}
|