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:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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` | |
|
||||||
|
|||||||
13
src/web.rs
13
src/web.rs
@@ -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")]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
154
web/index.html
154
web/index.html
@@ -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=>{
|
||||||
|
|||||||
Reference in New Issue
Block a user