Pin a feed to the top of the feed list
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).
Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.
Closes #33.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
||||||
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
||||||
browser already had is saved to your account the first time you load the page.
|
browser already had is saved to your account the first time you load the page.
|
||||||
|
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
|
||||||
|
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
|
||||||
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||||
swipe the item you are reading left for the next one and right for the one before, or back
|
swipe the item you are reading left for the next one and right for the one before, or back
|
||||||
to the list from the first.
|
to the list from the first.
|
||||||
|
|||||||
36
src/db.rs
36
src/db.rs
@@ -96,6 +96,8 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|||||||
auto_download INTEGER,
|
auto_download INTEGER,
|
||||||
allow_explicit INTEGER,
|
allow_explicit INTEGER,
|
||||||
max_new_per_check INTEGER,
|
max_new_per_check INTEGER,
|
||||||
|
-- Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (user_id, feed_id)
|
PRIMARY KEY (user_id, feed_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
// Kept per account so a theme follows you to another browser; it was in localStorage.
|
// Kept per account so a theme follows you to another browser; it was in localStorage.
|
||||||
("users", "theme", "TEXT"),
|
("users", "theme", "TEXT"),
|
||||||
("users", "theme_mode", "TEXT"),
|
("users", "theme_mode", "TEXT"),
|
||||||
|
("subscriptions", "pinned", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
];
|
];
|
||||||
let retired: &[(&str, &str)] = &[
|
let retired: &[(&str, &str)] = &[
|
||||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||||
@@ -1060,6 +1063,24 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The feeds this person pinned to the top of their list. Kept apart from `Sub`, which is
|
||||||
|
/// what the scanner merges into its policy, and which a pin has nothing to do with.
|
||||||
|
pub fn pinned_feeds(&self, user_id: i64) -> Result<std::collections::HashSet<String>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT feed_id FROM subscriptions WHERE user_id = ?1 AND pinned")?;
|
||||||
|
let ids = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False when they do not subscribe to it, since there is then no row in their list to pin.
|
||||||
|
pub fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE subscriptions SET pinned = ?3 WHERE user_id = ?1 AND feed_id = ?2",
|
||||||
|
params![user_id, feed_id, on as i64],
|
||||||
|
)? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -2029,6 +2050,21 @@ mod tests {
|
|||||||
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
|
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
|
||||||
|
let db = Db::memory().unwrap();
|
||||||
|
let me = db.create_user("pat", None, false).unwrap();
|
||||||
|
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
|
||||||
|
db.subscribe(me, "f").unwrap();
|
||||||
|
assert!(db.set_pinned(me, "f", true).unwrap());
|
||||||
|
// set_subscription writes the rest of the row; it must leave the pin alone.
|
||||||
|
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() })
|
||||||
|
.unwrap();
|
||||||
|
assert!(db.pinned_feeds(me).unwrap().contains("f"));
|
||||||
|
db.set_pinned(me, "f", false).unwrap();
|
||||||
|
assert!(db.pinned_feeds(me).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enclosure_url_is_the_dedupe_key() {
|
fn enclosure_url_is_the_dedupe_key() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
|
|||||||
18
src/web.rs
18
src/web.rs
@@ -614,6 +614,8 @@ struct FeedRow {
|
|||||||
unread: i64,
|
unread: i64,
|
||||||
/// Including you. More than one means every file here is shared.
|
/// Including you. More than one means every file here is shared.
|
||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
|
/// Pinned to the top of your list.
|
||||||
|
pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -641,6 +643,7 @@ async fn feeds(
|
|||||||
.map(|s| (s.feed_id.clone(), s))
|
.map(|s| (s.feed_id.clone(), s))
|
||||||
.collect();
|
.collect();
|
||||||
let counts = state.ctx.db.subscriber_counts()?;
|
let counts = state.ctx.db.subscriber_counts()?;
|
||||||
|
let pinned = state.ctx.db.pinned_feeds(user.id)?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in &subs {
|
for sub in &subs {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
@@ -701,6 +704,7 @@ async fn feeds(
|
|||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
unread: state.ctx.db.unread_count(user.id, id)?,
|
unread: state.ctx.db.unread_count(user.id, id)?,
|
||||||
subscribers: counts.get(id).copied().unwrap_or(0),
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||||
|
pinned: pinned.contains(id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
@@ -884,6 +888,13 @@ impl ApiError {
|
|||||||
status: StatusCode::FORBIDDEN,
|
status: StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn not_found(msg: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: anyhow::Error::msg(msg.into()),
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
@@ -1218,6 +1229,7 @@ struct FeedPatch {
|
|||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
max_new_per_check: Option<Option<usize>>,
|
max_new_per_check: Option<Option<usize>>,
|
||||||
|
pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
@@ -1234,6 +1246,12 @@ async fn patch_feed(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<FeedPatch>,
|
Json(body): Json<FeedPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
||||||
|
if let Some(on) = body.pinned
|
||||||
|
&& !state.ctx.db.set_pinned(user.id, &id, on)?
|
||||||
|
{
|
||||||
|
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||||
|
}
|
||||||
// What one person wants -- which items, whether to fetch them, how many at a time --
|
// What one person wants -- which items, whether to fetch them, how many at a time --
|
||||||
// is theirs. It goes on their subscription and nobody else sees the change.
|
// is theirs. It goes on their subscription and nobody else sees the change.
|
||||||
if state.ctx.db.subscription(user.id, &id)?.is_some() {
|
if state.ctx.db.subscription(user.id, &id)?.is_some() {
|
||||||
|
|||||||
@@ -1263,3 +1263,30 @@ test.describe('an item with no files, on a phone', () => {
|
|||||||
await expect(page.locator('#detail')).not.toContainText('No files');
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||||
|
const rows = page.locator('#feedlist .feed');
|
||||||
|
const group = page.locator('.feed.group').first();
|
||||||
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||||
|
const child = page.locator('.feed.child').first();
|
||||||
|
const name = (await child.locator('.txt b').textContent()).trim();
|
||||||
|
await child.click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
|
||||||
|
// First in the list, out of its folder, marked, and with the rule under it.
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
await expect(rows.first()).toHaveClass(/\bpinned\b/);
|
||||||
|
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
|
||||||
|
await expect(rows.first()).toHaveClass(/\blastpin\b/);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
|
||||||
|
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// It is on the account: a reload keeps it.
|
||||||
|
await page.reload();
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
|
||||||
|
// Unpinned, it goes back into its folder.
|
||||||
|
await rows.first().click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -363,6 +363,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.feed.sel{background:var(--raise)}
|
.feed.sel{background:var(--raise)}
|
||||||
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||||
.feed.child{margin-left:11px}
|
.feed.child{margin-left:11px}
|
||||||
|
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
||||||
|
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
|
||||||
|
.feed.lastpin{margin-bottom:9px}
|
||||||
|
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
||||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||||
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function renderFeed(){
|
|||||||
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +119,7 @@ function renderGroup(f,kids){
|
|||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,6 +159,12 @@ async function feedAction(a,f){
|
|||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
|
if(a==='pin'){
|
||||||
|
try{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||||
|
await loadFeeds(true); renderFeed();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
if(a==='settings') settingsModal(f);
|
if(a==='settings') settingsModal(f);
|
||||||
if(a==='dl') downloadLatestModal(f);
|
if(a==='dl') downloadLatestModal(f);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,19 +53,25 @@ function renderFeeds(){
|
|||||||
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||||
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||||
|
|
||||||
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
|
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
||||||
|
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
|
||||||
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
|
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||||
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||||
const order=[];
|
const order=[];
|
||||||
for(const f of shown){
|
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||||
if(f.group && byId[f.group]) continue; // drawn under its parent instead
|
|
||||||
order.push([f,0]);
|
order.push([f,0]);
|
||||||
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
// opens them all, or matches inside a closed folder would be invisible.
|
// opens them all, or matches inside a closed folder would be invisible.
|
||||||
if(expanded.has(f.id) || q)
|
if(expanded.has(f.id) || q)
|
||||||
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
|
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
|
||||||
}
|
}
|
||||||
|
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
|
||||||
|
// it is open.
|
||||||
|
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
|
||||||
|
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
|
||||||
for(const [f,depth] of order){
|
for(const [f,depth] of order){
|
||||||
const kids=shown.filter(c=>c.group===f.id).length;
|
const kids=inside(f).length;
|
||||||
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
||||||
// taken from every feed it holds, not just the ones a filter left showing.
|
// taken from every feed it holds, not just the ones a filter left showing.
|
||||||
const mine=S.feeds.filter(c=>c.group===f.id);
|
const mine=S.feeds.filter(c=>c.group===f.id);
|
||||||
@@ -79,7 +85,8 @@ function renderFeeds(){
|
|||||||
const bad=c=>c.failing?.reason||c.last_error;
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
el.tabIndex=0; el.dataset.id=f.id;
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
const open = !!(kids && (expanded.has(f.id) || q));
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
@@ -88,7 +95,7 @@ function renderFeeds(){
|
|||||||
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
||||||
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
||||||
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
||||||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
|
|||||||
Reference in New Issue
Block a user