mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
feat: add multi-view reader with Channels, Deck, and Timeline views
Three reader views accessible via icon toolbar: - Channels: existing view (renamed), per-channel timelines - Deck: TweetDeck-style configurable columns with compact cards - Timeline: all channels merged chronologically with colored borders Includes channel color palette, cross-channel query, deck config storage, session-based view preference, and view switcher partial.
This commit is contained in:
@@ -1014,3 +1014,290 @@
|
|||||||
background: #7c3aed20;
|
background: #7c3aed20;
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
View Switcher
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher__button {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher__button:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher__button--active {
|
||||||
|
background: var(--color-primary, #333);
|
||||||
|
border-color: var(--color-primary, #333);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher__button--active:hover {
|
||||||
|
background: var(--color-primary, #333);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Timeline View (all channels chronological)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.timeline-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__item {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__item .item-card {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__channel-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 var(--space-s) var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__filter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__filter-form {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 200px;
|
||||||
|
padding: var(--space-s);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__filter-label {
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-view__filter-color {
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Compact Item Card (Deck view)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.item-card-compact {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border, #e0e0e0);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact--read {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact:not(.item-card-compact--read) {
|
||||||
|
border-left: 3px solid rgba(255, 204, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__link {
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__photo {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__title {
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
display: -webkit-box;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__text {
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: -webkit-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__source {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-compact__unread {
|
||||||
|
color: rgba(255, 204, 0, 0.9);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Deck View (TweetDeck-style columns)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.deck {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__columns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-m);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-s);
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column {
|
||||||
|
flex-shrink: 0;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column-header {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column-name {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: var(--space-m);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__column-more {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deck settings */
|
||||||
|
.deck-settings__channels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: var(--space-m) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-settings__channel {
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|||||||
5
index.js
5
index.js
@@ -132,6 +132,10 @@ export default class MicrosubEndpoint {
|
|||||||
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
||||||
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
||||||
readerRouter.get("/opml", opmlController.exportOpml);
|
readerRouter.get("/opml", opmlController.exportOpml);
|
||||||
|
readerRouter.get("/timeline", readerController.timeline);
|
||||||
|
readerRouter.get("/deck", readerController.deck);
|
||||||
|
readerRouter.get("/deck/settings", readerController.deckSettings);
|
||||||
|
readerRouter.post("/deck/settings", readerController.saveDeckSettings);
|
||||||
router.use("/reader", readerRouter);
|
router.use("/reader", readerRouter);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
@@ -171,6 +175,7 @@ export default class MicrosubEndpoint {
|
|||||||
indiekit.addCollection("microsub_notifications");
|
indiekit.addCollection("microsub_notifications");
|
||||||
indiekit.addCollection("microsub_muted");
|
indiekit.addCollection("microsub_muted");
|
||||||
indiekit.addCollection("microsub_blocked");
|
indiekit.addCollection("microsub_blocked");
|
||||||
|
indiekit.addCollection("microsub_deck_config");
|
||||||
|
|
||||||
console.info("[Microsub] Registered MongoDB collections");
|
console.info("[Microsub] Registered MongoDB collections");
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ObjectId } from "mongodb";
|
|||||||
import { refreshFeedNow } from "../polling/scheduler.js";
|
import { refreshFeedNow } from "../polling/scheduler.js";
|
||||||
import {
|
import {
|
||||||
getChannels,
|
getChannels,
|
||||||
|
getChannelsWithColors,
|
||||||
getChannel,
|
getChannel,
|
||||||
createChannel,
|
createChannel,
|
||||||
updateChannelSettings,
|
updateChannelSettings,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from "../storage/feeds.js";
|
} from "../storage/feeds.js";
|
||||||
import {
|
import {
|
||||||
getTimelineItems,
|
getTimelineItems,
|
||||||
|
getAllTimelineItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
markItemsRead,
|
markItemsRead,
|
||||||
countReadItems,
|
countReadItems,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
validateExcludeRegex,
|
validateExcludeRegex,
|
||||||
} from "../utils/validation.js";
|
} from "../utils/validation.js";
|
||||||
import { proxyItemImages } from "../media/proxy.js";
|
import { proxyItemImages } from "../media/proxy.js";
|
||||||
|
import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reader index - redirect to channels
|
* Reader index - redirect to channels
|
||||||
@@ -43,7 +46,10 @@ import { proxyItemImages } from "../media/proxy.js";
|
|||||||
* @param {object} response - Express response
|
* @param {object} response - Express response
|
||||||
*/
|
*/
|
||||||
export async function index(request, response) {
|
export async function index(request, response) {
|
||||||
response.redirect(`${request.baseUrl}/channels`);
|
const lastView = request.session?.microsubView || "channels";
|
||||||
|
const validViews = ["channels", "deck", "timeline"];
|
||||||
|
const view = validViews.includes(lastView) ? lastView : "channels";
|
||||||
|
response.redirect(`${request.baseUrl}/${view}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,10 +63,14 @@ export async function channels(request, response) {
|
|||||||
|
|
||||||
const channelList = await getChannels(application, userId);
|
const channelList = await getChannels(application, userId);
|
||||||
|
|
||||||
|
if (request.session) request.session.microsubView = "channels";
|
||||||
|
|
||||||
response.render("reader", {
|
response.render("reader", {
|
||||||
title: request.__("microsub.reader.title"),
|
title: request.__("microsub.views.channels"),
|
||||||
channels: channelList,
|
channels: channelList,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +83,8 @@ export async function newChannel(request, response) {
|
|||||||
response.render("channel-new", {
|
response.render("channel-new", {
|
||||||
title: request.__("microsub.channels.new"),
|
title: request.__("microsub.channels.new"),
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +155,8 @@ export async function channel(request, response) {
|
|||||||
readCount,
|
readCount,
|
||||||
showRead: showReadItems,
|
showRead: showReadItems,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +182,8 @@ export async function settings(request, response) {
|
|||||||
}),
|
}),
|
||||||
channel: channelDocument,
|
channel: channelDocument,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +271,8 @@ export async function feeds(request, response) {
|
|||||||
channel: channelDocument,
|
channel: channelDocument,
|
||||||
feeds: feedList,
|
feeds: feedList,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +359,8 @@ export async function item(request, response) {
|
|||||||
item: itemDocument,
|
item: itemDocument,
|
||||||
channel,
|
channel,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +471,8 @@ export async function compose(request, response) {
|
|||||||
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
||||||
syndicationTargets,
|
syndicationTargets,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,6 +646,8 @@ export async function searchPage(request, response) {
|
|||||||
title: request.__("microsub.search.title"),
|
title: request.__("microsub.search.title"),
|
||||||
channels: channelList,
|
channels: channelList,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +684,8 @@ export async function searchFeeds(request, response) {
|
|||||||
discoveryError,
|
discoveryError,
|
||||||
searched: true,
|
searched: true,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +717,8 @@ export async function subscribe(request, response) {
|
|||||||
query: url,
|
query: url,
|
||||||
validationError: validation.error,
|
validationError: validation.error,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +809,8 @@ export async function editFeedForm(request, response) {
|
|||||||
channel: channelDocument,
|
channel: channelDocument,
|
||||||
feed,
|
feed,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +846,8 @@ export async function updateFeedUrl(request, response) {
|
|||||||
feed,
|
feed,
|
||||||
error: validation.error,
|
error: validation.error,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,6 +1027,8 @@ export async function actorProfile(request, response) {
|
|||||||
isFollowing,
|
isFollowing,
|
||||||
canFollow,
|
canFollow,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
||||||
@@ -1006,6 +1040,8 @@ export async function actorProfile(request, response) {
|
|||||||
isFollowing,
|
isFollowing,
|
||||||
canFollow,
|
canFollow,
|
||||||
baseUrl: request.baseUrl,
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
error: "Could not fetch this actor's profile. They may have restricted access.",
|
error: "Could not fetch this actor's profile. They may have restricted access.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1095,181 @@ export async function unfollowActorAction(request, response) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeline view - all channels chronologically
|
||||||
|
* @param {object} request - Express request
|
||||||
|
* @param {object} response - Express response
|
||||||
|
*/
|
||||||
|
export async function timeline(request, response) {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const userId = getUserId(request);
|
||||||
|
const { before, after } = request.query;
|
||||||
|
|
||||||
|
// Get channels with colors for filtering UI and item decoration
|
||||||
|
const channelList = await getChannelsWithColors(application, userId);
|
||||||
|
|
||||||
|
// Build channel lookup map (ObjectId string -> { name, color })
|
||||||
|
const channelMap = new Map();
|
||||||
|
for (const ch of channelList) {
|
||||||
|
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse excluded channel IDs from query params
|
||||||
|
const excludeParam = request.query.exclude;
|
||||||
|
const excludeIds = excludeParam
|
||||||
|
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Exclude the notifications channel by default
|
||||||
|
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
||||||
|
const excludeChannelIds = [...excludeIds];
|
||||||
|
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
||||||
|
excludeChannelIds.push(notificationsChannel._id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getAllTimelineItems(application, {
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
userId,
|
||||||
|
excludeChannelIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy images
|
||||||
|
const proxyBaseUrl = application.url;
|
||||||
|
if (proxyBaseUrl && result.items) {
|
||||||
|
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorate items with channel name and color
|
||||||
|
for (const item of result.items) {
|
||||||
|
if (item._channelId) {
|
||||||
|
const info = channelMap.get(item._channelId);
|
||||||
|
if (info) {
|
||||||
|
item._channelName = info.name;
|
||||||
|
item._channelColor = info.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set view preference cookie
|
||||||
|
if (request.session) request.session.microsubView = "timeline";
|
||||||
|
|
||||||
|
response.render("timeline", {
|
||||||
|
title: "Timeline",
|
||||||
|
channels: channelList,
|
||||||
|
items: result.items,
|
||||||
|
paging: result.paging,
|
||||||
|
excludeIds,
|
||||||
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "timeline",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck view - TweetDeck-style columns
|
||||||
|
* @param {object} request - Express request
|
||||||
|
* @param {object} response - Express response
|
||||||
|
*/
|
||||||
|
export async function deck(request, response) {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const userId = getUserId(request);
|
||||||
|
|
||||||
|
const channelList = await getChannelsWithColors(application, userId);
|
||||||
|
const deckConfig = await getDeckConfig(application, userId);
|
||||||
|
|
||||||
|
// Determine which channels to show as columns
|
||||||
|
let columnChannels;
|
||||||
|
if (deckConfig?.columns?.length > 0) {
|
||||||
|
// Use saved config order
|
||||||
|
const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
|
||||||
|
columnChannels = deckConfig.columns
|
||||||
|
.map((col) => channelMap.get(col.channelId.toString()))
|
||||||
|
.filter(Boolean);
|
||||||
|
} else {
|
||||||
|
// Default: all channels except notifications
|
||||||
|
columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch items for each column (limited to 10 per column for performance)
|
||||||
|
const proxyBaseUrl = application.url;
|
||||||
|
const columns = await Promise.all(
|
||||||
|
columnChannels.map(async (channel) => {
|
||||||
|
const result = await getTimelineItems(application, channel._id, {
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proxyBaseUrl && result.items) {
|
||||||
|
result.items = result.items.map((item) =>
|
||||||
|
proxyItemImages(item, proxyBaseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
items: result.items,
|
||||||
|
paging: result.paging,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set view preference cookie
|
||||||
|
if (request.session) request.session.microsubView = "deck";
|
||||||
|
|
||||||
|
response.render("deck", {
|
||||||
|
title: "Deck",
|
||||||
|
columns,
|
||||||
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "deck",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck settings page
|
||||||
|
* @param {object} request - Express request
|
||||||
|
* @param {object} response - Express response
|
||||||
|
*/
|
||||||
|
export async function deckSettings(request, response) {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const userId = getUserId(request);
|
||||||
|
|
||||||
|
const channelList = await getChannelsWithColors(application, userId);
|
||||||
|
const deckConfig = await getDeckConfig(application, userId);
|
||||||
|
|
||||||
|
const selectedIds = deckConfig?.columns
|
||||||
|
? deckConfig.columns.map((col) => col.channelId.toString())
|
||||||
|
: channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
|
||||||
|
|
||||||
|
response.render("deck-settings", {
|
||||||
|
title: "Deck settings",
|
||||||
|
channels: channelList,
|
||||||
|
selectedIds,
|
||||||
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "deck",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save deck settings
|
||||||
|
* @param {object} request - Express request
|
||||||
|
* @param {object} response - Express response
|
||||||
|
*/
|
||||||
|
export async function saveDeckSettings(request, response) {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const userId = getUserId(request);
|
||||||
|
|
||||||
|
let { columns } = request.body;
|
||||||
|
if (!columns) columns = [];
|
||||||
|
if (!Array.isArray(columns)) columns = [columns];
|
||||||
|
|
||||||
|
await saveDeckConfig(application, userId, columns);
|
||||||
|
|
||||||
|
response.redirect(`${request.baseUrl}/deck`);
|
||||||
|
}
|
||||||
|
|
||||||
export const readerController = {
|
export const readerController = {
|
||||||
index,
|
index,
|
||||||
channels,
|
channels,
|
||||||
@@ -1086,4 +1297,8 @@ export const readerController = {
|
|||||||
actorProfile,
|
actorProfile,
|
||||||
followActorAction,
|
followActorAction,
|
||||||
unfollowActorAction,
|
unfollowActorAction,
|
||||||
|
timeline,
|
||||||
|
deck,
|
||||||
|
deckSettings,
|
||||||
|
saveDeckSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import { ObjectId } from "mongodb";
|
|||||||
|
|
||||||
import { generateChannelUid } from "../utils/jf2.js";
|
import { generateChannelUid } from "../utils/jf2.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel color palette for visual identification.
|
||||||
|
* Colors chosen for accessibility on white/light backgrounds as 4px left borders.
|
||||||
|
*/
|
||||||
|
const CHANNEL_COLORS = [
|
||||||
|
"#4A90D9", // blue
|
||||||
|
"#E5604E", // red
|
||||||
|
"#50B86C", // green
|
||||||
|
"#E8A838", // amber
|
||||||
|
"#9B59B6", // purple
|
||||||
|
"#00B8D4", // cyan
|
||||||
|
"#F06292", // pink
|
||||||
|
"#78909C", // blue-grey
|
||||||
|
"#FF7043", // deep orange
|
||||||
|
"#26A69A", // teal
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a color for a channel based on its order
|
||||||
|
* @param {number} order - Channel order index
|
||||||
|
* @returns {string} Hex color
|
||||||
|
*/
|
||||||
|
export function getChannelColor(order) {
|
||||||
|
return CHANNEL_COLORS[Math.abs(order) % CHANNEL_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
import { deleteFeedsForChannel } from "./feeds.js";
|
import { deleteFeedsForChannel } from "./feeds.js";
|
||||||
import { deleteItemsForChannel } from "./items.js";
|
import { deleteItemsForChannel } from "./items.js";
|
||||||
|
|
||||||
@@ -65,11 +91,14 @@ export async function createChannel(application, { name, userId }) {
|
|||||||
|
|
||||||
const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0;
|
const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0;
|
||||||
|
|
||||||
|
const color = getChannelColor(order);
|
||||||
|
|
||||||
const channel = {
|
const channel = {
|
||||||
uid,
|
uid,
|
||||||
name,
|
name,
|
||||||
userId,
|
userId,
|
||||||
order,
|
order,
|
||||||
|
color,
|
||||||
settings: {
|
settings: {
|
||||||
excludeTypes: [],
|
excludeTypes: [],
|
||||||
excludeRegex: undefined,
|
excludeRegex: undefined,
|
||||||
@@ -141,6 +170,56 @@ export async function getChannels(application, userId) {
|
|||||||
return channelsWithCounts;
|
return channelsWithCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channels with color field ensured (fallback for older channels without color).
|
||||||
|
* Returns full channel documents with _id, unlike getChannels() which returns simplified objects.
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {string} [userId] - User ID
|
||||||
|
* @returns {Promise<Array>} Channels with color and unread fields
|
||||||
|
*/
|
||||||
|
export async function getChannelsWithColors(application, userId) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
const itemsCollection = getItemsCollection(application);
|
||||||
|
|
||||||
|
const filter = userId ? { userId } : {};
|
||||||
|
const channels = await collection
|
||||||
|
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
||||||
|
.find(filter)
|
||||||
|
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||||
|
.sort({ order: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
|
||||||
|
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
channels.map(async (channel, index) => {
|
||||||
|
const unreadCount = await itemsCollection.countDocuments({
|
||||||
|
channelId: channel._id,
|
||||||
|
readBy: { $ne: userId },
|
||||||
|
published: { $gte: cutoffDate },
|
||||||
|
_stripped: { $ne: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
color: channel.color || getChannelColor(index),
|
||||||
|
unread: unreadCount > 0 ? unreadCount : false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notifications first, then by order
|
||||||
|
const notifications = enriched.find((c) => c.uid === "notifications");
|
||||||
|
const others = enriched.filter((c) => c.uid !== "notifications");
|
||||||
|
|
||||||
|
if (notifications) {
|
||||||
|
return [notifications, ...others];
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single channel by UID
|
* Get a single channel by UID
|
||||||
* @param {object} application - Indiekit application
|
* @param {object} application - Indiekit application
|
||||||
|
|||||||
56
lib/storage/deck.js
Normal file
56
lib/storage/deck.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Deck configuration storage
|
||||||
|
* @module storage/deck
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deck config collection
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @returns {object} MongoDB collection
|
||||||
|
*/
|
||||||
|
function getCollection(application) {
|
||||||
|
return application.collections.get("microsub_deck_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deck configuration for a user
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @returns {Promise<object|null>} Deck config or null
|
||||||
|
*/
|
||||||
|
export async function getDeckConfig(application, userId) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
return collection.findOne({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save deck configuration
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @param {Array<string>} channelIds - Ordered array of channel ObjectId strings
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function saveDeckConfig(application, userId, channelIds) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
const columns = channelIds.map((id, order) => ({
|
||||||
|
channelId: new ObjectId(id),
|
||||||
|
order,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await collection.updateOne(
|
||||||
|
{ userId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
columns,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
$setOnInsert: {
|
||||||
|
userId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -149,6 +149,69 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timeline items across ALL channels, sorted chronologically
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {object} options - Query options
|
||||||
|
* @param {string} [options.before] - Before cursor
|
||||||
|
* @param {string} [options.after] - After cursor
|
||||||
|
* @param {number} [options.limit] - Items per page
|
||||||
|
* @param {string} [options.userId] - User ID for read state
|
||||||
|
* @param {boolean} [options.showRead] - Include read items
|
||||||
|
* @param {Array<string>} [options.excludeChannelIds] - Channel IDs to exclude
|
||||||
|
* @returns {Promise<object>} { items, paging }
|
||||||
|
*/
|
||||||
|
export async function getAllTimelineItems(application, options = {}) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
const limit = parseLimit(options.limit);
|
||||||
|
|
||||||
|
// Base query - no channelId filter (cross-channel)
|
||||||
|
const baseQuery = { _stripped: { $ne: true } };
|
||||||
|
|
||||||
|
if (options.userId && !options.showRead) {
|
||||||
|
baseQuery.readBy = { $ne: options.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude specific channels if requested
|
||||||
|
if (options.excludeChannelIds?.length > 0) {
|
||||||
|
baseQuery.channelId = {
|
||||||
|
$nin: options.excludeChannelIds.map(
|
||||||
|
(id) => (typeof id === "string" ? new ObjectId(id) : id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = buildPaginationQuery({
|
||||||
|
before: options.before,
|
||||||
|
after: options.after,
|
||||||
|
baseQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sort = buildPaginationSort(options.before);
|
||||||
|
|
||||||
|
const items = await collection
|
||||||
|
// eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
|
||||||
|
.find(query)
|
||||||
|
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||||
|
.sort(sort)
|
||||||
|
.limit(limit + 1)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
if (hasMore) {
|
||||||
|
items.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const jf2Items = items.map((item) => transformToJf2(item, options.userId));
|
||||||
|
|
||||||
|
const paging = generatePagingCursors(items, limit, hasMore, options.before);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: jf2Items,
|
||||||
|
paging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract URL string from a media value
|
* Extract URL string from a media value
|
||||||
* @param {object|string} media - Media value (can be string URL or object)
|
* @param {object|string} media - Media value (can be string URL or object)
|
||||||
@@ -207,6 +270,7 @@ function transformToJf2(item, userId) {
|
|||||||
published: item.published?.toISOString(), // Convert Date to ISO string
|
published: item.published?.toISOString(), // Convert Date to ISO string
|
||||||
_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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
|
|||||||
@@ -94,6 +94,11 @@
|
|||||||
"title": "Preview",
|
"title": "Preview",
|
||||||
"subscribe": "Subscribe to this feed"
|
"subscribe": "Subscribe to this feed"
|
||||||
},
|
},
|
||||||
|
"views": {
|
||||||
|
"channels": "Channels",
|
||||||
|
"deck": "Deck",
|
||||||
|
"timeline": "Timeline"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"channelNotFound": "Channel not found",
|
"channelNotFound": "Channel not found",
|
||||||
"feedNotFound": "Feed not found",
|
"feedNotFound": "Feed not found",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||||
"version": "1.0.37",
|
"version": "1.0.38",
|
||||||
"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",
|
||||||
|
|||||||
33
views/deck-settings.njk
Normal file
33
views/deck-settings.njk
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% block reader %}
|
||||||
|
<div class="settings">
|
||||||
|
<header>
|
||||||
|
<a href="{{ baseUrl }}/deck" class="back-link">
|
||||||
|
{{ __("microsub.views.deck") }}
|
||||||
|
</a>
|
||||||
|
<h1>Deck columns</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="{{ baseUrl }}/deck/settings" method="POST">
|
||||||
|
<p>Select which channels appear as columns in your deck, and their order.</p>
|
||||||
|
|
||||||
|
<div class="deck-settings__channels">
|
||||||
|
{% for channel in channels %}
|
||||||
|
{% if channel.uid !== "notifications" %}
|
||||||
|
<label class="deck-settings__channel">
|
||||||
|
<input type="checkbox" name="columns" value="{{ channel._id }}"
|
||||||
|
{% if channel._id.toString() in selectedIds %}checked{% endif %}>
|
||||||
|
<span class="timeline-view__filter-color" style="background: {{ channel.color }}"></span>
|
||||||
|
{{ channel.name }}
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button button--primary">
|
||||||
|
Save deck configuration
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
52
views/deck.njk
Normal file
52
views/deck.njk
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% block reader %}
|
||||||
|
<div class="deck">
|
||||||
|
<header class="deck__header">
|
||||||
|
<h1>{{ __("microsub.views.deck") }}</h1>
|
||||||
|
<a href="{{ baseUrl }}/deck/settings" class="button button--secondary button--small">
|
||||||
|
Configure columns
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if columns.length > 0 %}
|
||||||
|
<div class="deck__columns">
|
||||||
|
{% for col in columns %}
|
||||||
|
<div class="deck__column" data-channel-uid="{{ col.channel.uid }}">
|
||||||
|
<div class="deck__column-header" style="border-top: 3px solid {{ col.channel.color or '#ccc' }}">
|
||||||
|
<a href="{{ baseUrl }}/channels/{{ col.channel.uid }}" class="deck__column-name">
|
||||||
|
{{ col.channel.name }}
|
||||||
|
</a>
|
||||||
|
{% if col.channel.unread %}
|
||||||
|
<span class="reader__channel-badge{% if col.channel.unread === true %} reader__channel-badge--dot{% endif %}">
|
||||||
|
{% if col.channel.unread !== true %}{{ col.channel.unread }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="deck__column-items">
|
||||||
|
{% for item in col.items %}
|
||||||
|
{% include "partials/item-card-compact.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if col.items.length === 0 %}
|
||||||
|
<p class="deck__column-empty">No unread items</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if col.paging and col.paging.after %}
|
||||||
|
<a href="{{ baseUrl }}/channels/{{ col.channel.uid }}"
|
||||||
|
class="deck__column-more button button--secondary button--small">
|
||||||
|
View more
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="reader__empty">
|
||||||
|
<p>No columns configured. Add channels to your deck.</p>
|
||||||
|
<a href="{{ baseUrl }}/deck/settings" class="button button--primary">
|
||||||
|
Configure deck
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,5 +6,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
|
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
|
||||||
|
{% include "partials/view-switcher.njk" %}
|
||||||
{% block reader %}{% endblock %}
|
{% block reader %}{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
37
views/partials/item-card-compact.njk
Normal file
37
views/partials/item-card-compact.njk
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{# Compact item card for deck columns #}
|
||||||
|
<article class="item-card-compact{% if item._is_read %} item-card-compact--read{% endif %}"
|
||||||
|
data-item-id="{{ item._id }}">
|
||||||
|
<a href="{{ readerBaseUrl }}/item/{{ item._id }}" class="item-card-compact__link">
|
||||||
|
{% if item.photo and item.photo.length > 0 %}
|
||||||
|
<img src="{{ item.photo[0] }}"
|
||||||
|
alt=""
|
||||||
|
class="item-card-compact__photo"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-compact__body">
|
||||||
|
{% if item.name %}
|
||||||
|
<h4 class="item-card-compact__title">{{ item.name }}</h4>
|
||||||
|
{% elif item.content %}
|
||||||
|
<p class="item-card-compact__text">
|
||||||
|
{% if item.content.text %}{{ item.content.text | truncate(80) }}{% elif item.content.html %}{{ item.content.html | safe | striptags | truncate(80) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-compact__meta">
|
||||||
|
{% if item._source %}
|
||||||
|
<span class="item-card-compact__source">{{ item._source.name or item._source.url }}</span>
|
||||||
|
{% elif item.author %}
|
||||||
|
<span class="item-card-compact__source">{{ item.author.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.published %}
|
||||||
|
<time datetime="{{ item.published }}" class="item-card-compact__date">
|
||||||
|
{{ item.published | date("PP") }}
|
||||||
|
</time>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not item._is_read %}
|
||||||
|
<span class="item-card-compact__unread" aria-label="Unread"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
25
views/partials/view-switcher.njk
Normal file
25
views/partials/view-switcher.njk
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{# View mode switcher - icon toolbar #}
|
||||||
|
<nav class="view-switcher" aria-label="View mode">
|
||||||
|
<a href="{{ readerBaseUrl }}/channels"
|
||||||
|
class="view-switcher__button{% if activeView === 'channels' %} view-switcher__button--active{% endif %}"
|
||||||
|
title="Channels">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="{{ readerBaseUrl }}/deck"
|
||||||
|
class="view-switcher__button{% if activeView === 'deck' %} view-switcher__button--active{% endif %}"
|
||||||
|
title="Deck">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="{{ readerBaseUrl }}/timeline"
|
||||||
|
class="view-switcher__button{% if activeView === 'timeline' %} view-switcher__button--active{% endif %}"
|
||||||
|
title="Timeline">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="2" x2="12" y2="22"/><polyline points="19 15 12 22 5 15"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
100
views/timeline.njk
Normal file
100
views/timeline.njk
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% block reader %}
|
||||||
|
<div class="timeline-view">
|
||||||
|
<header class="timeline-view__header">
|
||||||
|
<h1>{{ __("microsub.views.timeline") }}</h1>
|
||||||
|
<div class="timeline-view__actions">
|
||||||
|
{% if channels.length > 0 %}
|
||||||
|
<details class="timeline-view__filter">
|
||||||
|
<summary class="button button--secondary button--small">
|
||||||
|
Filter channels
|
||||||
|
</summary>
|
||||||
|
<form action="{{ baseUrl }}/timeline" method="GET" class="timeline-view__filter-form">
|
||||||
|
{% for ch in channels %}
|
||||||
|
{% if ch.uid !== "notifications" %}
|
||||||
|
<label class="timeline-view__filter-label">
|
||||||
|
<input type="checkbox" name="exclude" value="{{ ch._id }}"
|
||||||
|
{% if excludeIds and ch._id.toString() in excludeIds %}checked{% endif %}>
|
||||||
|
<span class="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="timeline" id="timeline">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="timeline-view__item" style="border-left: 4px solid {{ item._channelColor or '#ccc' }}">
|
||||||
|
{% include "partials/item-card.njk" %}
|
||||||
|
{% if item._channelName %}
|
||||||
|
<span class="timeline-view__channel-label" style="color: {{ item._channelColor or '#888' }}">
|
||||||
|
{{ item._channelName }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if paging %}
|
||||||
|
<nav class="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="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('.item-card'));
|
||||||
|
let currentIndex = -1;
|
||||||
|
|
||||||
|
function focusItem(index) {
|
||||||
|
if (items[currentIndex]) items[currentIndex].classList.remove('item-card--focused');
|
||||||
|
currentIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||||
|
if (items[currentIndex]) {
|
||||||
|
items[currentIndex].classList.add('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('.item-card__link');
|
||||||
|
if (link) link.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user