feat: add show/hide read items and fix individual mark-read

- Add countReadItems function to storage/items.js
- Update getTimelineItems to filter out read items by default
- Add showRead query param support to channel controller
- Update channel.njk with show/hide read toggle buttons
- Add "All caught up!" state when all items are read
- Add JavaScript handler for individual mark-read buttons
- Mark-read now hides the item with smooth animation
- Add locale strings: showRead, hideRead, allRead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-06 21:24:33 +01:00
parent 8d373dca5f
commit c830ad5df6
5 changed files with 120 additions and 4 deletions

View File

@@ -21,6 +21,7 @@ import {
getTimelineItems, getTimelineItems,
getItemById, getItemById,
markItemsRead, markItemsRead,
countReadItems,
} from "../storage/items.js"; } from "../storage/items.js";
import { getUserId } from "../utils/auth.js"; import { getUserId } from "../utils/auth.js";
import { import {
@@ -95,24 +96,37 @@ export async function channel(request, response) {
const { application } = request.app.locals; const { application } = request.app.locals;
const userId = getUserId(request); const userId = getUserId(request);
const { uid } = request.params; const { uid } = request.params;
const { before, after } = request.query; const { before, after, showRead } = request.query;
const channelDocument = await getChannel(application, uid, userId); const channelDocument = await getChannel(application, uid, userId);
if (!channelDocument) { if (!channelDocument) {
return response.status(404).render("404"); return response.status(404).render("404");
} }
// Check if showing read items
const showReadItems = showRead === "true";
const timeline = await getTimelineItems(application, channelDocument._id, { const timeline = await getTimelineItems(application, channelDocument._id, {
before, before,
after, after,
userId, userId,
showRead: showReadItems,
}); });
// Count read items to show "View read items" button
const readCount = await countReadItems(
application,
channelDocument._id,
userId,
);
response.render("channel", { response.render("channel", {
title: channelDocument.name, title: channelDocument.name,
channel: channelDocument, channel: channelDocument,
items: timeline.items, items: timeline.items,
paging: timeline.paging, paging: timeline.paging,
readCount,
showRead: showReadItems,
baseUrl: request.baseUrl, baseUrl: request.baseUrl,
}); });
} }

View File

@@ -78,6 +78,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
* @param {string} [options.after] - After cursor * @param {string} [options.after] - After cursor
* @param {number} [options.limit] - Items per page * @param {number} [options.limit] - Items per page
* @param {string} [options.userId] - User ID for read state * @param {string} [options.userId] - User ID for read state
* @param {boolean} [options.showRead] - Whether to show read items (default: false)
* @returns {Promise<object>} Timeline with items and paging * @returns {Promise<object>} Timeline with items and paging
*/ */
export async function getTimelineItems(application, channelId, options = {}) { export async function getTimelineItems(application, channelId, options = {}) {
@@ -86,7 +87,12 @@ export async function getTimelineItems(application, channelId, options = {}) {
typeof channelId === "string" ? new ObjectId(channelId) : channelId; typeof channelId === "string" ? new ObjectId(channelId) : channelId;
const limit = parseLimit(options.limit); const limit = parseLimit(options.limit);
// Base query - filter out read items unless showRead is true
const baseQuery = { channelId: objectId }; const baseQuery = { channelId: objectId };
if (options.userId && !options.showRead) {
baseQuery.readBy = { $ne: options.userId };
}
const query = buildPaginationQuery({ const query = buildPaginationQuery({
before: options.before, before: options.before,
after: options.after, after: options.after,
@@ -256,6 +262,24 @@ export async function getItemsByUids(application, uids, userId) {
return items.map((item) => transformToJf2(item, userId)); return items.map((item) => transformToJf2(item, userId));
} }
/**
* Count read items in a channel
* @param {object} application - Indiekit application
* @param {ObjectId|string} channelId - Channel ObjectId
* @param {string} userId - User ID
* @returns {Promise<number>} Number of read items
*/
export async function countReadItems(application, channelId, userId) {
const collection = getCollection(application);
const objectId =
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
return collection.countDocuments({
channelId: objectId,
readBy: userId,
});
}
/** /**
* Mark items as read * Mark items as read
* @param {object} application - Indiekit application * @param {object} application - Indiekit application

View File

@@ -4,6 +4,9 @@
"title": "Reader", "title": "Reader",
"empty": "No items to display", "empty": "No items to display",
"markAllRead": "Mark all as read", "markAllRead": "Mark all as read",
"showRead": "Show read ({{count}})",
"hideRead": "Hide read items",
"allRead": "All caught up!",
"newer": "Newer", "newer": "Newer",
"older": "Older" "older": "Older"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-microsub", "name": "@rmdes/indiekit-endpoint-microsub",
"version": "1.0.16", "version": "1.0.17",
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
"keywords": [ "keywords": [
"indiekit", "indiekit",

View File

@@ -7,6 +7,7 @@
{{ icon("previous") }} {{ __("microsub.channels.title") }} {{ icon("previous") }} {{ __("microsub.channels.title") }}
</a> </a>
<div class="channel__actions"> <div class="channel__actions">
{% if not showRead and items.length > 0 %}
<form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;"> <form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
<input type="hidden" name="channel" value="{{ channel.uid }}"> <input type="hidden" name="channel" value="{{ channel.uid }}">
<input type="hidden" name="entry" value="last-read-entry"> <input type="hidden" name="entry" value="last-read-entry">
@@ -14,6 +15,16 @@
{{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }} {{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
</button> </button>
</form> </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"> <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
{{ icon("syndicate") }} {{ __("microsub.feeds.title") }} {{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
</a> </a>
@@ -33,14 +44,14 @@
{% if paging %} {% if paging %}
<nav class="timeline__paging" aria-label="Pagination"> <nav class="timeline__paging" aria-label="Pagination">
{% if paging.before %} {% if paging.before %}
<a href="?before={{ paging.before }}" class="button button--secondary"> <a href="?before={{ paging.before }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
{{ icon("previous") }} {{ __("microsub.reader.newer") }} {{ icon("previous") }} {{ __("microsub.reader.newer") }}
</a> </a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if paging.after %} {% if paging.after %}
<a href="?after={{ paging.after }}" class="button button--secondary"> <a href="?after={{ paging.after }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
{{ __("microsub.reader.older") }} {{ icon("next") }} {{ __("microsub.reader.older") }} {{ icon("next") }}
</a> </a>
{% endif %} {% endif %}
@@ -48,11 +59,19 @@
{% endif %} {% endif %}
{% else %} {% else %}
<div class="reader__empty"> <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") }} {{ icon("syndicate") }}
<p>{{ __("microsub.timeline.empty") }}</p> <p>{{ __("microsub.timeline.empty") }}</p>
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary"> <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary">
{{ __("microsub.feeds.subscribe") }} {{ __("microsub.feeds.subscribe") }}
</a> </a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -97,6 +116,62 @@
break; break;
} }
}); });
// Handle individual mark-read buttons
const channelUid = timeline.dataset.channel;
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('{{ baseUrl }}', {
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;
}
});
} }
</script> </script>
{% endblock %} {% endblock %}