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:
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 %}
|
||||
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
|
||||
{% include "partials/view-switcher.njk" %}
|
||||
{% block reader %}{% 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