diff --git a/assets/styles.css b/assets/styles.css index bfbe20b..2182215 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -416,7 +416,57 @@ 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 { margin-left: auto; } diff --git a/lib/controllers/timeline.js b/lib/controllers/timeline.js index be13143..18f9815 100644 --- a/lib/controllers/timeline.js +++ b/lib/controllers/timeline.js @@ -9,6 +9,7 @@ import { proxyItemImages } from "../media/proxy.js"; import { getChannel, getChannelById } from "../storage/channels.js"; import { getTimelineItems, + markFeedItemsRead, markItemsRead, markItemsUnread, removeItems, @@ -103,6 +104,22 @@ export async function action(request, response) { 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": { validateEntries(entries); const count = await markItemsUnread( diff --git a/lib/storage/items.js b/lib/storage/items.js index 27cbb8a..4ff55e6 100644 --- a/lib/storage/items.js +++ b/lib/storage/items.js @@ -271,6 +271,7 @@ function transformToJf2(item, userId) { _id: item._id.toString(), _is_read: userId ? item.readBy?.includes(userId) : false, _channelId: item.channelId?.toString(), + _feedId: item.feedId?.toString(), }; // Optional fields @@ -695,6 +696,41 @@ export async function markItemsRead(application, channelId, entryIds, userId) { 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 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 * @param {object} application - Indiekit application diff --git a/package.json b/package.json index 3a610f5..4a11850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit", diff --git a/views/channel.njk b/views/channel.njk index 0aa3e27..ec87228 100644 --- a/views/channel.njk +++ b/views/channel.njk @@ -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 timeline.addEventListener('click', async (e) => { const button = e.target.closest('.item-actions__save-later'); diff --git a/views/partials/item-card.njk b/views/partials/item-card.njk index f5ae360..15ec77b 100644 --- a/views/partials/item-card.njk +++ b/views/partials/item-card.njk @@ -4,6 +4,7 @@ #}
{# Context bar for interactions (Aperture pattern) #} @@ -198,6 +199,33 @@ Bookmark {% if not item._is_read %} + {% if item._feedId %} + + + + + + {% else %} {% endif %} + {% endif %} {% if application.readlaterEndpoint %}