One icon per meaning across the UI; mark everything read in All Subscriptions

- All Subscriptions' header checks every feed and marks everything read
  (POST /api/read-all, the same feeds the view lists); it asks first.
- Minus unsubscribes everywhere (the feed header's x read as "close"),
  x only closes or cancels, plus adds/subscribes/imports, and a dialog's
  confirm carries its action's icon. Remaining word buttons, the player
  and the folder arrow are Font Awesome 7.3.1 icons.
- Toolbar grouped by what it acts on (add, unsubscribe, scan | play,
  read, keep); read and keep show the selected item's state.
- The OPML subscription page uses the same header as a feed.
- Fixed: Escape ignored inside a dialog's text box (Add feed could not
  be closed with it), white password box in the dark theme, stray dot
  in an undated item's details.
- Tests: one action one icon across toolbar, page and all 8 dialogs;
  All Subscriptions mark everything read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 17:05:47 +00:00
parent 57dcba2d1a
commit 0efc49519c
5 changed files with 188 additions and 61 deletions

View File

@@ -22,6 +22,8 @@ The long form, with what was wrong before and how it was found, is in
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`). Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, item, feed, file, published) with a Files pane beside it, - Items show as a table (unread, kept, item, feed, file, published) with a Files pane beside it,
and a status bar with the totals. and a status bar with the totals.
- Mark everything read from All Subscriptions, across every feed you subscribe to
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
### Changed ### Changed
@@ -38,10 +40,26 @@ The long form, with what was wrong before and how it was found, is in
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope. font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x
and read as closing the page. The remaining word buttons are icons too:
- Log, Add feed, Users, Unsubscribe and OPML.
- Popular's Subscribe, Copy and Sign out.
- The player's back, play, forward and close, which were font characters, and the folder arrow.
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is - A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
downloaded and red when the download failed, with the details in its tooltip. One icon per downloaded and red when the download failed, with the details in its tooltip. One icon per
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone. row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
### Fixed
- The password box in Manage users was white in the dark theme.
- An item with no date showed a stray dot in its details.
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens
in its URL box, could not be closed with Escape.
### Security ### Security
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never - Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never

View File

@@ -107,6 +107,7 @@ else a `401`.
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable | | `GET /api/feeds/{id}/entries` | paged, filtered, searchable |
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) | | `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
| `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | | | `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position | | `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position |
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |

View File

@@ -69,6 +69,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all)) .route("/api/feeds/{id}/read-all", post(read_all))
.route("/api/read-all", post(read_all_mine))
.route("/api/feeds/{id}/download-latest", post(download_latest)) .route("/api/feeds/{id}/download-latest", post(download_latest))
.route("/api/enclosures/{id}/download", post(download_now)) .route("/api/enclosures/{id}/download", post(download_now))
.route("/api/enclosures/{id}", delete(delete_file)) .route("/api/enclosures/{id}", delete(delete_file))
@@ -1260,6 +1261,18 @@ async fn read_all(
Ok(Json(serde_json::json!({ "marked": n }))) Ok(Json(serde_json::json!({ "marked": n })))
} }
/// Everything read in every feed you subscribe to: exactly what All Subscriptions lists, since
/// that view is scoped by the same subscriptions.
async fn read_all_mine(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids: Vec<String> =
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
Ok(Json(serde_json::json!({ "marked": n })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct HowMany { struct HowMany {
#[serde(default = "five")] #[serde(default = "five")]

View File

@@ -455,7 +455,7 @@ test('Settings exports your OPML and imports a pasted one', async ({ page }) =>
await page.locator('#prefs').click(); await page.locator('#prefs').click();
const [dl] = await Promise.all([ const [dl] = await Promise.all([
page.waitForEvent('download'), page.waitForEvent('download'),
page.locator('#modalCard a', { hasText: 'Export OPML' }).click(), page.locator('#modalCard a[title="Export OPML"]').click(),
]); ]);
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml'); expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
const out = require('fs').readFileSync(await dl.path(), 'utf8'); const out = require('fs').readFileSync(await dl.path(), 'utf8');
@@ -636,7 +636,7 @@ test('Popular lists what everyone here reads, but never a private feed', async (
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show'); (await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row(); const before = await row();
expect(before.subscribed).toBe(false); expect(before.subscribed).toBe(false);
await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click(); await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
@@ -679,3 +679,62 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
await expect(page.locator('#files')).not.toContainText(/reaped/i); await expect(page.locator('#files')).not.toContainText(/reaped/i);
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible(); await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
}); });
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
const icon = loc => loc.locator('svg path').first().getAttribute('d');
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
// a change made from the toolbar.
await page.locator('.ep').first().click();
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
await icon(page.locator(`#detail [data-a="${a}"]`))];
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
const [kept] = await pair('flag');
await page.locator('#tbFlag').click();
await expect.poll(async () => { const [tb, own] = await pair('flag'); return tb === own && tb !== kept; }).toBe(true);
await page.locator('#tbFlag').click(); // leave it as it was
await expect.poll(async () => (await pair('flag'))[0]).toBe(kept);
// Every button in every dialog is an icon with its words in the tooltip.
const dialogs = [
() => page.locator('#addFeed').click(),
() => page.locator('#prefs').click(),
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
() => page.locator('#logs').click(),
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),
];
for (const open of dialogs) {
await open();
const btns = page.locator('#modalCard .btn');
await expect(btns.first()).toBeVisible();
for (const b of await btns.all()) {
await expect(b.locator('svg')).toHaveCount(1);
await expect(b).toHaveAttribute('title', /\S/);
}
await page.keyboard.press('Escape');
await expect(page.locator('#modal.on')).toBeHidden();
}
});
test('All Subscriptions marks everything read, across every feed', async ({ page }) => {
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
await all.click();
// Earlier tests read things; make sure something is unread. Opening an item reads it, and
// its own button makes it unread again.
await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept());
await page.locator('#content .acts [data-a="readall"]').click();
await expect(all.locator('.badge')).toHaveText('0');
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep')).toHaveCount(0);
});

View File

@@ -125,7 +125,7 @@ a{color:var(--accent)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px} .sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75} .sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px} .searchwrap{padding:0 12px 8px}
input[type=search],input[type=text],input[type=number],select{ input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg); width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px; border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
} }
@@ -515,20 +515,18 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<body> <body>
<header id="topbar"> <header id="topbar">
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button> <button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
A phone hides the item group, which it has no table for. -->
<div class="tgroup"> <div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button> <button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button> <button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button>
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
</div> </div>
<div class="tgroup item"> <div class="tgroup item">
<button id="tbPlay" title="Play the selected item" aria-label="Play the selected item" data-icon="play" disabled></button> <button id="tbPlay" title="Play the selected item" aria-label="Play the selected item" data-icon="play" disabled></button>
</div>
<div class="tgroup item">
<button id="tbRead" title="Mark the selected item read or unread" aria-label="Mark read or unread" data-icon="check" disabled></button> <button id="tbRead" title="Mark the selected item read or unread" aria-label="Mark read or unread" data-icon="check" disabled></button>
<button id="tbFlag" title="Keep the selected item, so it is never deleted" aria-label="Keep" data-icon="flag" disabled></button> <button id="tbFlag" title="Keep the selected item, so it is never deleted" aria-label="Keep" data-icon="flag" disabled></button>
</div> </div>
<div class="tgroup">
<button id="scanAll" title="Check every feed for new items" data-icon="scan"> Scan</button>
</div>
<span class="grow"></span> <span class="grow"></span>
<input type="search" id="epSearch" placeholder="Search items…"> <input type="search" id="epSearch" placeholder="Search items…">
<div class="tgroup" id="admintools"> <div class="tgroup" id="admintools">
@@ -545,7 +543,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div> <div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div> <div id="feedlist"></div>
<div class="sidefoot"> <div class="sidefoot">
<div class="who"><span id="who"></span><button id="signout" title="Sign out">Sign out</button></div> <div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
</div> </div>
</aside> </aside>
<div id="main"> <div id="main">
@@ -562,9 +560,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
</div> </div>
<div id="pmid"> <div id="pmid">
<div id="pbtns"> <div id="pbtns">
<button class="iconbtn" id="pback" title="Back 15s (←)">↺15</button> <button class="iconbtn" id="pback" title="Back 15 seconds (←)" aria-label="Back 15 seconds" data-icon="back"></button>
<button class="iconbtn" id="pplay" title="Play/pause (space)"></button> <button class="iconbtn" id="pplay" title="Play or pause (space)" aria-label="Play or pause" data-icon="play"></button>
<button class="iconbtn" id="pfwd" title="Forward 30s (→)">30↻</button> <button class="iconbtn" id="pfwd" title="Forward 30 seconds (→)" aria-label="Forward 30 seconds" data-icon="fwd"></button>
</div> </div>
<div id="seekrow"> <div id="seekrow">
<span id="pcur">0:00</span> <span id="pcur">0:00</span>
@@ -579,7 +577,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<option value="1.75">1.75×</option><option value="2">2×</option><option value="2.5">2.5×</option> <option value="1.75">1.75×</option><option value="2">2×</option><option value="2.5">2.5×</option>
</select> </select>
<input type="range" id="vol" min="0" max="100" value="100" title="Volume"> <input type="range" id="vol" min="0" max="100" value="100" title="Volume">
<button class="iconbtn" id="pclose" title="Close"></button> <button class="iconbtn" id="pclose" title="Close the player" aria-label="Close the player" data-icon="close"></button>
</div> </div>
</div> </div>
@@ -625,7 +623,17 @@ const ICON={
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
}; };
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
// The page's own buttons name their icon; this draws it in, ahead of any label they carry. // The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]); for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
@@ -640,9 +648,10 @@ async function api(url,opts){
async function copyText(text,btn){ async function copyText(text,btn){
const flash=ok=>{ const flash=ok=>{
if(!btn) return; if(!btn) return;
const was=btn.textContent; // The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML;
btn.textContent=ok?'Copied':'Failed'; btn.textContent=ok?'Copied':'Failed';
setTimeout(()=>btn.textContent=was,1300); setTimeout(()=>btn.innerHTML=was,1300);
}; };
try{ try{
if(navigator.clipboard&&window.isSecureContext){ if(navigator.clipboard&&window.isSecureContext){
@@ -763,7 +772,7 @@ function renderFeeds(){
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
const open = kids && (expanded.has(f.id) || q); const open = kids && (expanded.has(f.id) || q);
el.innerHTML = el.innerHTML =
`<span class="chev${open?' open':''}"${kids?' title="Show or hide the feeds inside"':''}>${kids?'▶':''}</span>`+ `<span class="chev${open?' open':''}"${kids?' title="Show or hide the feeds inside"':''}>${kids?ICON.caret:''}</span>`+
artHTML(f.image,f.title||f.id)+ artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+ `<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` (mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
@@ -811,11 +820,11 @@ function renderFeed(){
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''} ${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div> </div>
<div class="acts"> <div class="acts">
<button class="btn ico primary" data-a="scan" title="Scan now" aria-label="Scan 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="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 last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.close}</button> <button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
</div> </div>
</div>` : ` </div>` : `
<div class="fhead slim"> <div class="fhead slim">
@@ -825,6 +834,10 @@ function renderFeed(){
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you <div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
subscribe to, newest first · ${unreadAll} unread</div> subscribe to, newest first · ${unreadAll} unread</div>
</div> </div>
<div class="acts">
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
</div>
</div>`) + ` </div>`) + `
<div class="toolbar"> <div class="toolbar">
<div class="tabs"> <div class="tabs">
@@ -844,7 +857,7 @@ function renderFeed(){
dragSplit(pane); dragSplit(pane);
showDetail(null); showDetail(null);
if(f) $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()}); $$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
} }
@@ -855,23 +868,24 @@ function renderGroup(f,kids){
const saved=kids.reduce((n,c)=>n+c.downloaded,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length; const gone=kids.filter(c=>c.orphaned).length;
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`; $('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
// The same header as a feed's, buttons in the same places: it is a feed underneath.
$('#content').innerHTML = ` $('#content').innerHTML = `
<div class="fhead"> <div class="fhead slim">
${artHTML(f.image,f.title||f.id)} ${artHTML(f.image,f.title||f.id)}
<div class="meta"> <div class="meta">
<h2>${esc(f.title||f.id)}</h2> <h2>${esc(f.title||f.id)}</h2>
<div class="sub">OPML subscription · ${kids.length} feed${kids.length===1?'':'s'} <div class="sub stat">OPML subscription · ${kids.length} feed${kids.length===1?'':'s'}
· ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)} · ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)}
· every ${everyText(f.every_mins)}</div> · every ${everyText(f.every_mins)}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''} ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer ${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed in this OPML but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''} listed in this OPML but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
<div class="acts"> </div>
<button class="btn ico primary" data-a="scan" title="Re-read the OPML" aria-label="Re-read the OPML">${ICON.scan}</button> <div class="acts">
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</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="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.close}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
</div> <button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
</div> </div>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@@ -909,6 +923,20 @@ async function feedAction(a,f){
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){
if(a==='scanall') return scanAll();
if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
if(!n){ toast('Nothing unread'); return; }
// One click across every feed is a lot to take back, so this one asks first.
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
try{
const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
}
}
/* ---------------- items ---------------- */ /* ---------------- items ---------------- */
async function loadEntries(append){ async function loadEntries(append){
@@ -959,7 +987,7 @@ function epEl(e){
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=` el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${ <button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?'▶':(e.read?'':'●')}</button> player.guid===e.guid?ICON.play:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${ <button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?ICON.flagOn:ICON.flag}</button> e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
<div class="body"> <div class="body">
@@ -1084,6 +1112,9 @@ const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){ function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e; $('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.flagOn:ICON.flag;
if(e){ if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`; $('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted'; $('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted';
@@ -1106,13 +1137,12 @@ function showDetail(e){
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=` box.innerHTML=`
<button class="btn" id="dback">← Items</button> <button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3> <h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta"> <div class="dmeta">
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''} ${/* Joined, so a missing date or number leaves no stray dot behind. */
${num?`<span>${num}</span><span class="dot"></span>`:''} [f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
<span>${dateOf(e.published)}</span> .filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}" <button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button> aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Kept: never deleted. Stop keeping':'Keep, so it is never deleted'}" <button class="btn ico" data-a="flag" title="${e.flagged?'Kept: never deleted. Stop keeping':'Keep, so it is never deleted'}"
@@ -1280,9 +1310,9 @@ function savePos(){
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'})); new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'}));
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').textContent='▶'}); audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
audio.addEventListener('play',()=>$('#pplay').textContent='❚❚'); audio.addEventListener('play',()=>$('#pplay').innerHTML=ICON.pause);
audio.addEventListener('pause',()=>$('#pplay').textContent='▶'); audio.addEventListener('pause',()=>$('#pplay').innerHTML=ICON.play);
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $('#pback').onclick=()=>audio.currentTime-=15;
@@ -1298,12 +1328,15 @@ $('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');p
})(); })();
document.addEventListener('keydown',ev=>{ document.addEventListener('keydown',ev=>{
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
if(ev.key==='Escape'){closeModal();nav(false);return}
// The rest are single keys that would otherwise eat what you type.
if(/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) return; if(/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) return;
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()} if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(ev.key==='Escape'){closeModal();nav(false)}
}); });
/* ---------------- modals ---------------- */ /* ---------------- modals ---------------- */
@@ -1346,8 +1379,8 @@ function logsModal(){
</select> </select>
<input type="search" id="logq" class="grow" placeholder="Filter…"> <input type="search" id="logq" class="grow" placeholder="Filter…">
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label> <label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn" id="logcopy">Copy</button> <button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
<button class="btn" onclick="closeModal()">Close</button> <button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
</div> </div>
<div id="logbox"><p class="empty">Loading…</p></div> <div id="logbox"><p class="empty">Loading…</p></div>
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and <span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
@@ -1417,20 +1450,20 @@ $('#addFeed').onclick=()=>{
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div> <input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<div class="field"><label>Popular on this server</label> <div class="field"><label>Popular on this server</label>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div></div> <div class="childlist" id="popular"><p class="hint">Loading…</p></div></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn primary" id="nsave">Add feed</button></div>`); <button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
$('#nurl').focus(); $('#nurl').focus();
listFeeds('/api/popular'); listFeeds('/api/popular');
$('#nsave').onclick=async()=>{ $('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return; const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true; $('#nsave').disabled=true; $('#nsave').title='Adding…';
try{ try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null, url, folder:$('#nfolder').value.trim()||null,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').textContent='Add feed'; $('#nsave').disabled=false; } }catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
}; };
}; };
@@ -1447,7 +1480,8 @@ async function listFeeds(url){
el.innerHTML=artHTML(p.image,p.title||p.id)+ el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+ `<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+ `<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
(p.subscribed?'<span class="tag">Subscribed</span>':'<button class="btn" data-a="sub">Subscribe</button>'); (p.subscribed?'<span class="tag">Subscribed</span>'
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
// Yours already: the row opens it instead. // Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; } if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{ $('[data-a="sub"]',el).onclick=async()=>{
@@ -1561,13 +1595,13 @@ async function prefsModal(){
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label> <div class="field"><label>Subscriptions</label>
<div class="inline"> <div class="inline">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export OPML</a> <a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
<button class="btn" id="gopml">Import OPML…</button> <button class="btn ico" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus}</button>
</div> </div>
<span class="hint">Export hands every subscription to another podcast app. Import adds the <span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
feeds listed in an OPML you paste in.</span></div> subscribes you to every feed in one.</span></div>
<div class="field"><label>Users</label> <div class="field"><label>Users</label>
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div> <div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.users}</button></div>
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div> <span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`); <button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
@@ -1595,7 +1629,7 @@ async function usersModal(){
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b> <b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'} ${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label> <label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
<button class="btn danger" data-a="rm">Remove</button></div>`).join('')} <button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
<div class="field" style="margin-top:16px"><label>Add someone</label> <div class="field" style="margin-top:16px"><label>Add someone</label>
<div class="inline"> <div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false"> <input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
@@ -1604,8 +1638,8 @@ async function usersModal(){
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label> <label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in <span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div> through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn primary" id="uadd">Add</button></div>`); <button class="btn ico primary" id="uadd" title="Add" aria-label="Add">${ICON.plus}</button></div>`);
const change=async(u,opts)=>{ const change=async(u,opts)=>{
try{ try{
await api(`/api/users/${u.id}`,opts); await api(`/api/users/${u.id}`,opts);
@@ -1655,7 +1689,7 @@ function settingsModal(f){
<div class="field"><label>Feed URL</label> <div class="field"><label>Feed URL</label>
<div class="inline"> <div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}> <input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn" id="scopy">Copy</button> <button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div> </div>
<span class="hint">${S.me&&S.me.admin <span class="hint">${S.me&&S.me.admin
? `Shared with everyone reading this feed. Editing it keeps every item and download — ? `Shared with everyone reading this feed. Editing it keeps every item and download —
@@ -1710,8 +1744,8 @@ function removeFeed(f){
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else <p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state. reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p> Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn danger" id="rgo">Unsubscribe</button></div>`); <button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button></div>`);
$('#rgo').onclick=async()=>{ $('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null; closeModal(); toast('Unsubscribed'); S.feed=null;
@@ -1722,14 +1756,15 @@ function removeFeed(f){
function opmlModal(){ function opmlModal(){
openModal(`<h3>OPML</h3> openModal(`<h3>OPML</h3>
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p> <p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
<div class="cardacts" style="justify-content:flex-start"> <div class="field"><label>Export: save your subscriptions as OPML</label>
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a> <div class="inline">
</div> <a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div>
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label> <div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px"> <input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div> <textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn primary" id="oimp">Import</button></div>`); <button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$('#oimp').onclick=async()=>{ $('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing // A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported. // the picker lets go of it on this side too, whether it was refused or imported.
@@ -1753,7 +1788,8 @@ function on(sel,ev,fn){
if(typeof fn!=='function'){ console.error('ipx: handler for',sel,'is not a function'); return; } if(typeof fn!=='function'){ console.error('ipx: handler for',sel,'is not a function'); return; }
el[ev]=fn; el[ev]=fn;
} }
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }; async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll;
on('#prefs','onclick',prefsModal); on('#prefs','onclick',prefsModal);
on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; }); on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; });
api('/api/me').then(u=>{ api('/api/me').then(u=>{