mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
- Namespace all plugin CSS classes with ms- prefix (20 BEM blocks) - Update all 19 templates to match prefixed class names - Replace visually-hidden with -!-visually-hidden (core convention) - Remove inline onerror handlers from avatar/photo images - Remove dead source-type SVG icons (Fediverse/Bluesky/Web) Confab-Link: http://localhost:8080/sessions/bb4a6ec4-b711-48cd-b3d7-942ec2a9851d
272 lines
9.3 KiB
Plaintext
272 lines
9.3 KiB
Plaintext
{% extends "layouts/reader.njk" %}
|
|
|
|
{% block reader %}
|
|
<div class="ms-timeline-view">
|
|
<header class="ms-timeline-view__header">
|
|
<h1>{{ __("microsub.views.timeline") }}</h1>
|
|
<div class="ms-timeline-view__actions">
|
|
{% if channels.length > 0 %}
|
|
<details class="ms-timeline-view__filter">
|
|
<summary class="button button--secondary button--small">
|
|
Filter channels
|
|
</summary>
|
|
<form action="{{ baseUrl }}/timeline" method="GET" class="ms-timeline-view__filter-form">
|
|
{% for ch in channels %}
|
|
{% if ch.uid !== "notifications" %}
|
|
<label class="ms-timeline-view__filter-label">
|
|
<input type="checkbox" name="exclude" value="{{ ch._id }}"
|
|
{% if excludeIds and ch._id.toString() in excludeIds %}checked{% endif %}>
|
|
<span class="ms-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="ms-timeline" id="timeline">
|
|
{% for item in items %}
|
|
<div class="ms-timeline-view__item">
|
|
{% if item._channelName %}
|
|
<span class="ms-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="ms-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="ms-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('.ms-item-card'));
|
|
let currentIndex = -1;
|
|
|
|
function focusItem(index) {
|
|
if (items[currentIndex]) items[currentIndex].classList.remove('ms-item-card--focused');
|
|
currentIndex = Math.max(0, Math.min(index, items.length - 1));
|
|
if (items[currentIndex]) {
|
|
items[currentIndex].classList.add('ms-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('.ms-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('.ms-item-actions__mark-read');
|
|
if (!button) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const itemId = button.dataset.itemId;
|
|
const channelUid = button.dataset.channelUid;
|
|
const channelId = button.dataset.channelId;
|
|
if (!itemId || (!channelUid && !channelId)) return;
|
|
|
|
button.disabled = true;
|
|
|
|
try {
|
|
const formData = new URLSearchParams();
|
|
formData.append('action', 'timeline');
|
|
formData.append('method', 'mark_read');
|
|
formData.append('channel', channelUid || channelId);
|
|
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('.ms-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('.ms-timeline-view__item');
|
|
if (wrapper) wrapper.remove();
|
|
else card.remove();
|
|
if (timeline.querySelectorAll('.ms-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('.ms-item-actions__mark-read-caret');
|
|
if (!caret) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Close other open popovers
|
|
for (const p of timeline.querySelectorAll('.ms-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('.ms-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(`.ms-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('.ms-timeline-view__item');
|
|
if (wrapper) wrapper.remove();
|
|
else card.remove();
|
|
}
|
|
if (timeline.querySelectorAll('.ms-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('.ms-item-actions__mark-read-group')) {
|
|
for (const p of timeline.querySelectorAll('.ms-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('.ms-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('ms-item-actions__save-later--saved');
|
|
button.title = 'Saved';
|
|
} else {
|
|
button.disabled = false;
|
|
}
|
|
} catch {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|