templates/index.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token_for(request) }}" />
{# Installable to the home screen. Worth more than a convenience: installed
there is no browser tab, so Back at the root backgrounds the app instead of
closing the tab and losing the session — which is something the in-page
history guard cannot promise, because Chrome deliberately limits how far a
page may trap Back. #}
<link rel="manifest" href="/static/manifest.webmanifest?v={{ static_asset_version }}" />
<meta name="theme-color" content="#15191d" />
<title>{{ page_title or 'Lectio' }}</title>
<script>
// ── Resume where you left off ────────────────────────────────────────────
// Runs before anything renders, so a resume is a redirect rather than a
// visible jump.
//
// Every attempt to stop Back leaving has failed on purpose, at two levels:
// Chrome marks script-pushed history entries skippable to defeat back-traps,
// and Android exits any app at its root. Installing as a WebAPK does not
// change the second. So this stops trying to keep you in the app and makes
// leaving cost nothing instead — reopening lands exactly where you were.
//
// Escape hatches, both deliberate: a URL that already carries a query is an
// explicit destination and is never overridden, and the Lectio wordmark
// links to /?home=1 so "take me to the top" still exists.
window.__lectioResume = null;
(function () {
try {
var KEY = 'lectio-last-position';
// Long enough to cover a weekend away; past that, dropping you back into
// a half-read article is a surprise rather than a convenience.
var MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
var raw = window.localStorage.getItem(KEY);
if (!raw) return;
var saved = JSON.parse(raw);
if (!saved || !saved.url || !saved.ts) return;
if (Date.now() - saved.ts > MAX_AGE_MS) return;
var target = new URL(saved.url, window.location.origin);
if (target.origin !== window.location.origin) return;
// Already where we want to be: nothing to redirect. (The payload is kept on window.__lectioResume, informational only.)
if (target.pathname + target.search === window.location.pathname + window.location.search) {
window.__lectioResume = saved;
return;
}
// Only a bare "/" is treated as "just opened the app".
if (window.location.pathname !== '/' || window.location.search) return;
if (!target.search) return; // nothing more specific than the landing
// replace(), not assign(): resuming must not leave a history entry that
// Back would immediately bounce off.
window.location.replace(target.href);
} catch (e) { /* resume is a convenience; never block the app */ }
})();
window.CURRENT_SORT_BY = "{{ selected_sort_by }}";
window.STATIC_ASSET_VERSION = "{{ static_asset_version }}";
window.__lectioTheme = window.localStorage.getItem('lectio-theme') || 'dark';
window.HIGHLIGHT_RULES = {{ highlight_rules | tojson }};
window.EMAIL_CONTACTS = {{ email_contacts | tojson }};
window.EMAIL_CONFIGURED = {{ email_configured | tojson }};
window.YT_OAUTH_CONNECTED = {{ yt_oauth_connected | tojson }};
window.YT_FOLDER_ID = {{ yt_folder_id | tojson }};
window.YT_EMBED_ACCOUNT_FEATURES = {{ yt_embed_account_features | tojson }};
window.INSTAPAPER_CONFIGURED = {{ instapaper_configured | tojson }};
window.QUIRE_CONFIGURED = {{ quire_configured | tojson }};
window.QUIRE_PROJECT_NAME = {{ quire_project_name | tojson }};
window.PROFILE_NAME = {{ profile_name | tojson }};
window.PROFILE_EMAIL = {{ profile_email | tojson }};
window.SELECTED_FOLDER_ID = "{{ selected_folder_id }}";
window.YT_SYNC_LAST_AT = {{ youtube_sync_last_at | tojson }};
window.YT_SYNC_LAST_RESULT = {{ youtube_sync_last_result | tojson }};
window.SUBSCRIBE_URL = {{ subscribe_url | tojson }};
window.NO_RSS_URL = {{ no_rss_url | tojson }};
window.FORCE_URL = {{ force_url | tojson }};
window.PORTRAIT_IMG_MAX_WIDTH = {{ portrait_img_max_width | tojson }};
window.MARK_READ_ON_NEW_TAB = {{ mark_read_on_new_tab | tojson }};
</script>
<script>
// CSRF shim — installs BEFORE any other handler registers.
// Reads the token from the <meta name="csrf-token"> tag and:
// 1) Wraps fetch() to add X-CSRF-Token header for same-origin
// POST/PUT/PATCH/DELETE (covers all SPA async submits).
// 2) Adds a capture-phase submit listener that injects a hidden
// `_csrf` input into POST forms that don't already have one
// (covers native form submits with no JS interception).
(function setupCsrf() {
const meta = document.querySelector('meta[name="csrf-token"]');
const token = meta ? meta.content : '';
if (!token) return;
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const _origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
let method = (init.method || '').toUpperCase();
if (!method && input && typeof input === 'object' && input.method) {
method = String(input.method).toUpperCase();
}
if (!method) method = 'GET';
if (UNSAFE.has(method)) {
let sameOrigin = true;
try {
const url = new URL(typeof input === 'string' ? input : input.url, window.location.origin);
sameOrigin = url.origin === window.location.origin;
} catch (e) { sameOrigin = true; }
if (sameOrigin) {
const h = new Headers(init.headers || {});
if (!h.has('X-CSRF-Token')) h.set('X-CSRF-Token', token);
init.headers = h;
}
}
return _origFetch(input, init);
};
function injectCsrfInto(form) {
if (!(form instanceof HTMLFormElement)) return;
if ((form.method || '').toLowerCase() !== 'post') return;
if (!form.querySelector('input[name="_csrf"]')) {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = '_csrf';
inp.value = token;
form.appendChild(inp);
}
}
// Native submit-event path (handles user-driven submits and requestSubmit()).
document.addEventListener('submit', function (ev) {
injectCsrfInto(ev.target);
}, true);
// HTMLFormElement.submit() bypasses the submit event entirely, so wrap it
// so programmatic submits (e.g. context-menu refresh, OPML upload) still
// get a token before the native submit is invoked.
const _origFormSubmit = HTMLFormElement.prototype.submit;
HTMLFormElement.prototype.submit = function () {
injectCsrfInto(this);
return _origFormSubmit.apply(this, arguments);
};
})();
</script>
<script>
document.write(`<link id="theme-stylesheet" rel="stylesheet" href="/static/themes/${window.__lectioTheme}.css?v=${window.STATIC_ASSET_VERSION}" />`);
// List-thumbnail fallback: /thumb proxies images server-side, but some hosts
// IP-block our server (e.g. Cloudflare 403) while serving the browser's own IP
// fine. On /thumb failure, retry once with the direct image URL so the
// thumbnail still loads (CSS object-fit:cover sizes the un-resized image).
// Defined here (global, pre-body) so it exists before any onerror fires.
window.thumbImgFallback = function (img) {
const direct = img.dataset.direct || '';
if (direct && !img.dataset.triedDirect && /^https?:\/\//i.test(direct)) {
img.dataset.triedDirect = '1';
img.src = direct;
return;
}
img.parentElement.classList.add('is-empty');
img.style.display = 'none';
};
</script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1" />
<link rel="stylesheet" href="/static/vendor/katex-0.19.1/katex.min.css" />
<link rel="stylesheet" href="/static/style.css?v={{ static_asset_version }}" />
<style>
.entry-pane-title-link { -webkit-line-clamp: 5 !important; }
</style>
</head>
<body data-tree-filter="{{ 'unread' if selected_read_filter == 'unread' and not selected_star_only else 'all' }}">
<div id="pane-load-bar" class="pane-load-bar" aria-hidden="true"></div>
<div id="pane-load-hint" class="pane-load-hint" hidden>Still loading — this can take a while for hard-to-fetch sites.</div>
<form id="opml-import-form" method="post" action="/opml/import" enctype="multipart/form-data" hidden>
{{ csrf_input(request)|safe }}
<input id="opml-file-input" type="file" name="opml_file" accept=".opml,.xml,text/xml,application/xml" required />
</form>
<form id="takeout-import-form" method="post" action="/takeout/import" enctype="multipart/form-data" hidden>
{{ csrf_input(request)|safe }}
<input id="takeout-file-input" type="file" name="takeout_file" accept=".zip,application/zip" required />
</form>
<form id="instapaper-import-form" method="post" action="/instapaper/import" enctype="multipart/form-data" hidden>
{{ csrf_input(request)|safe }}
<input id="instapaper-file-input" type="file" name="instapaper_file" accept=".csv,text/csv" required />
</form>
<div id="medium-pane-backdrop" class="medium-pane-backdrop" hidden></div>
<main class="panes">
<section class="pane pane-folders">
<header class="folders-pane-header">
<details class="hamburger-menu app-menu topbar-menu topbar-overflow-menu folders-pane-menu">
<summary class="topbar-icon-btn topbar-overflow-btn" title="Menu" aria-label="Main menu"><img id="menu-avatar-img" class="menu-avatar-img" src="{{ profile_avatar_url }}" alt="" /></summary>
<div class="menu-popover anchor-left">
<div class="menu-item menu-theme-row" role="group" aria-label="Create">
<button type="button" id="menu-add-feed-btn" class="menu-theme-btn" title="Add a feed" aria-label="Add a feed"><span class="material-symbols-rounded" aria-hidden="true">rss_feed</span><span class="menu-theme-label">Add Feed</span></button>
<button type="button" id="menu-save-article-btn" class="menu-theme-btn" title="Save an article to read later" aria-label="Save an article to read later"><span class="material-symbols-rounded" aria-hidden="true">bookmark_add</span><span class="menu-theme-label">Save Article</span></button>
</div>
<button type="button" class="menu-item" data-toggle-panel="global-note-modal" title="Note" aria-label="Note"><span class="material-symbols-rounded" aria-hidden="true">sticky_note_2</span> Note</button>
<hr class="menu-divider" />
<div class="flyout-item">
<div class="flyout-trigger menu-item"><span class="flyout-label">Auto Refresh: {% if auto_refresh_enabled %}{{ (auto_refresh_minutes|string) + 'm' if auto_refresh_minutes < 60 else (auto_refresh_minutes // 60)|string + 'h' }}{% else %}Off{% endif %}</span><span class="flyout-arrow material-symbols-rounded" aria-hidden="true">chevron_right</span></div>
<div class="flyout-panel">
<form method="post" action="/settings/auto-refresh">
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
{% if selected_feed_url %}<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />{% endif %}
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
{% for option_minutes in auto_refresh_option_minutes %}
<button type="submit" class="menu-item refresh-option {% if option_minutes == auto_refresh_minutes %}active{% endif %}" name="refresh_minutes" value="{{ option_minutes }}">{% if option_minutes == 0 %}Off{% else %}{{ (option_minutes|string) + 'm' if option_minutes < 60 else (option_minutes // 60)|string + 'h' }}{% endif %}</button>
{% endfor %}
</form>
</div>
</div>
<button type="button" id="folders-unread-toggle" class="menu-item folders-unread-toggle" title="Show only folders with unread posts" aria-label="Show only folders with unread posts" aria-pressed="false"><span class="material-symbols-rounded" aria-hidden="true">filter_alt</span> Unread only</button>
<hr class="menu-divider" />
<div class="menu-item menu-theme-row" role="group" aria-label="Theme">
<button type="button" class="menu-theme-btn" data-menu-theme="dark" title="Dark theme" aria-label="Dark theme"><span class="material-symbols-rounded" aria-hidden="true">dark_mode</span><span class="menu-theme-label">Dark</span></button>
<button type="button" class="menu-theme-btn" data-menu-theme="light" title="Light theme" aria-label="Light theme"><span class="material-symbols-rounded" aria-hidden="true">light_mode</span><span class="menu-theme-label">Light</span></button>
<a class="menu-theme-btn" href="/read?scope=feeds" title="e-ink Read Mode" aria-label="e-ink Read Mode"><span class="material-symbols-rounded" aria-hidden="true">chrome_reader_mode</span><span class="menu-theme-label">eInk</span></a>
</div>
<button type="button" id="menu-settings-btn" class="menu-item"><span class="material-symbols-rounded" aria-hidden="true">settings</span> Settings</button>
{% if is_admin %}
<a class="menu-item" href="/administration"><span class="material-symbols-rounded" aria-hidden="true">admin_panel_settings</span> Administration</a>
{% endif %}
<a class="menu-item" href="/logout"><span class="material-symbols-rounded" aria-hidden="true">logout</span> Sign out</a>
</div>
</details>
{# ?home=1 rather than a bare "/": a bare "/" is what the resume handler
treats as "just opened the app" and redirects to your last position,
which would make the wordmark unable to reach the top. #}
<a class="menu-brand topbar-brand" href="/?home=1">Lectio</a>
{% if debug_mode %}
<div class="topbar-debug" id="topbar-debug">
<button type="button" class="topbar-debug-btn" id="debug-bypass-cache-btn" title="Toggle cache bypass for current feed">
<span class="material-symbols-rounded" aria-hidden="true">no_photography</span>
<span class="topbar-debug-btn-label">Bypass Cache</span>
</button>
</div>
{% endif %}
<button id="folders-collapse-btn" class="folders-collapse-btn folders-pane-header-collapse-btn" type="button"
aria-label="Collapse folders pane" title="Collapse folders pane">
<span class="folders-btn-icon folders-btn-expanded-icon material-symbols-rounded" aria-hidden="true">push_pin</span>
<span class="folders-btn-icon folders-btn-strip-icon material-symbols-rounded" aria-hidden="true">chevron_right</span>
<span class="folders-btn-icon folders-btn-popout-icon material-symbols-rounded" aria-hidden="true">push_pin</span>
</button>
</header>
<nav class="tree{% if selected_star_only %} saved-mode{% endif %}"{% if root_folder_row %} data-root-folder-id="{{ root_folder_row.id }}" data-root-folder-name="{{ root_folder_row.name }}"{% endif %}>
{% if root_folder_row %}
{% set tree_read_filter = selected_resume_read_filter if selected_read_filter == 'history' else selected_read_filter %}
{# Compact query fragments for tree/folder/feed links — omit default values #}
{# 'starred' is the Inbox's node default, not a choice to carry around:
propagating it would hand every other Saved node an order most of its
rows cannot support, and the index persists what it is handed. Tree
links fall back to the scope's own remembered sort instead. #}
{% set _tree_sort_by = 'post' if selected_sort_by == 'starred' else selected_sort_by %}
{% set _tree_sort_dir = 'asc' if selected_sort_by == 'starred' else selected_sort_dir %}
{% set _tree_sq = (('&sort_by=' ~ _tree_sort_by) if _tree_sort_by != 'post' else '') ~ (('&sort_dir=' ~ _tree_sort_dir) if _tree_sort_dir != 'asc' else '') %}
{# Feeds-tree links never carry star mode: Saved Articles and All Feeds
are the mode switches, and each block is only visible in its own mode. #}
{# The Feeds tree's OWN links must never carry the Saved scope's active
sort either, for the same reason 'starred' alone used to get special-
cased above — 'size' means nothing outside Saved, but it isn't
REJECTED there, only its sort_by half is (normalize_sort_by falls
it back to "post"); its sort_dir half (just "desc"/"asc", valid in
any scope) sailed through untouched and got PERSISTED into the
Feeds scope's own remembered direction the moment a Feeds-tree link
built from _tree_sq (stamped with Saved's current order) was
clicked while browsing Saved sorted by size. Reported live
2026-09-13 as "my FEEDS sort keeps getting reset to Pub new" (really
Pub OLD silently flipping to Pub NEW — sort_by stayed "post" the
whole time, only sort_dir was ever cross-contaminated). Omitting
the params entirely when the current view is Saved is safe: the
destination just reads that scope's own remembered order fresh,
the same as any other omitted-default link already does. #}
{% set _feeds_tree_sq = '' if selected_star_only else _tree_sq %}
{% set _tree_rfq = (('&read_filter=' ~ tree_read_filter) if tree_read_filter != 'all' else '') ~ (('&resume_read_filter=' ~ selected_resume_read_filter) if selected_resume_read_filter != tree_read_filter else '') %}
{# Saved-mode links carry only the mode: each mode's read filter is
remembered separately (per-mode cookie/localStorage, resolved
server-side; the scope-link stamper is exempt on mode switches). #}
{% set _saved_rfq = '&star_only=1' ~ (('&read_filter=' ~ selected_read_filter) if (selected_star_only and selected_read_filter != 'all') else '') %}
<div class="tree-block root-tree-block">
{# Feeds | Saved scope tabs (like Read Mode). Feeds = normal view;
Saved = the starred view (star_only=1, as the toolbar's Starred
filter). No counters on the tabs. The .root-item/.saved-item classes
+ data attrs are preserved for the SPA scope/badge machinery. #}
{# The tabs are expand-only landings (home=1): switch mode + show the
tree, load no posts (All can be huge) — pick All/a folder explicitly. #}
<div class="scope-tabs" role="tablist">
<a
{# No sort in a scope-switch link: Feeds and Saved remember their
order separately, and an explicit sort_by here would overwrite
the target view's remembered value with this one's. #}
href="/?folder_id={{ root_folder_row.id }}&home=1"
class="tree-item root-item scope-tab {% if not selected_star_only %}active{% endif %}"
data-folder-id="{{ root_folder_row.id }}"
data-folder-name="{{ root_folder_row.name }}"
data-folder-depth="{{ root_folder_row.depth }}"
title="Feeds"
>
<span class="material-symbols-rounded saved-items-icon" aria-hidden="true">menu</span>
<span class="name">Feeds</span>
</a>
{# Unlike Feeds (no single obvious start view -> load nothing),
Saved has one: the Inbox. Landing on an empty scope-tab shell
just made you click Inbox immediately afterward. #}
<a
href="/?folder_id={{ root_folder_row.id }}&star_only=1&kept=starred"
class="tree-item saved-item scope-tab {% if selected_star_only %}active{% endif %}"
data-folder-id="{{ root_folder_row.id }}"
data-saved-items="true"
title="Saved (starred)"
>
<span class="material-symbols-rounded saved-items-icon" aria-hidden="true">star</span>
<span class="name">Saved</span>
</a>
</div>
{# Folders that hold saved articles — the Saved counterpart of the
feeds tree; visible only in Saved mode (badges = total saved).
Collapsible, so Saved's two sections (Folders and Tags) can each be
folded away independently. The Feeds tree has no such header: it is
the only section there, and a collapse control for the whole of it
would just be a way to empty the sidebar. #}
<div class="saved-section-header-row"{% if not selected_star_only %} hidden{% endif %}>
<button type="button" class="tags-header section-block-header"
id="saved-folders-header-btn">
Folders
<span class="tags-collapse-chevron material-symbols-rounded" aria-hidden="true">expand_more</span>
</button>
</div>
<div class="tree-children saved-tree-children"{% if not selected_star_only %} hidden{% endif %}>
{# Inbox: everything starred, newest star first — however the star
was made (by hand, by a save_article rule, by the extension). It
was the 'inbox' TAG bucket, which only the automation ever wrote,
so it showed 3 of 9,577 stars and disagreed with Read Mode's
Inbox for the same word. `kept=starred` is what narrows it to the
star axis and gives it its most-recently-starred default order;
no other Saved link carries it, so leaving the Inbox restores the
order you had. The 'inbox' tag is still written and still listed
under Tags — it just no longer defines this node. #}
<div class="tree-folder-group">
<div class="tree-folder-row">
<span class="tree-toggle-spacer"></span>
<a
href="/?folder_id={{ root_folder_row.id }}{{ _saved_rfq }}&kept=starred"
class="tree-item child-item saved-folder-item saved-inbox-item {% if inbox_view %}active{% endif %}"
{# Without this the SPA's active-state pass cannot match this row
at all (it keys off data-folder-id), so a second after the
click it moved the highlight to "All" — which matches on
star mode alone. The server had it right; the client
overrode it. #}
data-folder-id="{{ root_folder_row.id }}"
>
<span class="name">Inbox</span>
{% if inbox_total %}<span class="count">{{ inbox_total }}</span>{% endif %}
</a>
</div>
</div>
{# "All": the whole starred backlog — what the header itself used to
load. Sits at folder level so opening it is a deliberate click. #}
<div class="tree-folder-group">
<div class="tree-folder-row">
<span class="tree-toggle-spacer"></span>
<a
href="/?folder_id={{ root_folder_row.id }}{{ _tree_sq }}{{ _saved_rfq }}"
class="tree-item child-item saved-folder-item saved-all-item {% if selected_star_only and not selected_saved_home and root_folder_row.id == selected_folder_id and not selected_feed_url and not selected_tag %}active{% endif %}"
data-folder-id="{{ root_folder_row.id }}"
>
<span class="name">All</span>
{% set _saved_total = saved_counts_by_folder.get(root_folder_row.id, 0) %}
{% if _saved_total %}<span class="count">{{ _saved_total }}</span>{% endif %}
</a>
</div>
</div>
{% for row in child_folder_rows %}
{% set saved_in_folder = saved_counts_by_folder.get(row.id, 0) %}
{% if saved_in_folder %}
<div class="tree-folder-group">
<div class="tree-folder-row">
<span class="tree-toggle-spacer"></span>
{# No data-folder-name: the folder pickers iterate
[data-folder-id][data-folder-name] rows and would see
these as duplicates of the feeds-tree folders. #}
<a
href="/?folder_id={{ row.id }}{{ _tree_sq }}{{ _saved_rfq }}"
class="tree-item child-item saved-folder-item {% if row.virtual %}virtual-folder {% endif %}{% if selected_star_only and row.id == selected_folder_id and not selected_feed_url %}active{% endif %}"
data-folder-id="{{ row.id }}"
>
<span class="name">{{ row.name }}</span>
<span class="count">{{ saved_in_folder }}</span>
</a>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="tree-children feeds-tree-children"{% if selected_star_only %} hidden{% endif %}>
{# "All": every feed — what the old All Feeds root row loaded. Sits at
folder level so opening it is a deliberate click (the Feeds tab
itself is an expand-only landing). #}
<div class="tree-folder-group feeds-all-group" data-unread-count="{{ root_folder_row.unread_count }}">
<div class="tree-folder-row">
<span class="tree-toggle-spacer"></span>
<a
href="/?folder_id={{ root_folder_row.id }}{{ _feeds_tree_sq }}{{ _tree_rfq }}"
class="tree-item child-item feeds-all-item {% if not selected_star_only and not selected_home and root_folder_row.id == selected_folder_id and not selected_feed_url and not selected_tag %}active{% endif %}"
data-folder-id="{{ root_folder_row.id }}"
>
<span class="name">All</span>
{% if root_folder_row.unread_count %}<span class="count">{{ root_folder_row.unread_count }}</span>{% endif %}
</a>
</div>
</div>
{% for row in child_folder_rows %}
{% if not (row.virtual and not row.get('has_display_feeds', True)) %}
{# folder_feeds is populated ONLY for the folders whose rows render in
this response (the selected one and the root). Every other folder
fills from /tree/folder-feeds/{id} on first expand, so use
folder_has_feeds — which is computed for all of them — to decide
whether there is anything to expand. Testing folder_feeds here
would hide the toggle on every unselected folder. #}
{% set folder_feeds = feeds_by_folder.get(row.id, []) %}
<div class="tree-folder-group" data-unread-count="{{ row.unread_count }}">
<div class="tree-folder-row">
{% if folder_has_feeds.get(row.id) %}
<button
type="button"
class="tree-toggle"
data-tree-target="folder-feeds-{{ row.id }}"
aria-expanded="false"
title="Toggle feeds"
><span class="material-symbols-rounded" aria-hidden="true">chevron_right</span></button>
{% else %}
<span class="tree-toggle-spacer"></span>
{% endif %}
<a
href="/?folder_id={{ row.id }}{{ _feeds_tree_sq }}{{ _tree_rfq }}"
class="tree-item child-item {% if row.virtual %}virtual-folder {% endif %}{% if row.id == selected_folder_id and not selected_feed_url %}active{% endif %}"
data-folder-id="{{ row.id }}"
data-folder-name="{{ row.name }}"
data-folder-depth="{{ row.depth }}"
{% if row.virtual %}data-virtual="1"{% endif %}
>
<span class="name">{{ row.name }}</span>
{% if row.unread_count %}<span class="count">{{ row.unread_count }}</span>{% endif %}
</a>
</div>
{% if folder_has_feeds.get(row.id) %}
{% if row.id == selected_folder_id %}
{# The selected folder ships its rows inline so the active-feed
highlight and auto-expand work on full page load. #}
<ul id="folder-feeds-{{ row.id }}" class="feed-list tree-feed-list" hidden>
{% include "_tree_folder_feeds.html" %}
</ul>
{% else %}
{# Deferred: rows load from /tree/folder-feeds/{{ row.id }} on
first expand — inlining every folder's rows costs megabytes
at thousands of feeds. #}
<ul id="folder-feeds-{{ row.id }}" class="feed-list tree-feed-list" data-lazy-feeds="{{ row.id }}" hidden></ul>
{% endif %}
{% endif %}
</div>
{% endif %}
{% endfor %}
{% set root_feeds = feeds_by_folder.get(root_folder_row.id, []) %}
{% if root_feeds %}
<ul class="feed-list tree-root-feed-list">
{% for feed in root_feeds %}
<li class="tree-feed-item" data-unread-count="{{ feed.unread_count }}">
<a
class="feed-link {% if selected_feed_url == feed.url and selected_folder_id == root_folder_row.id %}active{% endif %}"
href="/?folder_id={{ root_folder_row.id }}&list_feed_url={{ feed.url | urlencode }}{{ _feeds_tree_sq }}{{ _tree_rfq }}"
data-feed-url="{{ feed.url }}"
data-folder-id="{{ root_folder_row.id }}"
title="{{ feed.url }}"
>
<span class="feed-label">
{% if feed.icon_url %}
<img class="feed-favicon" src="{{ feed.icon_url }}" alt="" loading="lazy" onerror="this.style.display='none'" />
{% endif %}
<span>{{ feed.title }}</span>
{% if feed.url in push_feed_urls %}<span class="feed-push-badge" title="Instant updates (WebSub)">⚡</span>{% endif %}
</span>
{% if feed.has_error %}<span class="feed-error-badge" title="Fetch errors — right-click → Properties for details">⚠</span>{% endif %}
{% if feed.unread_count %}<span class="count">{{ feed.unread_count }}</span>{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{# Tags are a Saved-mode section. In the Feeds tree they duplicated a
filter that only ever applied to kept articles anyway. #}
<div class="tree-block tags-tree-block" id="tags-tree-block"{% if not selected_star_only %} hidden{% endif %}>
<div class="tags-header-row">
<button type="button" class="tags-header section-block-header" id="tags-header-btn">
Tags
<span class="tags-collapse-chevron material-symbols-rounded" aria-hidden="true">expand_more</span>
</button>
</div>
<div class="tags-tree-body" id="tags-tree-body">
{% if tag_rows %}
<ul class="feed-list tag-list">
{% for tag_row in tag_rows %}
<li>
<a
class="tag-link {% if selected_tag == tag_row.name %}active{% endif %}"
{# star_only=1, not 0: this whole block only renders in Saved
(see the comment above, and the JS that toggles it on the
same flag), so a tag link that cleared it threw you into
the Feeds scope on "All" — and then showed nothing, because
Feeds drops disabled and kept-but-unsubscribed feeds, which
is where saved-only tagged entries live. A leftover from
when this list was in the Feeds sidebar. #}
href="/?folder_id={{ selected_folder_id }}{% if selected_feed_url %}&list_feed_url={{ selected_feed_url | urlencode }}{% endif %}&tag={{ tag_row.name | urlencode }}{{ _tree_sq }}&read_filter=all&star_only=1&resume_read_filter={{ selected_resume_read_filter }}"
title="#{{ tag_row.name }}"
>
<span class="tag-label">#{{ tag_row.name }}</span>
<span class="count">{{ tag_row.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<div class="tags-empty muted">No tags in this view.</div>
{% endif %}
</div>
</div>
{% endif %}
</nav>
</section>
<div class="pane-resizer" data-resizer="left-middle" role="separator" aria-label="Resize left and middle panes"></div>
<section class="pane pane-posts">
{% if no_feeds %}
<div class="no-feeds-prompt">
<p class="no-feeds-prompt-text muted">No feeds yet. Add your first feed to get started.</p>
<button type="button" class="btn no-feeds-add-btn" id="no-feeds-add-btn">+ Add Feed</button>
</div>
{% elif selected_read_filter == 'history' %}
<div class="pane-header history-pane-header">
<span class="material-symbols-rounded history-header-icon" aria-hidden="true">history</span>
<span class="history-header-label">History</span>
<button type="button" class="history-close-pill" title="Close History" aria-label="Close History" onclick="closeHistoryView()"><span class="material-symbols-rounded" aria-hidden="true">close</span></button>
</div>
{% else %}
<div class="posts-toolbar">
{% set tb_base = '/?folder_id=' ~ selected_folder_id %}
{% if selected_feed_url %}{% set tb_base = tb_base ~ '&list_feed_url=' ~ (selected_feed_url | urlencode) %}{% endif %}
{% if selected_tag %}{% set tb_base = tb_base ~ '&tag=' ~ (selected_tag | urlencode) %}{% endif %}
{# Sticky for toolbar links only. Changing the sort or the filter must
stay in the Inbox; the TREE links deliberately omit it, so clicking
any other Saved node leaves. #}
{% if inbox_view %}{% set tb_base = tb_base ~ '&kept=starred' %}{% endif %}
{% set tb_sort = (('&sort_by=' ~ selected_sort_by) if selected_sort_by != 'post' else '') ~ (('&sort_dir=' ~ selected_sort_dir) if selected_sort_dir != 'asc' else '') %}
{% set tb_rf = (('&read_filter=' ~ selected_read_filter) if selected_read_filter != 'all' else '') ~ ('&star_only=1' if selected_star_only else '') ~ (('&resume_read_filter=' ~ selected_resume_read_filter) if selected_resume_read_filter != selected_read_filter else '') %}
{% set resume_filter = selected_resume_read_filter if selected_read_filter in ('history', 'capture_failed') else selected_read_filter %}
{% set filter_label = 'Capture failed' if selected_read_filter == 'capture_failed' else ('Starred · Unread' if selected_star_only else 'Unread') if selected_read_filter == 'unread' else ('Starred' if (selected_star_only or selected_read_filter == 'starred') else ('History' if selected_read_filter == 'history' else 'All')) %}
{% set filter_icon = 'cloud_off' if selected_read_filter == 'capture_failed' else 'star' if (selected_star_only or selected_read_filter == 'starred') else ('mark_email_unread' if selected_read_filter == 'unread' else ('history' if selected_read_filter == 'history' else 'format_list_bulleted')) %}
{% set filter_is_active = selected_read_filter in ('unread', 'capture_failed') or selected_star_only or selected_read_filter == 'starred' %}
{% set sort_is_active = selected_sort_by != 'post' or selected_sort_dir != 'desc' %}
{# 'Star' is why this exists: order-by-star-date reaching the toolbar as
an unlabelled case is the bug that made it read "Published newest"
over a differently-ordered list. #}
{% set sort_by_label = 'Pub' if selected_sort_by == 'post' else ('Star' if selected_sort_by == 'starred' else ('Size' if selected_sort_by == 'size' else 'Rec')) %}
{% set sort_dir_label = ('big' if selected_sort_dir == 'desc' else 'small') if selected_sort_by == 'size' else ('new' if selected_sort_dir == 'desc' else 'old') %}
<div class="toolbar-row-main">
{# Phone only; CSS hides both at desktop widths. Two different jobs, so
two different controls rather than one that changes meaning:
Scoped to a single feed, the step "up" is the folder's own list, not
the drawer — so the control is a back arrow naming where it goes.
Once at the folder list there is nothing above it but the drawer,
which slides OVER this list rather than replacing it, hence a
hamburger. This mirrors what the phone's own Back button does. #}
{% if selected_feed_url and selected_folder_name %}
<a class="single-back-btn single-up-btn"
href="/?folder_id={{ selected_folder_id }}{{ tb_sort }}{{ tb_rf }}"
aria-label="Back to {{ selected_folder_name }}" title="Back to {{ selected_folder_name }}">
<span class="material-symbols-rounded" aria-hidden="true">chevron_left</span
><span class="single-up-label">{{ selected_folder_name }}</span>
</a>
{% else %}
<button type="button" class="single-back-btn single-menu-btn" data-single-back="0"
aria-label="Show folders" title="Folders">
<span class="material-symbols-rounded" aria-hidden="true">menu</span>
</button>
{% endif %}
<div class="sort-group">
<details class="toolbar-dropdown filter-menu">
<summary class="sort-pill toolbar-dropdown-summary {% if filter_is_active %}active{% endif %}" title="Filter: {{ filter_label }}">
<span class="material-symbols-rounded toolbar-dd-icon" aria-hidden="true">{{ filter_icon }}</span>
<span class="material-symbols-rounded toolbar-dd-chevron" aria-hidden="true">expand_more</span>
</summary>
<div class="menu-popover anchor-left toolbar-dropdown-popover">
{# In star mode, All/Unread narrow the starred set (star_only rides
along); clicking the active Starred item exits star mode. #}
{# All/Unread narrow the CURRENT mode (star_only rides along) and
the click persists to that mode's remembered filter (JS).
Mode switching itself lives on the Feeds | Saved tabs — the
old Starred toggle here was redundant and its exit-on-active
behavior surprising. #}
{% set _fm_star = '&star_only=1' if selected_star_only else '' %}
<a class="menu-item {% if not filter_is_active and selected_read_filter != 'history' %}active{% endif %}" href="{{ tb_base ~ tb_sort ~ '&read_filter=all' ~ _fm_star }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">format_list_bulleted</span> All</a>
<a class="menu-item {% if selected_read_filter == 'unread' %}active{% endif %}" href="{{ tb_base ~ tb_sort ~ '&read_filter=unread' ~ _fm_star }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">mark_email_unread</span> Unread</a>
{# Feeds-mode only: a peer filter, not a sub-mode of star_only (the
Saved-scope switch) — deliberately never carries star_only along,
so it stays on the Feeds view's own remembered sort rather than
Saved's. Not offered while already in Saved scope, where
everything is star/tag-scoped already. #}
{% if not selected_star_only %}
<a class="menu-item {% if selected_read_filter == 'starred' %}active{% endif %}" href="{{ tb_base ~ tb_sort ~ '&read_filter=starred' }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">star</span> Starred</a>
{% endif %}
{# Saved only: kept items whose source page could not be captured; shown while any exist or while active. #}
{% if selected_star_only and (has_capture_failed or selected_read_filter == 'capture_failed') %}
<a class="menu-item {% if selected_read_filter == 'capture_failed' %}active{% endif %}" href="{{ tb_base ~ tb_sort ~ '&read_filter=capture_failed&star_only=1' }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">cloud_off</span> Capture failed</a>
{% endif %}
<hr class="menu-divider" />
<a class="menu-item filter-history-item {% if selected_read_filter == 'history' %}active{% endif %}" href="{{ tb_base ~ tb_sort ~ '&read_filter=history' ~ (('&resume_read_filter=' ~ resume_filter) if resume_filter != 'history' else '') }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">history</span> History</a>
{% if tag_rows %}
{# Tags submenu: filter the CURRENT selection (folder/feed + read
filter) by a tag. Base keeps everything except the tag. #}
{% set tag_filter_base = '/?folder_id=' ~ selected_folder_id %}
{% if selected_feed_url %}{% set tag_filter_base = tag_filter_base ~ '&list_feed_url=' ~ (selected_feed_url | urlencode) %}{% endif %}
{# Picking a tag LEAVES the Inbox — a tag is filing, the Inbox is
the to-do pile — so it must not drag the star order with it.
(The server ignores a starred sort off-node anyway; this keeps
the URL honest rather than relying on that.) #}
{% set tag_filter_sort = '' if selected_sort_by == 'starred' else tb_sort %}
<hr class="menu-divider" />
<details class="filter-tags-submenu">
<summary class="menu-item filter-tags-summary {% if selected_tag %}active{% endif %}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">sell</span> Tags <span class="filter-tags-caret material-symbols-rounded" aria-hidden="true">expand_more</span></summary>
<div class="filter-tags-list">
{% if selected_tag %}<a class="menu-item" href="{{ tag_filter_base ~ tb_rf ~ tag_filter_sort }}"><span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">close</span> Clear tag</a>{% endif %}
{% for tag_row in tag_rows %}
<a class="menu-item {% if selected_tag == tag_row.name %}active{% endif %}" href="{{ tag_filter_base ~ '&tag=' ~ (tag_row.name | urlencode) ~ tb_rf ~ tag_filter_sort }}"><span class="tag-label">#{{ tag_row.name }}</span> <span class="count">{{ tag_row.count }}</span></a>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</details>
<details class="toolbar-dropdown sort-menu">
<summary class="sort-pill toolbar-dropdown-summary {% if sort_is_active %}active{% endif %}" title="Sort posts">
<span class="toolbar-dd-label">{{ sort_by_label }} {{ sort_dir_label }}</span>
<span class="material-symbols-rounded toolbar-dd-chevron" aria-hidden="true">expand_more</span>
</summary>
<div class="menu-popover anchor-left toolbar-dropdown-popover">
<a class="menu-item {% if selected_sort_by == 'post' and selected_sort_dir == 'desc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=post&sort_dir=desc' ~ tb_rf }}">Published newest</a>
<a class="menu-item {% if selected_sort_by == 'post' and selected_sort_dir == 'asc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=post&sort_dir=asc' ~ tb_rf }}">Published oldest</a>
<a class="menu-item {% if selected_sort_by == 'received' and selected_sort_dir == 'desc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=received&sort_dir=desc' ~ tb_rf }}">Received newest</a>
<a class="menu-item {% if selected_sort_by == 'received' and selected_sort_dir == 'asc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=received&sort_dir=asc' ~ tb_rf }}">Received oldest</a>
{# Saved only: in Feeds most entries were never starred, so this
order would be arbitrary. #}
{% if selected_star_only %}
<a class="menu-item {% if selected_sort_by == 'starred' and selected_sort_dir == 'desc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=starred&sort_dir=desc' ~ tb_rf }}">Recently starred</a>
<a class="menu-item {% if selected_sort_by == 'starred' and selected_sort_dir == 'asc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=starred&sort_dir=asc' ~ tb_rf }}">Longest starred</a>
{# Maintained at capture/re-fetch time, not computed live — see
Plan.md "Saved: see and sort by item size". #}
<a class="menu-item {% if selected_sort_by == 'size' and selected_sort_dir == 'desc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=size&sort_dir=desc' ~ tb_rf }}">Biggest first</a>
<a class="menu-item {% if selected_sort_by == 'size' and selected_sort_dir == 'asc' %}active{% endif %}" href="{{ tb_base ~ '&sort_by=size&sort_dir=asc' ~ tb_rf }}">Smallest first</a>
{% endif %}
</div>
</details>
</div>
<div class="sort-group">
<button type="button" id="toolbar-search-btn" class="sort-pill{% if selected_query %} active{% endif %}" title="Search posts" aria-label="Search posts" aria-expanded="{{ 'true' if selected_query else 'false' }}">
<span class="material-symbols-rounded toolbar-dd-icon" aria-hidden="true">search</span>
</button>
<div class="mark-read-group" role="group" aria-label="Mark posts read options">
<details class="mark-read-menu">
<summary class="sort-pill mark-read-summary" title="Mark posts as read options" aria-label="Mark posts as read options">
<span class="material-symbols-rounded mark-read-trigger-icon" aria-hidden="true">done_all</span>
<span class="material-symbols-rounded mark-read-trigger-caret" aria-hidden="true">arrow_drop_down</span>
</summary>
<div class="mark-read-popover" role="menu" aria-label="Mark read actions">
<p class="mark-read-header-label">Mark as read:</p>
{% set _mr_hidden %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
{% if selected_feed_url %}<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />{% endif %}
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
{% endset %}
{% if selected_feed_url %}
<form method="post" action="/feeds/mark-read" class="mark-read-action-form">
{% if selected_query %}<input type="hidden" name="q" value="{{ selected_query }}" />{% endif %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="feed_url" value="{{ selected_feed_url }}" />
<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button type="submit" class="mark-read-action" role="menuitem">
<span class="material-symbols-rounded mark-read-action-icon" aria-hidden="true">rss_feed</span>
<span>This feed</span>
</button>
</form>
{% else %}
<form method="post" action="/folders/mark-read" class="mark-read-action-form">
{% if selected_query %}<input type="hidden" name="q" value="{{ selected_query }}" />{% endif %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button type="submit" class="mark-read-action" role="menuitem">
<span class="material-symbols-rounded mark-read-action-icon" aria-hidden="true">folder</span>
<span>This folder</span>
</button>
</form>
{% endif %}
<p class="mark-read-section-label">Older than:</p>
{% for age_days, age_label in [(1, '1 day'), (2, '2 days'), (7, '1 week'), (14, '2 weeks'), (30, '1 month')] %}
<form method="post" action="/entries/mark-older-than-read" class="mark-read-action-form">
{% if selected_query %}<input type="hidden" name="q" value="{{ selected_query }}" />{% endif %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="max_age_days" value="{{ age_days }}" />
{% if selected_feed_url %}<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />{% endif %}
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button type="submit" class="mark-read-action" role="menuitem">
<span class="material-symbols-rounded mark-read-action-icon" aria-hidden="true">history</span>
<span>{{ age_label }}</span>
</button>
</form>
{% endfor %}
<hr class="mark-read-divider" />
<details class="mark-unread-submenu">
<summary class="mark-read-action mark-unread-summary" role="menuitem">
<span class="material-symbols-rounded mark-read-action-icon" aria-hidden="true">mark_email_unread</span>
<span>Mark as unread</span>
<span class="material-symbols-rounded mark-unread-caret" aria-hidden="true">chevron_right</span>
</summary>
<div class="mark-unread-submenu-body" role="group" aria-label="Mark unread actions">
<p class="mark-read-section-label">Newer than:</p>
{% for age_days, age_label in [(1, '1 day'), (7, '1 week'), (30, '1 month')] %}
<form method="post" action="/entries/mark-newer-than-unread" class="mark-unread-action-form">
{% if selected_query %}<input type="hidden" name="q" value="{{ selected_query }}" />{% endif %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="min_age_days" value="{{ age_days }}" />
{% if selected_feed_url %}<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />{% endif %}
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button type="submit" class="mark-read-action" role="menuitem">
<span class="material-symbols-rounded mark-read-action-icon" aria-hidden="true">history</span>
<span>{{ age_label }}</span>
</button>
</form>
{% endfor %}
</div>
</details>
</div>
</details>
</div>
</div>
</div>
{% if selected_feed_tag %}
{# Set by clicking a suggested-tag chip's name in the entry pane
(feed-tag-filter-name). Precise server-side match against
entry_feed_tags, not the free-text "Filter this view" box below —
see get_entry_ids_with_feed_tag. Dropped by any other toolbar link
(sort/read-filter/tag) since tb_base doesn't carry it forward:
deliberately a lightweight preview, not a sticky scope. #}
<div class="feed-tag-filter-active-row">
<span class="material-symbols-rounded" style="font-size:1rem;vertical-align:-3px" aria-hidden="true">sell</span>
Filtering: #{{ selected_feed_tag }}
<a class="feed-tag-filter-clear"
href="{{ tb_base }}{{ tb_sort }}{{ tb_rf }}"
title="Clear feed-tag filter" aria-label="Clear feed-tag filter">
<span class="material-symbols-rounded" aria-hidden="true">close</span>
</a>
</div>
{% endif %}
<div id="toolbar-search-row" class="toolbar-search-row{% if not selected_query %} toolbar-search-hidden{% endif %}">
<form method="get" action="/" class="posts-search-form" role="search" aria-label="Search posts">
{# Searching from the Inbox must search the Inbox. #}
{% if inbox_view %}<input type="hidden" name="kept" value="starred" />{% endif %}
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
{% if selected_feed_url %}<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />{% endif %}
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<input
id="topbar-search-input"
name="q"
class="topbar-search-input"
type="search"
value="{{ selected_query or '' }}"
placeholder="Search posts"
aria-label="Search posts"
autocomplete="off"
/>
<button type="submit" id="topbar-search-go" class="topbar-search-go" aria-label="Run search">
<span class="material-symbols-rounded" aria-hidden="true">search</span>
</button>
<button type="button" id="topbar-search-clear" class="topbar-search-go topbar-search-clear"
aria-label="Clear search"{% if not selected_query %} hidden{% endif %}>
<span class="material-symbols-rounded" aria-hidden="true">close</span>
</button>
</form>
</div>
{# "Filter this view" is deliberately NOT the search box above it. Search
is a server query that changes what is fetched; this narrows what is
already on screen, instantly, so the result can be acted on as a set.
The bulk action it feeds resolves server-side against the same
predicate — see /entries/move-visible-to-feed. #}
<div class="posts-filter-row" data-yt-folder="{{ 1 if is_yt_folder else 0 }}">
<span class="material-symbols-rounded posts-filter-icon" aria-hidden="true">filter_alt</span>
<input
id="posts-filter-input"
class="posts-filter-input"
type="search"
placeholder="Filter this view…"
aria-label="Filter the posts in this view by title, link, or feed"
title="Words must all match (title, link, feed); "a phrase" in quotes, !word to exclude. tag:games = has that chip, tag:!games = doesn't, tag:gam* = wildcard; tagged: does the same for tags you applied. Also matches video duration, e.g. <2:00, >=1h, <90s, =1-2 (range), =0 (no duration) -- posts with no duration are excluded otherwise"
autocomplete="off"
/>
<span id="posts-filter-count" class="posts-filter-count" hidden></span>
<button type="button" id="posts-filter-clear" class="posts-filter-clear"
aria-label="Clear filter" hidden>
<span class="material-symbols-rounded" aria-hidden="true">close</span>
</button>
{# Shown only while the multi-select checkbox selection is non-empty.
Click clears the selection — same reflex as the filter's own
clear button beside it. #}
<button type="button" id="posts-selection-count" class="posts-filter-count posts-selection-count"
title="Click to clear selection" hidden></button>
{# Resolves the whole current view + filter server-side, same as
"Move all shown to feed…" — selects everything matching, not
just what scroll-chunking has rendered so far. #}
<button type="button" id="posts-select-all" class="posts-filter-clear"
title="Select all posts in this view" aria-label="Select all posts in this view">
<span class="material-symbols-rounded" aria-hidden="true">select_all</span>
</button>
</div>
</div>
{% endif %}
<div class="posts {% if not posts and not selected_home and not is_chunk_delta_fetch %}posts-empty{% endif %}" data-chunk-size="{{ chunk_size }}" data-next-chunk="{{ next_chunk }}" data-view-exhausted="{{ 1 if view_exhausted else 0 }}" data-view-loaded-at="{{ view_loaded_at }}">
{% if not posts and not selected_home and not is_chunk_delta_fetch %}
<div class="empty-state-message muted">No posts match this filter.</div>
{% endif %}
{% for post in posts %}
{% set list_feed_query = '&list_feed_url=' ~ (selected_feed_url | urlencode) if selected_feed_url else '' %}
{% set tag_query = '&tag=' ~ (selected_tag | urlencode) if selected_tag else '' %}
{# Only include sort/filter params when non-default to keep URLs clean #}
{# Post links stay on the node they were clicked from, so in the Inbox
they must carry `kept=starred` too — the star order is only honored
there, and without it opening an article would silently re-sort the
list behind it. #}
{% set sort_query = ('&kept=starred' if inbox_view else '') ~ (('&sort_by=' ~ selected_sort_by) if selected_sort_by != 'post' else '') ~ (('&sort_dir=' ~ selected_sort_dir) if selected_sort_dir != 'asc' else '') %}
{% set read_filter_query = (('&read_filter=' ~ selected_read_filter) if selected_read_filter != 'all' else '') ~ ('&star_only=1' if selected_star_only else '') ~ (('&resume_read_filter=' ~ selected_resume_read_filter) if selected_resume_read_filter != selected_read_filter else '') %}
<div
class="post-item {% if selected_entry and selected_entry.feed_url == post.feed_url and selected_entry.id == post.id %}active{% endif %}{% if post.read %} is-read{% endif %}"
data-post-feed-url="{{ post.feed_url }}"
data-post-entry-id="{{ post.id }}"
data-post-read="{{ 1 if post.read else 0 }}"
data-post-saved="{{ 1 if post.saved else 0 }}"
{# Kept = starred OR tagged. The re-fetch hatch keys on this rather than
on the star: tag-as-keep made a tag a keep signal everywhere else, and
a tagged-but-unstarred post shows in the Saved view while having no
way to re-fetch its content. 14,695 items on the live library. #}
data-post-kept="{{ 1 if post.kept else 0 }}"
data-post-has-tags="{{ 1 if post.has_tags else 0 }}"
data-post-chip-tags="{{ (post.chip_tags or [])|join('|') }}"
data-post-applied-tags="{{ (post.applied_tags or [])|join('|') }}"
data-post-captured="{{ 1 if post.captured else 0 }}"
data-post-link="{{ post.link or '' }}"
data-post-title="{{ post.title or '' }}"
data-post-folder-id="{{ feed_to_folder.get(post.feed_url, selected_folder_id) }}"
{# Orphan archives (feed fully unsubscribed) have no home in the Feeds
tree at all — "Open in Feeds" would land on an empty page. #}
data-post-orphan="{{ 1 if post.is_orphan_archive else 0 }}"
data-post-video-id="{{ post.video_id or '' }}"
{# 0 is a legitimate (if unlikely) duration, so "is not none" rather
than "or ''" — the latter would also blank out a real zero. #}
data-post-duration-seconds="{{ post.duration_seconds if post.duration_seconds is not none else '' }}"
data-thumb-crop="{{ post.thumb_crop or 'cover' }}"
data-thumb-strategy="{{ post.thumb_strategy or '' }}"
data-smart-ms="{{ post.smart_min_scale or '' }}"
data-fill-zoom="{{ post.fill_zoom or '' }}"
>
<input type="checkbox" class="post-select-check" aria-label="Select post" tabindex="-1" />
{% set _is_data_thumb = post.thumbnail_url and post.thumbnail_url.startswith('data:') %}
{% if post.is_locked %}
{# A supporter-locked webcomic strip (hide_locked_comics off, so the row
still shows): the resolved thumbnail is stale/unresolvable while
locked, so show the unlock date instead of a broken image. #}
<div class="post-thumbnail post-thumbnail--locked crop-{{ post.thumb_crop or 'cover' }}" title="Locked until {{ post.locked_until_display }}">
<span class="material-symbols-rounded post-thumbnail-locked-icon" aria-hidden="true">lock</span>
<span class="post-thumbnail-locked-date">{{ post.locked_until_display }}</span>
</div>
{% else %}
<div class="post-thumbnail {% if not post.thumbnail_url %}is-empty{% endif %} crop-{{ post.thumb_crop or 'cover' }}"
{% if post.thumb_crop == 'smart' and post.thumbnail_url and not _is_data_thumb %}style="background-image:url('/thumb?url={{ post.thumbnail_url | urlencode }}&crop=cover')"{% endif %}>
{% if _is_data_thumb %}
{# Inline-SVG thumbnail: render the sanitized data URI directly (CSS sizes it; /thumb only rasterizes http(s)). #}
<img class="post-thumbnail-image" src="{{ post.thumbnail_url }}" alt=""
onerror="this.parentElement.classList.add('is-empty'); this.style.display='none'" />
{% elif post.thumbnail_url %}
{# data-direct: if /thumb fails (e.g. the image host IP-blocks our server
but serves the browser's own IP fine), fall back to the direct URL so
the thumbnail still loads. CSS object-fit:cover sizes the un-resized image. #}
{# lazy: rows hidden by chunking or "Filter this view" are display:none and never intersect, so their thumbnails are not fetched (and resized server-side) until shown. #}
<img class="post-thumbnail-image" loading="lazy" src="/thumb?url={{ post.thumbnail_url | urlencode }}&crop={{ post.thumb_crop or 'cover' }}{% if post.thumb_crop == 'smart' and post.smart_min_scale %}&ms={{ post.smart_min_scale }}{% endif %}{% if post.fill_zoom and post.fill_zoom != 1.0 and post.thumb_crop != 'smart' and post.thumb_crop != 'contain' %}&fz={{ post.fill_zoom }}{% endif %}" alt=""
data-direct="{{ post.thumbnail_url }}" onerror="thumbImgFallback(this)" />
{% endif %}
</div>
{% endif %}
<div class="post-item-card">
<div class="post-content">
<div class="post-title-row">
<a class="post-main-link" rel="noopener noreferrer"
href="/?folder_id={{ selected_folder_id }}{{ list_feed_query }}{{ tag_query }}{{ sort_query }}{{ read_filter_query }}&feed_url={{ post.feed_url | urlencode }}&entry_id={{ post.id | urlencode }}"
data-article-url="{{ post.link or '' }}">
<div class="post-title" title="{{ post.link or '' }}">{% if post.title_html %}{{ post.title_html | safe }}{% else %}{{ post.title or "(untitled)" }}{% endif %}</div>
</a>
</div>
</div>
<div class="post-bottom-row">
<div class="post-feed-row">
<div class="post-meta">
<span class="post-feed">
{% if post.feed_icon_url %}
<img class="feed-favicon" src="{{ post.feed_icon_url }}" alt="" loading="lazy" onerror="this.style.display='none'" />
{% else %}
<span class="feed-icon material-symbols-rounded" aria-hidden="true">article</span>
{% endif %}
{% set post_feed_folder_id = feed_to_folder.get(post.feed_url, selected_folder_id) %}
<a
class="feed-name post-feed-link"
data-feed-url="{{ post.feed_url }}"
data-folder-id="{{ post_feed_folder_id }}"
href="/?folder_id={{ post_feed_folder_id }}&list_feed_url={{ post.feed_url | urlencode }}{{ sort_query }}{{ read_filter_query }}"
>{{ post.feed_title }}</a>{% if post.feed_url in unsubscribed_feed_urls %}<span class="feed-unsubscribed-tag" title="No longer subscribed — this is a kept copy">(unsubscribed)</span>{% endif %}
</span>
{% if selected_star_only and post.size_display %}
<span class="post-size-badge" title="Captured content + archived assets">{{ post.size_display }}</span>
{% endif %}
</div>
</div>
<div class="post-actions-row">
{# Always render both timestamps as data attributes and let JS pick which to display #}
<time
class="post-time js-local-time-relative"
data-post-iso="{{ post.post_timestamp or '' }}"
data-received-iso="{{ post.received_timestamp or '' }}"
data-post-display="{{ post.post_display or '' }}"
data-received-display="{{ post.received_display or '' }}"
data-read-iso="{{ post.read_timestamp or '' }}"
data-read-display="{{ post.read_display or '' }}"
>
{# Server-side initial render: show the value matching selected_sort_by (or read history). Fallback through received → received → '—' to avoid literal "None" output. #}
{% if selected_read_filter == 'history' and not selected_star_only and post.read_display %}
{{ post.read_display }}
{% elif selected_sort_by == 'received' and post.received_display %}
{{ post.received_display }}
{% else %}
{{ post.post_display or '—' }}
{% endif %}
</time>
<div class="post-meta-actions">
<button
type="button"
class="post-tag-toggle"
title="Add tag"
aria-label="Add tag"
>
<span class="post-tag-indicator material-symbols-rounded" aria-hidden="true">sell</span>
</button>
<form method="post" action="/entries/saved" class="post-save-toggle-form">
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="feed_url" value="{{ post.feed_url }}" />
<input type="hidden" name="entry_id" value="{{ post.id }}" />
<input type="hidden" name="saved" value="{{ 0 if post.saved else 1 }}" />
<input type="hidden" name="select_entry" value="0" />
{% if selected_feed_url %}
<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />
{% endif %}
{% if selected_tag %}
<input type="hidden" name="tag" value="{{ selected_tag }}" />
{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button
type="submit"
class="post-save-toggle"
title="{{ 'Remove from saved' if post.saved else 'Save for later' }} (f)"
aria-label="{{ 'Remove from saved' if post.saved else 'Save for later' }}"
>
<span class="post-save-indicator material-symbols-rounded" aria-hidden="true">{{ 'star' if post.saved else 'star_outline' }}</span>
</button>
</form>
<form method="post" action="/entries/read" class="post-read-toggle-form">
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="feed_url" value="{{ post.feed_url }}" />
<input type="hidden" name="entry_id" value="{{ post.id }}" />
<input type="hidden" name="read" value="{{ 0 if post.read else 1 }}" />
<input type="hidden" name="select_entry" value="0" />
{% if selected_feed_url %}
<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />
{% endif %}
{% if selected_tag %}
<input type="hidden" name="tag" value="{{ selected_tag }}" />
{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
<button
type="submit"
class="post-read-toggle"
title="Mark as {{ 'Unread' if post.read else 'Read' }} (m)"
aria-label="Mark as {{ 'Unread' if post.read else 'Read' }}"
>
<span class="post-read-indicator material-symbols-rounded" aria-hidden="true">circle</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<div id="posts-chunk-sentinel" class="posts-chunk-sentinel" aria-hidden="true"></div>
<script>
function closeHistoryView() {
// Try to restore previous filter, fallback to 'all'
let url = window.location.pathname + window.location.search;
const params = new URLSearchParams(window.location.search);
const resume = params.get('resume_read_filter') || 'all';
params.set('read_filter', resume);
params.delete('resume_read_filter');
// Remove star_only if it was only set for history
if (params.get('star_only') === '0') params.delete('star_only');
// Remove any page chunking
params.delete('chunk');
// Compose new URL
url = window.location.pathname + '?' + params.toString();
if (window.loadScopePanesWithoutFullRefresh) {
loadScopePanesWithoutFullRefresh(url);
} else {
window.location.href = url;
}
}
</script>
</div>
</section>
<div class="pane-resizer" data-resizer="middle-right" role="separator" aria-label="Resize middle and right panes"></div>
{% include "_entry_pane.html" %}
</main>
<script>
(function(){
const MEDIUM_THRESHOLD = 1100;
// Phone width. Below this the three panes stack and exactly one is on screen
// at a time — folders (0) → posts (1) → article (2). This is the main app,
// not a second renderer, so lead images, thumbnail tuning, embeds and the
// full-image webcomic view all apply here by construction.
const SINGLE_THRESHOLD = 720;
const SINGLE_LEVEL_KEY = 'lectio-next-single-pane-level';
const WIDE_LEFT_COLLAPSED_KEY = 'lectio-wide-left-collapsed';
let layoutMode = 'wide';
const bodyEl = document.body;
const mediumPaneBackdrop = document.getElementById('medium-pane-backdrop');
function getWideLeftCollapsedPreference() {
return window.localStorage.getItem(WIDE_LEFT_COLLAPSED_KEY) === '1';
}
function setWideLeftCollapsed(collapsed, persist = true) {
bodyEl.setAttribute('data-left-collapsed', collapsed ? '1' : '0');
if (persist) {
window.localStorage.setItem(WIDE_LEFT_COLLAPSED_KEY, collapsed ? '1' : '0');
}
}
function setMediumLeftOpen(isOpen) {
bodyEl.setAttribute('data-medium-left-open', isOpen ? '1' : '0');
if (mediumPaneBackdrop) {
if (isOpen) {
mediumPaneBackdrop.removeAttribute('hidden');
} else {
mediumPaneBackdrop.setAttribute('hidden', '');
}
}
}
// Which pane is on screen in single mode. Persisted for the next render
// because a pane swap re-runs this shell and would otherwise drop the user
// back to the folder list mid-read.
let singlePaneLevel = 0;
// The article level sets the list pane to display:none, and a hidden element
// loses its scroll offset, so Back landed at the top of the list. Remember the
// offset keyed to the element: a scope swap replaces .posts, and a stale offset
// must not be applied to a different list.
let listScrollMemo = null;
function setSinglePaneLevel(level, persist = true) {
const prevLevel = singlePaneLevel;
const nextLevel = Math.max(0, Math.min(2, Number(level) || 0));
const listEl = layoutMode === 'single' ? document.querySelector('.posts') : null;
if (listEl && nextLevel === 2 && prevLevel !== 2) {
listScrollMemo = { el: listEl, top: listEl.scrollTop };
}
if (!listEl) listScrollMemo = null; // outside single mode the list is visible and scrolls freely; a stale offset must not return
singlePaneLevel = nextLevel;
bodyEl.setAttribute('data-single-pane-level', String(singlePaneLevel));
let restoredOffset = false;
if (listEl && prevLevel === 2 && nextLevel !== 2 && listScrollMemo
&& listScrollMemo.el === listEl && listScrollMemo.top > 0) {
listEl.scrollTop = listScrollMemo.top;
restoredOffset = true;
}
// Fallback when there is no usable offset (the list was re-rendered, or the
// remembered spot is gone): bring the article just read back into view.
if (listEl && prevLevel === 2 && nextLevel !== 2 && !restoredOffset) {
const active = listEl.querySelector('.post-item.active');
if (active && active.offsetParent !== null) {
const top = active.offsetTop;
const bottom = top + active.offsetHeight;
if (top < listEl.scrollTop || bottom > listEl.scrollTop + listEl.clientHeight) {
listEl.scrollTop = Math.max(0, top - (listEl.clientHeight - active.offsetHeight) / 2);
}
}
}
if (nextLevel !== 2 && prevLevel !== 2) listScrollMemo = null;
// The backdrop's `hidden` attribute has to come off, not be out-specified:
// a global [hidden] { display: none !important } beats any selector.
if (mediumPaneBackdrop) {
const showBackdrop = layoutMode === 'single' && singlePaneLevel === 0;
mediumPaneBackdrop.toggleAttribute('hidden', !showBackdrop);
}
if (persist) {
try { window.sessionStorage.setItem(SINGLE_LEVEL_KEY, String(singlePaneLevel)); }
catch (e) { /* private mode */ }
}
}
function restoreSinglePaneLevel() {
let level = 0;
try { level = Number.parseInt(window.sessionStorage.getItem(SINGLE_LEVEL_KEY), 10) || 0; }
catch (e) { level = 0; }
// An article in the URL means the article pane is what the user is looking
// at, whatever the last remembered level said.
const url = new URL(window.location.href);
if (url.searchParams.get('entry_id')) {
level = 2;
} else if (level === 2) {
level = 1;
}
setSinglePaneLevel(level, false);
}
function updateSingleMode() {
const width = window.innerWidth;
layoutMode = width <= SINGLE_THRESHOLD
? 'single'
: (width <= MEDIUM_THRESHOLD ? 'medium' : 'wide');
bodyEl.setAttribute('data-layout-mode', layoutMode);
if (layoutMode === 'wide') {
setMediumLeftOpen(false);
setWideLeftCollapsed(getWideLeftCollapsedPreference(), false);
} else {
setWideLeftCollapsed(false, false);
setMediumLeftOpen(false);
bodyEl.removeAttribute('data-left-floating');
}
if (layoutMode === 'single') {
restoreSinglePaneLevel();
} else {
bodyEl.removeAttribute('data-single-pane-level');
mediumPaneBackdrop?.setAttribute('hidden', '');
listScrollMemo = null; // the list is visible and scrolls freely outside single mode; a stale offset must not return
}
// A rotated phone is "medium" mode but still ~390px tall, where the desktop
// header stack is taller than the article itself. A narrow desktop window is
// also medium and has height to spare. A maximized touchscreen 2-in-1
// (Surface Pro and the like) is often "wide" outright — plenty of width,
// but the header controls are still finger targets, not mouse targets.
//
// There is no reliable "is this a phone" bit, so this asks about the thing
// that actually matters — is vertical space tight, and is this a finger:
//
// * height <= SHORT_VIEWPORT is the real problem, and is true of a
// landscape phone and of a short desktop window alike (where the
// compact header is just as welcome);
// * (pointer: coarse) AND (hover: none) means touch is the PRIMARY input.
// Both halves matter: a touchscreen laptop reports coarse but still
// hovers, and would otherwise be treated as a phone. This alone is
// sufficient — requiring navigator.userAgentData.mobile on top of it
// (as this used to) excludes touch-primary Windows 2-in-1s entirely,
// since Windows never reports "mobile" there even in tablet mode.
const SHORT_VIEWPORT = 560;
const touchPrimary = Boolean(window.matchMedia
&& window.matchMedia('(pointer: coarse) and (hover: none)').matches);
const compactArticle = layoutMode === 'single'
|| ((layoutMode === 'medium' || layoutMode === 'wide')
&& (window.innerHeight <= SHORT_VIEWPORT || touchPrimary));
if (compactArticle) {
bodyEl.setAttribute('data-compact-article', '1');
} else {
bodyEl.removeAttribute('data-compact-article');
}
updateVisualViewportInset();
// Rotating a tablet moves it between wide and medium, so the guard has to
// arm on layout change and not only at load. armDrawerBack is a no-op when
// already armed or when the layout has no drawer.
armDrawerBack();
}
function updateVisualViewportInset() {
try {
const vv = window.visualViewport;
let inset = 0;
if (vv) {
inset = Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0));
} else {
inset = 0;
}
document.documentElement.style.setProperty('--vv-bottom-inset', inset + 'px');
bodyEl.setAttribute('data-keyboard-open', inset > 120 ? '1' : '0');
} catch (e) {
// ignore
}
}
// listen for visual viewport changes to update bottom inset
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateVisualViewportInset, { passive: true });
window.visualViewport.addEventListener('scroll', updateVisualViewportInset, { passive: true });
}
window.addEventListener('resize', updateVisualViewportInset, { passive: true });
// Set dynamic --vh for accurate mobile viewport height
function setVh() {
try {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
} catch (e) {
// ignore
}
}
setVh();
window.addEventListener('resize', setVh, { passive: true });
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', setVh, { passive: true });
window.visualViewport.addEventListener('scroll', setVh, { passive: true });
}
// Returns what happened, so a caller can say so: 'ok', 'start', 'end',
// 'pending' (waiting on the next chunk) or 'none' (nothing selected).
function navigateEntry(direction, isRetry = false) {
const all = Array.from(document.querySelectorAll('.post-item'));
const posts = all.filter(p => !p.classList.contains('post-item-hidden'));
const active = document.querySelector('.post-item.active');
if (!active) return 'none';
const idx = posts.indexOf(active);
if (idx === -1) return 'none';
const nextIdx = idx + direction;
if (nextIdx < 0) return 'start';
if (nextIdx >= posts.length) {
// The list renders in chunks, so the end of the VISIBLE window is not
// the end of the list. Ask for the next chunk and try once more before
// telling the user there is nothing left — otherwise "no more articles"
// is simply wrong every 50 posts.
const moreLoaded = all.length > posts.length;
const moreOnServer = Boolean(document.getElementById('posts-chunk-sentinel'));
if (!isRetry && (moreLoaded || moreOnServer)) {
try { window.revealNextPostChunk && window.revealNextPostChunk(); } catch (e) {}
window.setTimeout(() => {
if (navigateEntry(direction, true) === 'end') {
window.showToastMessage && window.showToastMessage('No more articles.');
}
}, 450);
return 'pending';
}
return 'end';
}
const target = posts[nextIdx];
const link = target.querySelector('.post-main-link');
if (link) loadEntryPaneWithoutFullRefresh(link.href);
return 'ok';
}
// Swipe left/right in the article to move through the list, the way you would
// page through anything else on a phone. Deliberately conservative about what
// counts as a swipe: a mostly-horizontal, reasonably long, reasonably quick
// drag that did not start on something interactive or sideways-scrollable.
// Anything looser steals ordinary scrolling and text selection.
(function bindArticleSwipe() {
const MIN_DISTANCE = 60; // px — shorter than this is a tap or a jitter
const MAX_DURATION = 700; // ms — slower than this is a considered drag
let startX = 0;
let startY = 0;
let startedAt = 0;
let tracking = false;
document.addEventListener('touchstart', (event) => {
tracking = false;
if (event.touches.length !== 1) return;
const pane = document.querySelector('.pane-entry');
if (!pane || !pane.contains(event.target)) return;
// Wide code blocks and tables scroll sideways themselves; controls own
// their own gestures.
if (event.target.closest('a, button, input, textarea, select, pre, table, .entry-tags-row')) {
return;
}
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startedAt = Date.now();
tracking = true;
}, { passive: true });
document.addEventListener('touchend', (event) => {
if (!tracking) return;
tracking = false;
const touch = event.changedTouches && event.changedTouches[0];
if (!touch) return;
if (Date.now() - startedAt > MAX_DURATION) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Math.abs(dx) < MIN_DISTANCE) return;
if (Math.abs(dx) < Math.abs(dy) * 2) return; // mostly vertical: a scroll
// Left takes you forward, matching the direction the list runs.
const forward = dx < 0;
const result = navigateEntry(forward ? 1 : -1);
// Say so, rather than looking broken. 'pending' stays quiet: it is
// fetching, and it reports for itself if it comes back empty.
if (result === 'end') {
window.showToastMessage && window.showToastMessage('No more articles.');
} else if (result === 'start') {
window.showToastMessage && window.showToastMessage('Already at the first article.');
}
}, { passive: true });
})();
window.setSinglePaneLevel = setSinglePaneLevel;
window.isSingleMode = () => layoutMode === 'single';
window.isCompactArticle = () => bodyEl.getAttribute('data-compact-article') === '1';
// Not revived with single mode: swipe gestures and pull-to-refresh were
// removed separately and are a distinct decision from "can I read on my
// phone". Left as no-ops so their call sites stay quiet.
window.bindSinglePanePullToRefresh = () => {};
window.bindSwipeGestures = () => {};
// Back steps down the pane stack before it leaves the page: on a phone the
// article pane IS the page, so a plain Back that navigated away would lose
// the list every time.
//
// The intended chain is article → the feed list → the folder list → the
// folder drawer → leave. Two of those steps need no help: opening a feed
// from the drawer and opening an article each push a history entry, so the
// stack already holds the feed list under the article and the folder list
// under the feed list.
//
// The last step has nothing beneath it to pop to, so Back left the app
// straight from the folder list — which on a phone closes the tab. Instead
// the folder list is the END of the chain: Back there toggles the drawer
// open and closed, indefinitely.
//
// ⚠ That means Back never exits the app in single-pane mode. It is a
// deliberate back-trap, asked for after a stray press closed the tab
// mid-read: this is a self-hosted reader you live in, and losing the session
// costs more than the ability to reverse out of it. Leaving is still an
// ordinary browser action (close the tab, switch apps); it is only Back that
// no longer does it. Desktop is untouched — the whole thing is gated on
// isSingleMode().
//
// Intercepting that press needs a spare same-document history entry to
// consume. Back out of the entry the app was LOADED on is a cross-document
// navigation — the page unloads and popstate never fires — so there is
// nothing script can do at that point. The spare is pushed ahead of time: a
// duplicate of the current URL, so pushing and popping it are both visually
// silent, and popping it is what toggles the drawer. It is re-armed the
// instant it is consumed, so there is always exactly one spare and the stack
// neither grows nor runs out.
//
// A scope param is required so the bare landing screen is excluded: there is
// no folder list to toggle there, and Back on the app's front door should do
// the ordinary thing.
let drawerBackSpare = false;
// Single mode is not the only layout with a drawer, and gating on it was the
// bug: a tablet sits in MEDIUM (721-1100px), where the tree is also an
// overlay with its own backdrop — so the guard never armed and Back closed
// the tab exactly as before. Wide is deliberately excluded: there the tree is
// a permanent column, so there is nothing to toggle, and a Back that visibly
// does nothing is a trap rather than a step.
function hasDrawerLayout() {
return layoutMode === 'single' || layoutMode === 'medium';
}
function drawerIsOpen() {
return layoutMode === 'single'
? singlePaneLevel === 0
: bodyEl.getAttribute('data-medium-left-open') === '1';
}
function toggleDrawer() {
if (layoutMode === 'single') setSinglePaneLevel(singlePaneLevel === 0 ? 1 : 0);
else setMediumLeftOpen(!drawerIsOpen());
}
// What has to be protected is the BOTTOM of this document's history —
// whatever URL the app was loaded at — not a particular kind of URL.
//
// The first version guessed from the URL shape: it armed only on a
// folder-scoped list, on the reasoning that an article or a feed list always
// has a real parent underneath it. That holds only if you *navigated* there
// in this session. Reopen the app, restore a tab, or follow a bookmark
// straight to an article and there is no parent — so Back went
// cross-document and closed the tab, which is the bug this was meant to fix.
//
// So position is tracked instead, by stamping an index into history.state.
// An index (rather than a counter decremented on popstate) is what makes
// Forward safe: popstate fires for both directions and only the state can
// say which way we went, or how far.
let historyIndex = 0;
function _stamp(state, index) {
return Object.assign({}, state || {}, { lectioIdx: index });
}
const _origPushState = history.pushState.bind(history);
const _origReplaceState = history.replaceState.bind(history);
history.pushState = function (state, ...rest) {
// A push above the bottom means the spare is no longer the thing Back
// reaches first, so it stops being armed.
if (!(state && state.lectioDrawerSpare)) drawerBackSpare = false;
historyIndex += 1;
return _origPushState(_stamp(state, historyIndex), ...rest);
};
// replaceState swaps the current entry in place, so the index must ride
// along or the entry loses its position and reads as the bottom.
history.replaceState = function (state, ...rest) {
return _origReplaceState(_stamp(state, historyIndex), ...rest);
};
// Stamp the entry the document loaded on as index 0 — the floor.
_origReplaceState(_stamp(history.state, 0), '', window.location.href);
// An article carries its list's own scope params (folder_id, list_feed_url,
// tag, q, sort_*, ...) alongside entry_id/feed_url — stripping the latter two
// recovers "the list this article lives in" without knowing the rest of the
// vocabulary. Returns null for a URL that was never an article (nothing to
// strip), so callers can fall back to the plain drawer-toggle behavior.
function deriveListUrl(url) {
try {
const u = new URL(url, window.location.origin);
if (!u.searchParams.has('entry_id')) return null;
u.searchParams.delete('entry_id');
u.searchParams.delete('feed_url');
return u.href;
} catch (e) { return null; }
}
function armDrawerBack() {
// Only ever one spare, only at the floor. Mid-stack there are real views
// to walk back through and the spare would sit in front of them.
if (drawerBackSpare || !hasDrawerLayout() || historyIndex !== 0) return;
// Prefer the last real scope visited over the bare current URL: this only
// fires from the true floor, which on a repeat Back press (no tap in
// between to re-drive a fresh scope load first) is the bootstrap landing
// URL, not whatever list the user actually still has on screen. Landing on
// an article directly (the resume-on-launch redirect's most common case —
// see window.__lectioResume above) leaves no real list in history at all;
// deriving one from the article's own scope params, rather than falling
// straight to the bare current URL, is what lets the landedOnSpare handler
// below load that list instead of just flipping open an empty drawer.
// Found live 2026-09-02: first Back from a resumed article opened the
// drawer over nothing rather than the article's own list.
const listUrl = deriveListUrl(window.location.href);
const armUrl = window.__lectioLastScopeUrl || listUrl || window.location.href;
history.pushState({ lectioDrawerSpare: true, lectioPaneLevel: singlePaneLevel, lectioListUrl: listUrl },
'', armUrl);
drawerBackSpare = true;
}
window.armDrawerBack = armDrawerBack;
// ⚠ The limit of this whole mechanism, and why the manifest above matters
// more than the code below.
//
// Chrome ships a "history manipulation intervention": entries a page pushes
// WITHOUT user activation are marked skippable, and Back traverses straight
// past them. That is aimed squarely at pages trapping the back button, which
// is exactly what the spare does — so a spare pushed from inside a popstate
// handler (no gesture) can be skipped, and Back walks out of the app. On a
// Galaxy S21+ that showed up as two working drawer toggles followed by the
// tab closing, while an automated Chromium run of the same steps toggled
// indefinitely, because headless does not apply the intervention.
//
// Re-arming on a real gesture is the mitigation: an entry pushed while the
// user is touching carries activation and is not marked skippable. Reading
// means tapping constantly, so in practice the spare is usually gesture-made.
// It is NOT a guarantee — press Back repeatedly without touching anything in
// between and there is no gesture to arm from, and the app will eventually
// be left. Installing to the home screen is what actually removes the risk,
// because then there is no tab to lose.
for (const evt of ['pointerdown', 'touchstart', 'keydown']) {
window.addEventListener(evt, () => { armDrawerBack(); }, { capture: true, passive: true });
}
// ── Remembering the position the resume handler reads ────────────────────
// localStorage, not sessionStorage: the whole point is surviving the tab
// closing, and sessionStorage dies with it.
const RESUME_KEY = 'lectio-last-position';
function rememberPosition() {
try {
// The landing is not a position worth returning to, and saving it would
// overwrite a real one every time the wordmark is clicked.
if (window.location.pathname !== '/' || !window.location.search) return;
window.localStorage.setItem(RESUME_KEY, JSON.stringify({
url: window.location.pathname + window.location.search,
level: singlePaneLevel,
ts: Date.now(),
}));
} catch (e) { /* private mode, or quota — resume is optional */ }
}
// pagehide covers the cases that matter on a phone and that `unload` does
// not fire for reliably: the tab being closed, the app being swiped away,
// and Back leaving the document. visibilitychange catches backgrounding,
// which on Android is often the last event before the process is reaped.
window.addEventListener('pagehide', rememberPosition);
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') rememberPosition();
});
// Also on navigation, so a position exists even if the app is killed with no
// lifecycle event at all.
window.addEventListener('popstate', rememberPosition);
const _resumeOrigPush = history.pushState.bind(history);
history.pushState = function (...args) {
const out = _resumeOrigPush(...args);
rememberPosition();
return out;
};
// Registered before app.js loads, so stopImmediatePropagation() suppresses
// that file's popstate handler too — which is the point when the spare is
// consumed: nothing navigated, so its pane reload would refetch the list we
// are still standing on.
window.addEventListener('popstate', (event) => {
const landedAt = (event.state && typeof event.state.lectioIdx === 'number')
? event.state.lectioIdx
: 0;
historyIndex = landedAt;
// Landing on the spare itself (however deep the stack, now that a sibling
// scope swap replaces its own entry in place instead of pushing — see
// loadScopePanesWithoutFullRefresh's onScopeList branch) OR on index 0
// (the bootstrap case, before any scope has ever been visited and the
// spare above it was consumed by this very pop): either way, the next
// Back would otherwise leave the document, so flip the drawer instead.
// A resumed-straight-onto-an-article session (see window.__lectioResume)
// never rendered a real list, so there is nothing for a plain drawer
// toggle to reveal — load candidateListUrl's list pane instead when one
// is known, rather than opening the drawer over emptiness. When nothing
// is known (a bare/list floor with a real, already-rendered list or
// drawer behind it) this is exactly the old plain toggle.
function openDrawerOrList(candidateListUrl) {
if (candidateListUrl && typeof window.loadScopePanesWithoutFullRefresh === 'function') {
if (candidateListUrl !== window.location.href) {
history.replaceState(history.state, '', candidateListUrl);
}
setSinglePaneLevel(1);
window.loadScopePanesWithoutFullRefresh(candidateListUrl, false);
// Stand-in for the real-navigation stash at loadScopePanesWithoutFullRefresh's
// own `window.__lectioLastScopeUrl = url` (skipped here since pushHistory
// is false) — without it, the very next armDrawerBack() re-arm derives
// from window.location.href, which this call just rewrote to a list URL
// with no entry_id left to strip, so deriveListUrl returns null and the
// newly-armed spare has nothing to fall back to. That produced a working
// first Back (this article -> its list) immediately followed by a broken
// second one (open another article, Back opens the bare drawer instead
// of returning to the list) -- reported live 2026-09-03 as "back button
// ... seemed to be working for a bit."
window.__lectioLastScopeUrl = candidateListUrl;
} else {
toggleDrawer();
}
}
const landedOnSpare = Boolean(event.state && event.state.lectioDrawerSpare);
if (hasDrawerLayout() && (landedOnSpare || landedAt === 0)) {
event.stopImmediatePropagation();
if (landedOnSpare) {
// The spare's URL was only ever accurate at the moment it was armed.
// A sibling scope swap since then replaced the REAL entry above it,
// not this one — you cannot edit a history entry you are not
// currently on — so it can be stale (still pointing at whatever
// was current when it was pushed). Heal it back to the last real
// scope before toggling, or the drawer opens over the right list but
// the address bar and "active" folder both silently revert.
const healUrl = window.__lectioLastScopeUrl;
if (healUrl && healUrl !== window.location.href) {
history.replaceState(event.state, '', healUrl);
}
if (singlePaneLevel === 2) {
// Landed on the spare with an article still on screen — either it
// was armed directly from one (see deriveListUrl/armDrawerBack), or
// it was re-armed after openDrawerOrList showed a list and the user
// then opened another article from it. toggleDrawer only flips
// between levels 0 and 1, so from level 2 it always lands on 0 (the
// bare drawer) never 1 (the list) — load the list for real instead,
// same as the true-floor branch below. Found live 2026-09-03: this
// is why Back "seemed to work for a bit" and then didn't — the
// FIRST cycle hits the no-healUrl branch below (openDrawerOrList
// already), but the heal path here was still the unconditional
// toggle from the pre-single-pane-aware version of this mechanism.
openDrawerOrList(healUrl || event.state.lectioListUrl);
} else if (healUrl) {
toggleDrawer();
// Still the same spare entry, still sitting right where it was —
// no re-arm needed, nothing was consumed.
} else {
// No real scope was ever visited this session — only the spare's
// own derived list (see deriveListUrl/armDrawerBack), if any.
openDrawerOrList(event.state.lectioListUrl);
}
} else {
// True floor, nothing armed above it yet: flip the drawer (or load
// the floor's own derived list — same resumed-onto-an-article gap
// as above, just without a spare in play to have carried it) and
// re-arm in the same breath. Consuming one entry and pushing one
// back keeps the stack the same size, so this can run forever
// without growing history — and without ever running out of presses.
openDrawerOrList(deriveListUrl(window.location.href));
drawerBackSpare = false;
armDrawerBack();
}
return;
}
drawerBackSpare = false;
if (window.isSingleMode()) restoreSinglePaneLevel();
});
// Delegated so it survives pane swaps, which replace the entry pane wholesale.
document.addEventListener('click', (event) => {
const back = event.target.closest('[data-single-back]');
if (!back || !window.isSingleMode()) return;
event.preventDefault();
setSinglePaneLevel(Number(back.getAttribute('data-single-back')) || 0);
});
window.addEventListener('resize', () => updateSingleMode());
updateSingleMode();
// The app is most often loaded straight onto a list, and that first entry is
// exactly the one Back cannot be intercepted on without a spare.
armDrawerBack();
// Nothing off-site opens in this tab. Following a link in place replaces the
// app and loses your position in the list; on a phone Back now toggles the
// folder drawer rather than leaving, so there is no cheap way back to it.
//
// Done by setting the anchor's own target in the CAPTURE phase, so the
// browser's normal activation does the opening: a real tab, never a scripted
// window.open, which is what a popup blocker stops and what a phone opens as
// an awkward floating window.
//
// Article-body links are already handled server-side by the sanitizer, which
// also covers Read Mode and offline captures; this is the net for everything
// else on the shell — settings hints, feed sites, anything injected later.
document.addEventListener('click', function (e) {
const a = e.target instanceof Element ? e.target.closest('a[href]') : null;
if (!a || a.target) return;
let u;
try { u = new URL(a.href, window.location.origin); } catch (err) { return; }
// Same-origin is app navigation. Non-http(s) (mailto:, tel:) must stay in
// place, or the handler is handed a blank tab it cannot close.
if (u.origin === window.location.origin) return;
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
a.target = '_blank';
a.rel = 'noopener noreferrer';
}, true);
// Intercept internal app navigation links and perform SPA loads.
// Do not intercept external links, modified clicks, or the brand/logo
// (which must remain a bare `/`).
// Only a click on the root "Feeds" (or root "Saved") scope's OWN filter
// pill should become the remembered default for next time -- a folder,
// feed, or tag view's filter choice is for that view alone. Raised
// 2026-08-31: toggling read/unread while looking at one single feed was
// silently changing what "Feeds" (all) opens to on the next visit.
function _readFilterPillIsRootScope(url) {
if (url.searchParams.has('feed_url') || url.searchParams.has('list_feed_url') || url.searchParams.has('tag')) {
return false;
}
const folderId = url.searchParams.get('folder_id');
if (!folderId) return true;
const rootTree = document.querySelector('.tree[data-root-folder-id]');
const rootFolderId = rootTree ? rootTree.getAttribute('data-root-folder-id') : null;
return !rootFolderId || folderId === rootFolderId;
}
document.addEventListener('click', function(e){
const a = e.target.closest('a');
if (!a) return;
// ignore if already prevented or not a primary button click or modified
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
// ignore links that open in new contexts
if (a.target && a.target.toLowerCase() !== '_self') return;
// only intercept internal same-origin links
let href;
try {
href = a.href;
const u = new URL(href, window.location.origin);
if (u.origin !== window.location.origin) return;
} catch (err) {
return;
}
// Don't intercept the brand/logo link
if (a.classList && a.classList.contains('topbar-brand')) return;
// Only intercept app navigation (folders/feeds/tags/tree links, plus
// the post-list / entry-pane feed-name links that should also expand
// the containing folder in the sidebar).
if (!(a.matches('.feed-link, .tag-link, .tree-item, .post-feed-link, .entry-feed-link, .folders-utility-btn') || a.closest('.tree'))) return;
// If this is a filter pill click (All/Unread/Star), persist the
// `lectio-read-filter` immediately so the SPA loader can include it
// in headers for the server before performing the fetch. This avoids
// a race between capture-phase listeners.
try {
if (a.matches && a.matches('.filter-menu .menu-item:not(.filter-history-item)')) {
const u2 = new URL(href, window.location.origin);
const rf = u2.searchParams.get('read_filter') || u2.searchParams.get('resume_read_filter');
if (rf && _readFilterPillIsRootScope(u2)) {
// Feeds and Saved keep separate remembered filters (mode from the link).
const savedMode2 = u2.searchParams.get('star_only') === '1';
window.localStorage.setItem(savedMode2 ? 'lectio-read-filter-saved' : 'lectio-read-filter', rf);
try { document.cookie = `${savedMode2 ? 'lectio_read_filter_saved' : 'lectio_read_filter'}=${encodeURIComponent(rf)}; path=/; max-age=${60*60*24*365}`; } catch(_) {}
}
}
} catch (err) {}
e.preventDefault();
// Preserve explicit URL state (including read_filter) so filter pills
// and list content stay in sync after scope switches.
let navUrl = href;
try {
const u2 = new URL(href, window.location.origin);
applyCurrentScopeStateToScopeLink(u2, a);
navUrl = u2.toString();
} catch (err) {
// fallback to original href
}
if (window.loadScopePanesWithoutFullRefresh) {
loadScopePanesWithoutFullRefresh(navUrl).then(()=> {
try {
// Scope tabs (FEEDS / SAVED) switch which tree you are browsing —
// nothing has been picked to read yet, so advancing to the post list
// takes the folder list away exactly when it is needed. Every other
// tree link (folder, feed, tag) does mean "show me these posts".
if (window.isSingleMode && window.isSingleMode() && !a.matches('.scope-tab')) {
setSinglePaneLevel(1);
}
} catch(e){}
if (layoutMode === 'wide') {
bodyEl.removeAttribute('data-left-floating');
} else if (layoutMode === 'medium') {
setMediumLeftOpen(false);
mediumFlyoutClickOpen = false;
}
}).catch((_err)=> {});
} else {
window.location.href = navUrl;
}
}, true);
// Persist read_filter when user clicks filter dropdown items (All / Unread / Starred)
document.addEventListener('click', function(e) {
const pill = e.target.closest('.filter-menu .menu-item:not(.filter-history-item)');
if (!pill) return;
try {
const u = new URL(pill.href, window.location.origin);
const rf = u.searchParams.get('read_filter') || u.searchParams.get('resume_read_filter');
if (rf && _readFilterPillIsRootScope(u)) {
// Feeds and Saved keep separate remembered filters (mode from the link).
const savedMode2 = u.searchParams.get('star_only') === '1';
window.localStorage.setItem(savedMode2 ? 'lectio-read-filter-saved' : 'lectio-read-filter', rf);
try { document.cookie = `${savedMode2 ? 'lectio_read_filter_saved' : 'lectio_read_filter'}=${encodeURIComponent(rf)}; path=/; max-age=${60*60*24*365}`; } catch(_){}
}
} catch (err) {
// ignore
}
}, true);
// Entry pane mutation observer is bound in `refreshEntryPaneRefs()`
if (mediumPaneBackdrop) {
mediumPaneBackdrop.addEventListener('click', () => {
if (window.isSingleMode && window.isSingleMode()) {
setSinglePaneLevel(1); // tapping the dimmed list closes the drawer
return;
}
mediumFlyoutClickOpen = false;
setMediumLeftOpen(false);
});
}
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
if (layoutMode === 'medium' && bodyEl.getAttribute('data-medium-left-open') === '1') {
mediumFlyoutClickOpen = false;
setMediumLeftOpen(false);
}
if (appMenuDetails?.open) {
appMenuDetails.removeAttribute('open');
}
hideShareMenu();
});
// Folders pane in-pane collapse/expand + hover floater
const folderCollapseBtn = document.getElementById('folders-collapse-btn');
const folderPaneEl = document.querySelector('.pane-folders');
let mediumFlyoutManualClose = false;
let mediumFlyoutClickOpen = false;
if (folderCollapseBtn) {
folderCollapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (layoutMode === 'wide') {
const collapsed = bodyEl.getAttribute('data-left-collapsed') === '1';
if (collapsed) {
setWideLeftCollapsed(false, true);
bodyEl.removeAttribute('data-left-floating');
} else {
setWideLeftCollapsed(true, true);
}
} else if (layoutMode === 'medium') {
const isOpen = bodyEl.getAttribute('data-medium-left-open') === '1';
if (isOpen) {
mediumFlyoutManualClose = true;
mediumFlyoutClickOpen = false;
setMediumLeftOpen(false);
} else {
mediumFlyoutManualClose = false;
mediumFlyoutClickOpen = true;
setMediumLeftOpen(true);
}
}
});
}
if (folderPaneEl) {
folderPaneEl.addEventListener('mouseenter', () => {
if (layoutMode === 'wide' && bodyEl.getAttribute('data-left-collapsed') === '1') {
bodyEl.setAttribute('data-left-floating', '1');
} else if (layoutMode === 'medium' && !mediumFlyoutManualClose) {
bodyEl.setAttribute('data-medium-left-open', '1');
mediumFlyoutClickOpen = false;
}
});
folderPaneEl.addEventListener('mouseleave', () => {
if (layoutMode === 'wide') {
bodyEl.removeAttribute('data-left-floating');
} else if (layoutMode === 'medium') {
if (!mediumFlyoutClickOpen) {
setMediumLeftOpen(false);
}
mediumFlyoutManualClose = false;
}
});
// Tap on strip to open flyout (touch / pointer click in medium mode)
folderPaneEl.addEventListener('click', () => {
if (layoutMode === 'medium' && bodyEl.getAttribute('data-medium-left-open') !== '1') {
setMediumLeftOpen(true);
mediumFlyoutClickOpen = true;
}
});
}
document.addEventListener('click', (event) => {
if (layoutMode === 'wide'
&& bodyEl.getAttribute('data-left-collapsed') === '1'
&& bodyEl.getAttribute('data-left-floating') === '1'
&& folderPaneEl
&& !folderPaneEl.contains(event.target)) {
bodyEl.removeAttribute('data-left-floating');
}
}, true);
// Close hamburger menu when clicking outside it or on any non-flyout menu item
const appMenuDetails = document.querySelector('.hamburger-menu.topbar-menu');
if (appMenuDetails) {
document.addEventListener('click', (e) => {
if (appMenuDetails.open && !appMenuDetails.contains(e.target)) {
appMenuDetails.removeAttribute('open');
}
}, true);
appMenuDetails.addEventListener('click', (e) => {
const item = e.target.closest('.menu-item');
if (item && !item.classList.contains('flyout-trigger') && !item.closest('.flyout-trigger')) {
appMenuDetails.removeAttribute('open');
}
});
}
// Close any open toolbar dropdown (Filter / Sort / Mark As Read) when
// clicking outside it — native <details> stays open otherwise.
const TOOLBAR_DROPDOWN_SELECTOR = 'details.toolbar-dropdown, details.mark-read-menu';
document.addEventListener('click', (e) => {
document.querySelectorAll(TOOLBAR_DROPDOWN_SELECTOR).forEach((d) => {
if (d.open && !d.contains(e.target)) {
d.removeAttribute('open');
}
});
}, true);
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
document.querySelectorAll(TOOLBAR_DROPDOWN_SELECTOR).forEach((d) => d.removeAttribute('open'));
});
// init
updateSingleMode();
// Ensure entry pane refs and observer are bound for initial load
try { refreshEntryPaneRefs(); } catch (e) {}
// (no URL mutation) rely on SPA interception + headers to persist filter
// Leave the brand/logo as a normal bare link to `/` (no interception).
})();
</script>
{% if message %}
{# Two different offers, because the reasons differ. A page-feed scrapes a
readable page; "Subscribe anyway" is for an address we were REFUSED, which
may be a real feed behind a bot wall that lets up later. It is deliberately
absent when the page was fetched fine and simply is not a feed — that is how
article URLs became permanently failing husk feeds. #}
<div id="toast-message" class="toast-message">{{ message }}{% if no_rss_url %} <button type="button" class="toast-action-btn" id="toast-page-feed-btn">Create page feed</button>{% endif %}{% if force_url %} <button type="button" class="toast-action-btn" id="toast-force-subscribe-btn">Subscribe anyway</button>{% endif %}</div>
{% endif %}
<form id="context-add-feed-form" method="post" action="/feeds" hidden>
<input type="hidden" name="folder_id" id="context-folder-id" />
<input type="hidden" name="feed_url" id="context-feed-url" />
</form>
<form id="context-add-folder-form" method="post" action="/folders" hidden>
<input type="hidden" name="name" id="context-folder-name" />
</form>
<form id="context-rename-folder-form" method="post" action="/folders/rename" hidden>
<input type="hidden" name="folder_id" id="context-rename-folder-id" />
<input type="hidden" name="name" id="context-rename-folder-name" />
</form>
<form id="context-delete-folder-form" method="post" action="/folders/delete" hidden>
<input type="hidden" name="folder_id" id="context-delete-folder-id" />
<input type="hidden" name="feed_action" id="context-delete-folder-feed-action" value="unsub" />
<input type="hidden" name="move_to_folder_id" id="context-delete-folder-move-to" />
</form>
<form id="context-youtube-sync-form" method="post" action="/youtube/sync" hidden>
<input type="hidden" name="folder_id" id="context-youtube-sync-folder-id" />
</form>
<form id="context-refresh-folder-form" method="post" action="/refresh" hidden>
<input type="hidden" name="folder_id" id="context-refresh-folder-id" />
<input type="hidden" name="list_feed_url" id="context-refresh-folder-list-feed-url" value="" />
<input type="hidden" name="feed_url" id="context-refresh-folder-feed-url" value="" />
<input type="hidden" name="entry_id" id="context-refresh-folder-entry-id" value="" />
<input type="hidden" name="sort_by" id="context-refresh-folder-sort-by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" id="context-refresh-folder-sort-dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" id="context-refresh-folder-read-filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" id="context-refresh-folder-star-only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" id="context-refresh-folder-resume-read-filter" value="{{ selected_resume_read_filter }}" />
<input type="hidden" name="tag" id="context-refresh-folder-tag" value="{{ selected_tag or '' }}" />
</form>
<form id="context-mark-read-folder-form" method="post" action="/folders/mark-read" hidden>
<input type="hidden" name="folder_id" id="context-mark-read-folder-id" />
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
</form>
<form id="context-refresh-feed-form" method="post" action="/refresh/feed" hidden>
<input type="hidden" name="folder_id" id="context-refresh-feed-folder-id" />
<input type="hidden" name="feed_url" id="context-refresh-feed-url" />
<input type="hidden" name="list_feed_url" id="context-refresh-list-feed-url" />
<input type="hidden" name="entry_id" id="context-refresh-feed-entry-id" value="" />
<input type="hidden" name="sort_by" id="context-refresh-feed-sort-by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" id="context-refresh-feed-sort-dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" id="context-refresh-feed-read-filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" id="context-refresh-feed-star-only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" id="context-refresh-feed-resume-read-filter" value="{{ selected_resume_read_filter }}" />
<input type="hidden" name="tag" id="context-refresh-feed-tag" value="{{ selected_tag or '' }}" />
</form>
<form id="context-mark-read-feed-form" method="post" action="/feeds/mark-read" hidden>
<input type="hidden" name="folder_id" id="context-mark-read-feed-folder-id" />
<input type="hidden" name="feed_url" id="context-mark-read-feed-url" />
<input type="hidden" name="list_feed_url" id="context-mark-read-list-feed-url" />
{% if selected_tag %}<input type="hidden" name="tag" value="{{ selected_tag }}" />{% endif %}
</form>
<form id="context-post-read-form" method="post" action="/entries/read" hidden>
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="feed_url" id="context-post-read-feed-url" />
<input type="hidden" name="entry_id" id="context-post-read-entry-id" />
<input type="hidden" name="read" id="context-post-read-value" />
<input type="hidden" name="select_entry" value="0" />
{% if selected_feed_url %}
<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />
{% endif %}
{% if selected_tag %}
<input type="hidden" name="tag" value="{{ selected_tag }}" />
{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
</form>
<form id="context-post-range-read-form" method="post" action="/entries/mark-range-read" hidden>
<input type="hidden" name="folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="feed_url" id="context-post-range-feed-url" />
<input type="hidden" name="entry_id" id="context-post-range-entry-id" />
<input type="hidden" name="direction" id="context-post-range-direction" />
{% if selected_feed_url %}
<input type="hidden" name="list_feed_url" value="{{ selected_feed_url }}" />
{% endif %}
{% if selected_tag %}
<input type="hidden" name="tag" value="{{ selected_tag }}" />
{% endif %}
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
{% if selected_query %}
<input type="hidden" name="q" value="{{ selected_query }}" />
{% endif %}
</form>
<form id="context-move-feed-form" method="post" action="/feeds/move" hidden>
<input type="hidden" name="feed_url" id="context-move-feed-url" />
<input type="hidden" name="from_folder_id" id="context-move-feed-from-folder-id" />
<input type="hidden" name="to_folder_id" id="context-move-feed-to-folder-id" />
<input type="hidden" name="current_folder_id" value="{{ selected_folder_id }}" />
<input type="hidden" name="current_list_feed_url" value="{{ selected_feed_url or '' }}" />
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
</form>
<form id="context-disable-feed-form" method="post" action="/feeds/disable" hidden>
<input type="hidden" name="folder_id" id="context-disable-folder-id" />
<input type="hidden" name="feed_url" id="context-disable-feed-url" />
</form>
<form id="context-enable-feed-form" method="post" action="/feeds/enable" hidden>
<input type="hidden" name="folder_id" id="context-enable-folder-id" />
<input type="hidden" name="feed_url" id="context-enable-feed-url" />
</form>
<form id="context-unsubscribe-feed-form" method="post" action="/feeds/unsubscribe" hidden>
<input type="hidden" name="folder_id" id="context-unsubscribe-folder-id" />
<input type="hidden" name="feed_url" id="context-unsubscribe-feed-url" />
<input type="hidden" name="sort_by" value="{{ selected_sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ selected_sort_dir }}" />
<input type="hidden" name="read_filter" value="{{ selected_read_filter }}" />
<input type="hidden" name="star_only" value="{{ 1 if selected_star_only else 0 }}" />
<input type="hidden" name="resume_read_filter" value="{{ selected_resume_read_filter }}" />
</form>
{% include "_action_modals.html" %}
{% include "_context_menus.html" %}
{% include "_add_feed_modal.html" %}
<!-- Save Article dialog: read-later capture of an arbitrary page URL into
the local "Saved Articles" feed (starred, offline-archived). -->
{% if email_configured %}
{% endif %}
{% include "_settings_modal.html" %}
{% include "_feed_properties_modal.html" %}
{% include "_folder_properties_modal.html" %}
<!-- Must precede app.js: it looks these elements up once, at load. -->
<div id="refetch-pill" class="refetch-pill" hidden aria-live="polite">
<div class="refetch-pill-row">
<span class="refetch-pill-scope" id="refetch-pill-scope"></span>
<button type="button" class="refetch-pill-btn" id="refetch-pill-cancel">Stop</button>
</div>
<div class="refetch-pill-bar"><span id="refetch-pill-bar-fill" style="width:0%"></span></div>
<div class="refetch-pill-meta" id="refetch-pill-meta"></div>
<div class="refetch-pill-queue" id="refetch-pill-queue"></div>
</div>
<!-- Existing manual-tag names, for tag-input autocomplete (see attachTagAutocomplete). -->
<script id="lectio-tag-names" type="application/json">{{ all_tag_names | tojson }}</script>
<script src="/static/vendor/katex-0.19.1/katex.min.js"></script>
<script src="/static/vendor/katex-0.19.1/auto-render.min.js"></script>
<script src="/static/js/app.js?v={{ static_asset_version }}"></script>
<script src="/static/js/cleanup.js?v={{ static_asset_version }}"></script>
<!-- Persistent global audio player. Lives outside the entry-pane swap target
so playback survives navigation. Hidden until a track is loaded. -->
<div id="global-audio-player" class="global-audio-player" hidden>
<audio class="gap-audio" preload="metadata"></audio>
<button type="button" class="gap-play" aria-label="Play">▶</button>
<div class="gap-main">
<div class="gap-title" title=""></div>
<div class="gap-scrub">
<span class="gap-cur">0:00</span>
<input type="range" class="gap-seek" min="0" max="0" step="1" value="0"
aria-label="Seek" />
<span class="gap-dur">0:00</span>
</div>
</div>
<button type="button" class="gap-speed" aria-label="Playback speed">1×</button>
<a class="gap-download" href="#" download aria-label="Download audio" hidden>⇩</a>
<button type="button" class="gap-close" aria-label="Close player">✕</button>
</div>
<script src="/static/media-player.js?v={{ static_asset_version }}"></script>
<script>
// Register the offline worker for the MAIN app.
//
// A manifest alone does not make a site installable — the browser also wants
// a service worker controlling the start_url. Read Mode registered one (via
// offline-probe.js, which early-returns without its own rm-* elements and so
// never ran here), but "/" never did. The result was an app that advertised
// a manifest and still could not be installed: "Add to Home screen" degraded
// to a plain shortcut that opens in the browser — which is a tab, and dies
// with Back, which is the whole problem the manifest was meant to solve.
//
// The worker is served from "/" with Service-Worker-Allowed so its scope can
// cover the app (see the /sw.js route). It caches only /read, /static and
// /api/img, so registering it here adds no caching of the main app's HTML —
// requests stay network-first and pass straight through.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
/* installability is a bonus; a failed registration must not break the app */
});
});
}
</script>
</body>
</html>