feat: mark source as read — split button with popover
Add ability to mark all items from a specific feed/source as read at once, instead of clicking each item individually. The mark-read button becomes a split button group with a caret that opens a popover offering "Mark [source] as read". Items without a feedId (AP items) keep the simple button. Confab-Link: http://localhost:8080/sessions/a477883d-4aef-4013-983c-ce3d3157cfba
This commit is contained in:
+51
-1
@@ -416,7 +416,57 @@
|
|||||||
color: var(--color-background);
|
color: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mark as read button */
|
/* Mark as read — split button group */
|
||||||
|
.item-actions__mark-read-group {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions__mark-read-group .item-actions__mark-read {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions__mark-read-caret {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: var(--space-xs) 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions__mark-read-popover {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: var(--space-xs);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions__mark-source-read {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions__mark-source-read:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mark as read button (standalone, no split group) */
|
||||||
.item-actions__mark-read {
|
.item-actions__mark-read {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { proxyItemImages } from "../media/proxy.js";
|
|||||||
import { getChannel, getChannelById } from "../storage/channels.js";
|
import { getChannel, getChannelById } from "../storage/channels.js";
|
||||||
import {
|
import {
|
||||||
getTimelineItems,
|
getTimelineItems,
|
||||||
|
markFeedItemsRead,
|
||||||
markItemsRead,
|
markItemsRead,
|
||||||
markItemsUnread,
|
markItemsUnread,
|
||||||
removeItems,
|
removeItems,
|
||||||
@@ -103,6 +104,22 @@ export async function action(request, response) {
|
|||||||
return response.json({ result: "ok", updated: count });
|
return response.json({ result: "ok", updated: count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "mark_read_source": {
|
||||||
|
const feedId = request.body.feed;
|
||||||
|
if (!feedId) {
|
||||||
|
throw new IndiekitError("feed parameter required", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const count = await markFeedItemsRead(
|
||||||
|
application,
|
||||||
|
channelDocument._id,
|
||||||
|
feedId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return response.json({ result: "ok", updated: count });
|
||||||
|
}
|
||||||
|
|
||||||
case "mark_unread": {
|
case "mark_unread": {
|
||||||
validateEntries(entries);
|
validateEntries(entries);
|
||||||
const count = await markItemsUnread(
|
const count = await markItemsUnread(
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ function transformToJf2(item, userId) {
|
|||||||
_id: item._id.toString(),
|
_id: item._id.toString(),
|
||||||
_is_read: userId ? item.readBy?.includes(userId) : false,
|
_is_read: userId ? item.readBy?.includes(userId) : false,
|
||||||
_channelId: item.channelId?.toString(),
|
_channelId: item.channelId?.toString(),
|
||||||
|
_feedId: item.feedId?.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
@@ -695,6 +696,41 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|||||||
return result.modifiedCount;
|
return result.modifiedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all items from a specific feed as read in a channel
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||||
|
* @param {ObjectId|string} feedId - Feed ObjectId
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @returns {Promise<number>} Number of items updated
|
||||||
|
*/
|
||||||
|
export async function markFeedItemsRead(
|
||||||
|
application,
|
||||||
|
channelId,
|
||||||
|
feedId,
|
||||||
|
userId,
|
||||||
|
) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
const channelObjectId =
|
||||||
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||||
|
const feedObjectId =
|
||||||
|
typeof feedId === "string" ? new ObjectId(feedId) : feedId;
|
||||||
|
|
||||||
|
const result = await collection.updateMany(
|
||||||
|
{ channelId: channelObjectId, feedId: feedObjectId },
|
||||||
|
{ $addToSet: { readBy: userId } },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup old read items
|
||||||
|
await cleanupOldReadItems(collection, channelObjectId, userId);
|
||||||
|
|
||||||
|
return result.modifiedCount;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark items as unread
|
* Mark items as unread
|
||||||
* @param {object} application - Indiekit application
|
* @param {object} application - Indiekit application
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||||
"version": "1.0.44",
|
"version": "1.0.45",
|
||||||
"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",
|
||||||
|
|||||||
@@ -174,6 +174,84 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle caret toggle for mark-source-read popover
|
||||||
|
timeline.addEventListener('click', (e) => {
|
||||||
|
const caret = e.target.closest('.item-actions__mark-read-caret');
|
||||||
|
if (!caret) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Close other open popovers
|
||||||
|
for (const p of timeline.querySelectorAll('.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('.item-actions__mark-source-read');
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const feedId = button.dataset.feedId;
|
||||||
|
if (!feedId) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('action', 'timeline');
|
||||||
|
formData.append('method', 'mark_read_source');
|
||||||
|
formData.append('channel', channelUid);
|
||||||
|
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(`.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]) {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
if (timeline.querySelectorAll('.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('.item-actions__mark-read-group')) {
|
||||||
|
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
||||||
|
p.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle save-for-later buttons
|
// Handle save-for-later buttons
|
||||||
timeline.addEventListener('click', async (e) => {
|
timeline.addEventListener('click', async (e) => {
|
||||||
const button = e.target.closest('.item-actions__save-later');
|
const button = e.target.closest('.item-actions__save-later');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#}
|
#}
|
||||||
<article class="item-card{% if item._is_read %} item-card--read{% endif %}"
|
<article class="item-card{% if item._is_read %} item-card--read{% endif %}"
|
||||||
data-item-id="{{ item._id }}"
|
data-item-id="{{ item._id }}"
|
||||||
|
data-feed-id="{{ item._feedId or '' }}"
|
||||||
data-is-read="{{ item._is_read | default(false) }}">
|
data-is-read="{{ item._is_read | default(false) }}">
|
||||||
|
|
||||||
{# Context bar for interactions (Aperture pattern) #}
|
{# Context bar for interactions (Aperture pattern) #}
|
||||||
@@ -198,6 +199,33 @@
|
|||||||
<span class="visually-hidden">Bookmark</span>
|
<span class="visually-hidden">Bookmark</span>
|
||||||
</a>
|
</a>
|
||||||
{% if not item._is_read %}
|
{% if not item._is_read %}
|
||||||
|
{% if item._feedId %}
|
||||||
|
<span class="item-actions__mark-read-group">
|
||||||
|
<button type="button"
|
||||||
|
class="item-actions__button item-actions__mark-read"
|
||||||
|
data-action="mark-read"
|
||||||
|
data-item-id="{{ item._id }}"
|
||||||
|
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
|
||||||
|
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
|
||||||
|
title="Mark as read">
|
||||||
|
{{ icon("checkboxChecked") }}
|
||||||
|
<span class="visually-hidden">Mark read</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="item-actions__button item-actions__mark-read-caret"
|
||||||
|
aria-label="More mark-read options"
|
||||||
|
title="More options">▾</button>
|
||||||
|
<div class="item-actions__mark-read-popover" hidden>
|
||||||
|
<button type="button"
|
||||||
|
class="item-actions__mark-source-read"
|
||||||
|
data-feed-id="{{ item._feedId }}"
|
||||||
|
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
|
||||||
|
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}>
|
||||||
|
Mark {{ item._source.name or item.author.name or "source" }} as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="item-actions__button item-actions__mark-read"
|
class="item-actions__button item-actions__mark-read"
|
||||||
data-action="mark-read"
|
data-action="mark-read"
|
||||||
@@ -209,6 +237,7 @@
|
|||||||
<span class="visually-hidden">Mark read</span>
|
<span class="visually-hidden">Mark read</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if application.readlaterEndpoint %}
|
{% if application.readlaterEndpoint %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="item-actions__button item-actions__save-later"
|
class="item-actions__button item-actions__save-later"
|
||||||
|
|||||||
@@ -152,6 +152,88 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle caret toggle for mark-source-read popover
|
||||||
|
timeline.addEventListener('click', (e) => {
|
||||||
|
const caret = e.target.closest('.item-actions__mark-read-caret');
|
||||||
|
if (!caret) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Close other open popovers
|
||||||
|
for (const p of timeline.querySelectorAll('.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('.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(`.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('.timeline-view__item');
|
||||||
|
if (wrapper) wrapper.remove();
|
||||||
|
else card.remove();
|
||||||
|
}
|
||||||
|
if (timeline.querySelectorAll('.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('.item-actions__mark-read-group')) {
|
||||||
|
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
|
||||||
|
p.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle save-for-later buttons
|
// Handle save-for-later buttons
|
||||||
timeline.addEventListener('click', async (e) => {
|
timeline.addEventListener('click', async (e) => {
|
||||||
const button = e.target.closest('.item-actions__save-later');
|
const button = e.target.closest('.item-actions__save-later');
|
||||||
|
|||||||
Reference in New Issue
Block a user