diff --git a/assets/styles.css b/assets/styles.css index 5790b96..6c7a4d1 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -1014,3 +1014,290 @@ background: #7c3aed20; 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); +} diff --git a/index.js b/index.js index 56a9316..72d8882 100644 --- a/index.js +++ b/index.js @@ -132,6 +132,10 @@ export default class MicrosubEndpoint { readerRouter.post("/actor/unfollow", readerController.unfollowActorAction); readerRouter.post("/api/mark-read", readerController.markAllRead); 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); return router; @@ -171,6 +175,7 @@ export default class MicrosubEndpoint { indiekit.addCollection("microsub_notifications"); indiekit.addCollection("microsub_muted"); indiekit.addCollection("microsub_blocked"); + indiekit.addCollection("microsub_deck_config"); console.info("[Microsub] Registered MongoDB collections"); diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index dcd8bef..d0f9699 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -9,6 +9,7 @@ import { ObjectId } from "mongodb"; import { refreshFeedNow } from "../polling/scheduler.js"; import { getChannels, + getChannelsWithColors, getChannel, createChannel, updateChannelSettings, @@ -24,6 +25,7 @@ import { } from "../storage/feeds.js"; import { getTimelineItems, + getAllTimelineItems, getItemById, markItemsRead, countReadItems, @@ -36,6 +38,7 @@ import { validateExcludeRegex, } from "../utils/validation.js"; import { proxyItemImages } from "../media/proxy.js"; +import { getDeckConfig, saveDeckConfig } from "../storage/deck.js"; /** * Reader index - redirect to channels @@ -43,7 +46,10 @@ import { proxyItemImages } from "../media/proxy.js"; * @param {object} response - Express 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); + if (request.session) request.session.microsubView = "channels"; + response.render("reader", { - title: request.__("microsub.reader.title"), + title: request.__("microsub.views.channels"), channels: channelList, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -73,6 +83,8 @@ export async function newChannel(request, response) { response.render("channel-new", { title: request.__("microsub.channels.new"), baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -143,6 +155,8 @@ export async function channel(request, response) { readCount, showRead: showReadItems, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -168,6 +182,8 @@ export async function settings(request, response) { }), channel: channelDocument, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -255,6 +271,8 @@ export async function feeds(request, response) { channel: channelDocument, feeds: feedList, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -341,6 +359,8 @@ export async function item(request, response) { item: itemDocument, channel, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -451,6 +471,8 @@ export async function compose(request, response) { bookmarkOf: ensureString(bookmarkOf || bookmark), syndicationTargets, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -624,6 +646,8 @@ export async function searchPage(request, response) { title: request.__("microsub.search.title"), channels: channelList, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -660,6 +684,8 @@ export async function searchFeeds(request, response) { discoveryError, searched: true, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -691,6 +717,8 @@ export async function subscribe(request, response) { query: url, validationError: validation.error, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -781,6 +809,8 @@ export async function editFeedForm(request, response) { channel: channelDocument, feed, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -816,6 +846,8 @@ export async function updateFeedUrl(request, response) { feed, error: validation.error, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } @@ -995,6 +1027,8 @@ export async function actorProfile(request, response) { isFollowing, canFollow, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", }); } catch (error) { console.error(`[Microsub] Actor profile fetch failed: ${error.message}`); @@ -1006,6 +1040,8 @@ export async function actorProfile(request, response) { isFollowing, canFollow, baseUrl: request.baseUrl, + readerBaseUrl: request.baseUrl, + activeView: "channels", 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 = { index, channels, @@ -1086,4 +1297,8 @@ export const readerController = { actorProfile, followActorAction, unfollowActorAction, + timeline, + deck, + deckSettings, + saveDeckSettings, }; diff --git a/lib/storage/channels.js b/lib/storage/channels.js index 15297c9..5968f0a 100644 --- a/lib/storage/channels.js +++ b/lib/storage/channels.js @@ -7,6 +7,32 @@ import { ObjectId } from "mongodb"; 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 { 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 color = getChannelColor(order); + const channel = { uid, name, userId, order, + color, settings: { excludeTypes: [], excludeRegex: undefined, @@ -141,6 +170,56 @@ export async function getChannels(application, userId) { 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} 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 * @param {object} application - Indiekit application diff --git a/lib/storage/deck.js b/lib/storage/deck.js new file mode 100644 index 0000000..8ec446b --- /dev/null +++ b/lib/storage/deck.js @@ -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} 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} channelIds - Ordered array of channel ObjectId strings + * @returns {Promise} + */ +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 }, + ); +} diff --git a/lib/storage/items.js b/lib/storage/items.js index 227e2a2..31369e1 100644 --- a/lib/storage/items.js +++ b/lib/storage/items.js @@ -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} [options.excludeChannelIds] - Channel IDs to exclude + * @returns {Promise} { 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 * @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 _id: item._id.toString(), _is_read: userId ? item.readBy?.includes(userId) : false, + _channelId: item.channelId?.toString(), }; // Optional fields diff --git a/locales/en.json b/locales/en.json index 678d624..1108b16 100644 --- a/locales/en.json +++ b/locales/en.json @@ -94,6 +94,11 @@ "title": "Preview", "subscribe": "Subscribe to this feed" }, + "views": { + "channels": "Channels", + "deck": "Deck", + "timeline": "Timeline" + }, "error": { "channelNotFound": "Channel not found", "feedNotFound": "Feed not found", diff --git a/package.json b/package.json index 76f935d..098e7a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit", diff --git a/views/deck-settings.njk b/views/deck-settings.njk new file mode 100644 index 0000000..83daa36 --- /dev/null +++ b/views/deck-settings.njk @@ -0,0 +1,33 @@ +{% extends "layouts/reader.njk" %} + +{% block reader %} +
+
+ + {{ __("microsub.views.deck") }} + +

Deck columns

+
+ +
+

Select which channels appear as columns in your deck, and their order.

+ +
+ {% for channel in channels %} + {% if channel.uid !== "notifications" %} + + {% endif %} + {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/views/deck.njk b/views/deck.njk new file mode 100644 index 0000000..f44d7c8 --- /dev/null +++ b/views/deck.njk @@ -0,0 +1,52 @@ +{% extends "layouts/reader.njk" %} + +{% block reader %} +
+
+

{{ __("microsub.views.deck") }}

+ + Configure columns + +
+ + {% if columns.length > 0 %} +
+ {% for col in columns %} +
+
+ + {{ col.channel.name }} + + {% if col.channel.unread %} + + {% if col.channel.unread !== true %}{{ col.channel.unread }}{% endif %} + + {% endif %} +
+
+ {% for item in col.items %} + {% include "partials/item-card-compact.njk" %} + {% endfor %} + {% if col.items.length === 0 %} +

No unread items

+ {% endif %} + {% if col.paging and col.paging.after %} + + View more + + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+

No columns configured. Add channels to your deck.

+ + Configure deck + +
+ {% endif %} +
+{% endblock %} diff --git a/views/layouts/reader.njk b/views/layouts/reader.njk index 7928557..d031a6c 100644 --- a/views/layouts/reader.njk +++ b/views/layouts/reader.njk @@ -6,5 +6,6 @@ {% block content %} +{% include "partials/view-switcher.njk" %} {% block reader %}{% endblock %} {% endblock %} diff --git a/views/partials/item-card-compact.njk b/views/partials/item-card-compact.njk new file mode 100644 index 0000000..c863f08 --- /dev/null +++ b/views/partials/item-card-compact.njk @@ -0,0 +1,37 @@ +{# Compact item card for deck columns #} +
+ + {% if item.photo and item.photo.length > 0 %} + + {% endif %} +
+ {% if item.name %} +

{{ item.name }}

+ {% elif item.content %} +

+ {% if item.content.text %}{{ item.content.text | truncate(80) }}{% elif item.content.html %}{{ item.content.html | safe | striptags | truncate(80) }}{% endif %} +

+ {% endif %} +
+ {% if item._source %} + {{ item._source.name or item._source.url }} + {% elif item.author %} + {{ item.author.name }} + {% endif %} + {% if item.published %} + + {% endif %} +
+
+ {% if not item._is_read %} + + {% endif %} +
+
diff --git a/views/partials/view-switcher.njk b/views/partials/view-switcher.njk new file mode 100644 index 0000000..6c173ad --- /dev/null +++ b/views/partials/view-switcher.njk @@ -0,0 +1,25 @@ +{# View mode switcher - icon toolbar #} + diff --git a/views/timeline.njk b/views/timeline.njk new file mode 100644 index 0000000..5bea349 --- /dev/null +++ b/views/timeline.njk @@ -0,0 +1,100 @@ +{% extends "layouts/reader.njk" %} + +{% block reader %} +
+
+

{{ __("microsub.views.timeline") }}

+
+ {% if channels.length > 0 %} +
+ + Filter channels + +
+ {% for ch in channels %} + {% if ch.uid !== "notifications" %} + + {% endif %} + {% endfor %} + +
+
+ {% endif %} +
+
+ + {% if items.length > 0 %} +
+ {% for item in items %} +
+ {% include "partials/item-card.njk" %} + {% if item._channelName %} + + {{ item._channelName }} + + {% endif %} +
+ {% endfor %} +
+ + {% if paging %} + + {% endif %} + + {% else %} +
+

{{ __("microsub.reader.empty") }}

+
+ {% endif %} +
+ + +{% endblock %}