mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
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:
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user