Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes, light and dark), in Playwright against the test daemon. - #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named by what they show, the open one aria-current. Not role=button, which would flatten a folder's triangle inside it. - #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0. - #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers. - #301: the seek slider is named Position, its value read as "12:30 of 45:00". - #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search and filter boxes have names that do not depend on their placeholder. - #303: #toasts is role=status; an error toast stays until dismissed, at most four at once. - #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the daemon's internals, already in its log); play() failures are put in words and reported, an AbortError ignored; a failed download names its item. - #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80% for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks, phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0, so they take the same focus ring as everything else. - #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it, and makes the rest of the body inert until closeModal, which gives focus back to what opened it. Not <dialog>: its top layer would cover the toasts and their Dismiss. - #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes. - #309: Settings shows iPX's and the page's versions at its foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ const blocks = {};
|
||||
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||
const vars = {};
|
||||
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||
const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100;
|
||||
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) {
|
||||
}
|
||||
themes['modern'] = base;
|
||||
|
||||
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
|
||||
const oklab = h => {
|
||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
|
||||
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
|
||||
};
|
||||
const fromOklab = ([L, a, b]) => {
|
||||
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
|
||||
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
|
||||
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
|
||||
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); }
|
||||
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
|
||||
|
||||
let bad = 0;
|
||||
for (const [name, p] of Object.entries(themes)) {
|
||||
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||
|
||||
@@ -16,7 +16,7 @@ function makeContext(html, script, { media = false } = {}) {
|
||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
|
||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||
|
||||
@@ -2291,3 +2291,53 @@ signalsTest('the server pays for Signals only when it has a key to pay with', as
|
||||
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('a dialog keeps the keyboard inside it and gives it back on closing', async ({ page }) => {
|
||||
// #306: Tab walked out of a dialog into the toolbar, and closing one left focus on the page.
|
||||
await page.locator('#addFeed').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#modalCard[role=dialog]')).toHaveAttribute('aria-labelledby', /.+/);
|
||||
await expect(page.locator('#nurl')).toBeFocused();
|
||||
// Labels name their boxes (#307).
|
||||
await expect(page.getByRole('textbox', { name: 'Feed URL' })).toHaveCount(1);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
// Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind.
|
||||
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#modal.on')).toBeHidden();
|
||||
await expect(page.locator('#addFeed')).toBeFocused();
|
||||
expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false);
|
||||
});
|
||||
|
||||
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
||||
const row = page.locator('#eps .ep').first();
|
||||
// #298: the title is a button, so Tab reaches the item itself.
|
||||
await row.locator('button.t').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(row).toHaveClass(/\bsel\b/);
|
||||
// #300: the read toggle is named for what it does, not its dot.
|
||||
await expect(row.locator('.st')).toHaveAttribute('aria-label', /^Mark (read|unread)$/);
|
||||
// #299: focus shows what hover shows.
|
||||
await page.mouse.move(0, 0);
|
||||
const act = row.locator('.rowacts button').first();
|
||||
if (await act.count()) {
|
||||
await act.focus();
|
||||
await expect(row.locator('.rowacts')).toHaveCSS('opacity', '1');
|
||||
}
|
||||
// #298: a feed in the list is a link to a screen reader, the open one current.
|
||||
await expect(page.locator('#feedlist .feed[aria-current=page]')).toHaveAttribute('role', 'link');
|
||||
});
|
||||
|
||||
test('an error toast is announced and stays until it is dismissed', async ({ page }) => {
|
||||
// #303: role=status, and no timer on an error.
|
||||
await expect(page.locator('#toasts')).toHaveAttribute('role', 'status');
|
||||
await page.evaluate(() => toast('Unable to play this file. Try again, or download it again.', true));
|
||||
const t = page.locator('.toast.bad').last();
|
||||
await page.waitForTimeout(6500);
|
||||
await expect(t).toBeVisible();
|
||||
await t.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(t).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- An item opens from the keyboard and with a screen reader: its title is a button. The feed list's rows and the Directory's shows are links to a screen reader, the open one marked current (#298).
|
||||
- An item's Play, Download and Delete buttons show while the keyboard is on one, as they do under the pointer (#299).
|
||||
- A screen reader names an item's read button for what it does, Mark read or Mark unread, not "●" (#300).
|
||||
- The player's position slider has a name, and a screen reader reads where you are as a time, "12:30 of 45:00" (#301).
|
||||
- Placeholder text is in the theme's own hint colour, readable in every theme, and the search and feed filter boxes have names of their own (#302).
|
||||
- A screen reader reads toasts out, and an error stays until you dismiss it with its x; no more than four show at once (#303).
|
||||
- Error messages say what to do, for a server error, a lost connection, a file that would not play or a download that failed, in place of the browser's or the server's own words (#304).
|
||||
- Text boxes, selects and switches have an edge you can see in every theme, and a box with the keyboard in it has the same ring as every other control (#305, #308).
|
||||
- Dialogs are dialogs to a screen reader, named by their heading. The page behind one is out of reach while it is open, and closing it puts the keyboard back where it was (#306).
|
||||
- The boxes in Add a feed, Settings, Download latest and OPML are named by their labels, and clicking a label puts the cursor in its box (#307).
|
||||
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
|
||||
|
||||
## [1.2.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<section id="directory" hidden></section>
|
||||
<section id="log" hidden></section>
|
||||
</main>
|
||||
<div id="toasts"></div>
|
||||
<div id="toasts" role="status"></div>
|
||||
|
||||
<script data-src="web/src"></script>
|
||||
</body>
|
||||
|
||||
39
web/app.css
39
web/app.css
@@ -20,6 +20,12 @@
|
||||
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
|
||||
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
|
||||
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
|
||||
further apart takes more; tests/contrast.js holds every palette to it. */
|
||||
--fieldmix:60%;
|
||||
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
|
||||
}
|
||||
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
||||
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
||||
@@ -132,6 +138,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="adwaita"][data-mode="light"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#ffffff;
|
||||
--panel:#ebebed;
|
||||
--panel2:#fafafb;
|
||||
@@ -206,6 +213,7 @@
|
||||
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
|
||||
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
|
||||
:root[data-theme="nordic"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#2e3440; /* nord0 */
|
||||
--panel:#3b4252; /* nord1 */
|
||||
--panel2:#434c5e; /* nord2 */
|
||||
@@ -243,6 +251,7 @@
|
||||
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||
and peach for the rest. */
|
||||
:root[data-theme="catppuccin"] {
|
||||
--fieldmix:70%;
|
||||
--bg:#1e1e2e; /* base */
|
||||
--panel:#181825; /* mantle */
|
||||
--panel2:#313244; /* surface0 */
|
||||
@@ -260,6 +269,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||
--fieldmix:80%;
|
||||
--bg:#eff1f5; /* base */
|
||||
--panel:#e6e9ef; /* mantle */
|
||||
--panel2:#dce0e8; /* crust */
|
||||
@@ -296,6 +306,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||
--fieldmix:70%;
|
||||
--bg:#fbf1c7; /* bg0 */
|
||||
--panel:#f2e5bc; /* bg0_s */
|
||||
--panel2:#ebdbb2; /* bg1 */
|
||||
@@ -332,6 +343,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="solarized"][data-mode="light"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#fdf6e3; /* base3 */
|
||||
--panel:#eee8d5; /* base2 */
|
||||
--panel2:#e6dfca;
|
||||
@@ -562,11 +574,13 @@ a{color:var(--accent)}
|
||||
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
.sidefoot i{font-style:normal;opacity:.75}
|
||||
.searchwrap{padding:12px 12px 8px}
|
||||
/* The browser's own grey fell short of AA in most themes, and a placeholder is all some boxes say (#302). */
|
||||
::placeholder{color:var(--faint);opacity:1}
|
||||
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(--field);color:var(--fg);
|
||||
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||
}
|
||||
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
/* A search box and the x that clears it, inside its right end, there only once something is typed
|
||||
(#291). The browser's own x, in some browsers and not others, gives way to it. */
|
||||
.clearable{position:relative;display:flex;align-items:center;min-width:0}
|
||||
@@ -992,7 +1006,7 @@ body.playing .eq i:nth-child(1){animation-delay:-.3s}
|
||||
body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
@keyframes eq{from{transform:scaleY(.35)}}
|
||||
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
||||
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ep .t{font-weight:600;font-size:13.5px;display:block;width:100%;padding:0;text-align:start;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
/* An item with no title shows its opening words: set as text, not as a heading. */
|
||||
.ep .t.notitle{font-weight:400}
|
||||
.ep.read .t{color:var(--dim);font-weight:500}
|
||||
@@ -1009,7 +1023,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
#split.one .h-fd,#split.one .ep .fd{display:none}
|
||||
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||||
.ep:hover .rowacts{opacity:1}
|
||||
/* Focus shows them as hover does: Tab reached Play and Delete while they were still invisible (#299). */
|
||||
.ep:hover .rowacts,.ep:focus-within .rowacts{opacity:1}
|
||||
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
||||
.dlbar.live{background:var(--raise)}
|
||||
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
|
||||
@@ -1067,6 +1082,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
place-items:center;z-index:50;padding:20px;
|
||||
}
|
||||
#modal.on{display:grid}
|
||||
/* Focus lands on the card itself as a dialog opens (#306): a place to start from, not a control. */
|
||||
#modalCard:focus{outline:none}
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
|
||||
@@ -1138,10 +1155,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.switch.lead{font-weight:600}
|
||||
.sw{position:relative;flex:none;width:42px;height:26px}
|
||||
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
||||
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--field);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
|
||||
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||
/* White on a light track did not show where the knob was, or so whether it was on (#308). */
|
||||
:root[data-mode="light"] .sw i::after{box-shadow:0 0 0 1px var(--field),0 1px 3px rgba(0,0,0,.35)}
|
||||
.sw input:checked+i::after{transform:translateX(16px)}
|
||||
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
|
||||
@@ -1196,7 +1215,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.flabel{font-size:14px;font-weight:500}
|
||||
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--field);border-radius:9px;background:var(--bg);cursor:text}
|
||||
.chips:focus-within{border-color:var(--accent)}
|
||||
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
|
||||
@@ -1209,7 +1228,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||
}
|
||||
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel);display:flex;gap:10px;align-items:flex-start}
|
||||
.toastx{flex:none;display:grid;place-items:center;width:24px;height:24px;margin:-3px -7px -3px 0;border-radius:6px;color:inherit}
|
||||
.toastx svg{width:11px;height:11px}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
@@ -1368,7 +1389,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
|
||||
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
|
||||
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
||||
font-size:17px;background:var(--panel2);border-color:var(--field);border-radius:10px;padding:9px 12px;
|
||||
}
|
||||
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 9px 14px}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
@@ -1436,7 +1457,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.dpends{font-size:14px}
|
||||
/* 16px or more, or Safari zooms the page in on it. */
|
||||
.wcap input[type=number]{font-size:16px;padding:3px 4px}
|
||||
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||
.chips{background:var(--panel2);border-color:var(--field);border-radius:10px}
|
||||
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||
.chipx{width:26px;height:26px}
|
||||
.field.boxed{border-radius:14px}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
|
||||
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…" aria-label="Search"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
|
||||
<div class="tgroup" id="admintools">
|
||||
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
|
||||
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
|
||||
@@ -36,7 +36,7 @@
|
||||
</header>
|
||||
<div id="shell">
|
||||
<aside id="sidebar">
|
||||
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
|
||||
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…" aria-label="Filter feeds"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
|
||||
<div id="feedlist"></div>
|
||||
<div class="sidefoot">
|
||||
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
<div id="seekrow">
|
||||
<span id="pcur">0:00</span>
|
||||
<input type="range" id="seek" min="0" max="1000" value="0">
|
||||
<input type="range" id="seek" min="0" max="1000" value="0" aria-label="Position">
|
||||
<span id="pdur">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +78,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal"><div class="card" id="modalCard"></div></div>
|
||||
<div id="toasts"></div>
|
||||
<div id="modal"><div class="card" id="modalCard" role="dialog" aria-modal="true" tabindex="-1"></div></div>
|
||||
<div id="toasts" role="status"></div>
|
||||
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
|
||||
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
|
||||
the current file is video -- see body.has-video below. -->
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
/* ---------------- modals ---------------- */
|
||||
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||
// A dialog is a dialog to a screen reader, named by its heading, and the page behind it is out of
|
||||
// reach until it closes: Tab used to walk out of it into the toolbar, and closing it left focus
|
||||
// nowhere (#306). Not a <dialog>: its top layer would cover the toasts, and an error's Dismiss
|
||||
// with them.
|
||||
let modalFrom: HTMLElement|null=null, benched: HTMLElement[]=[];
|
||||
function openModal(html: string, wide?: boolean, cls?: string){
|
||||
$('#modalCard').innerHTML=html;
|
||||
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
$('#modal').classList.add('on');
|
||||
const m=$('#modal'), card=$('#modalCard');
|
||||
card.innerHTML=html;
|
||||
card.className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
const h=card.querySelector('h2,h3');
|
||||
if(h){ h.id=h.id||'modalTitle'; card.setAttribute('aria-labelledby',h.id); } else card.removeAttribute('aria-labelledby');
|
||||
if(!m.classList.contains('on')){
|
||||
modalFrom=document.activeElement as HTMLElement|null;
|
||||
// Not what is inert already (#dpeek), which must stay so.
|
||||
benched=([...document.body.children] as HTMLElement[]).filter(el=>el!==m&&el.id!=='toasts'&&!el.inert);
|
||||
for(const el of benched) el.inert=true;
|
||||
m.classList.add('on');
|
||||
}
|
||||
// Into the dialog; a dialog that wants the cursor in a box puts it there after this.
|
||||
if(!card.contains(document.activeElement)) card.focus();
|
||||
}
|
||||
function closeModal(){
|
||||
$('#modal').classList.remove('on');
|
||||
const m=$('#modal');
|
||||
if(!m.classList.contains('on')) return;
|
||||
m.classList.remove('on');
|
||||
for(const el of benched) el.inert=false;
|
||||
benched=[];
|
||||
if(modalFrom?.isConnected) modalFrom.focus();
|
||||
modalFrom=null;
|
||||
}
|
||||
|
||||
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||
|
||||
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||
openModal(`<h3>Add a feed</h3>
|
||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<div class="field"><label for="nurl">Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||
<div class="field"><label for="nkw">Keywords (optional, comma separated)</label>
|
||||
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -60,6 +82,7 @@ function listedFeed(p: DirFeed,cls: string,rank?: number){
|
||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
asLink(el,false);
|
||||
// Yours has an id here as well as the directory's, and opens by it.
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
@@ -418,10 +441,10 @@ async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||
<div class="field"><label>Theme</label>
|
||||
<div class="field"><label for="stheme">Theme</label>
|
||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label for="smode">Light or dark</label>
|
||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||
@@ -461,7 +484,8 @@ async function prefsModal(){
|
||||
Anyone holding one is you here, so revoke one you no longer use.</span></div>
|
||||
<div class="field"><label>Feeds are checked every</label>
|
||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
|
||||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||
@@ -745,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
|
||||
function downloadLatestModal(f: Feed){
|
||||
openModal(`<h3>Download latest items</h3>
|
||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||
<div class="field"><label for="dcount">How many of the newest undownloaded items?</label>
|
||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -781,9 +805,9 @@ function opmlModal(){
|
||||
<div class="inline">
|
||||
<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 for="opmlFile">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">
|
||||
<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" aria-label="OPML to import" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||
$<HTMLButtonElement>('#oimp').onclick=async()=>{
|
||||
@@ -818,6 +842,7 @@ api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// ipx's version and the page's, each released on its own.
|
||||
// Also at the foot of Settings: the tooltip alone was out of reach of a keyboard and of a phone (#309).
|
||||
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
|
||||
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||||
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||||
|
||||
@@ -50,7 +50,9 @@ function connect(){
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
||||
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
||||
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
||||
}
|
||||
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||
else if(ev.ev==='signal_hits'){
|
||||
|
||||
@@ -105,6 +105,7 @@ function renderFeeds(){
|
||||
el.tabIndex=0; el.dataset.id=id;
|
||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||
places.appendChild(el);
|
||||
@@ -168,6 +169,7 @@ function renderFeeds(){
|
||||
`</small></div>`+
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
asLink(el,S.feed===f.id);
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||
|
||||
@@ -51,7 +51,7 @@ function refreshFeed(){
|
||||
document.body.append(spin);
|
||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||
.catch(e => toast(e.message, true));
|
||||
.catch(e => toast(why(e), true));
|
||||
loadEntries();
|
||||
// Up for two seconds at least, so it is seen even when the server answers at once.
|
||||
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
|
||||
|
||||
@@ -162,13 +162,15 @@ function epEl(e: Entry){
|
||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||
const name=entryName(e);
|
||||
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
|
||||
// the row alone was a click target only a mouse could reach (#298).
|
||||
el.innerHTML=`
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
|
||||
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
||||
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
<div class="body">
|
||||
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
|
||||
<div class="line">${[
|
||||
num&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
@@ -283,7 +285,7 @@ function setRead(e: Entry,read: boolean){
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e: Entry){
|
||||
if(e.read) return;
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(why(err),true); });
|
||||
}
|
||||
|
||||
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
||||
|
||||
@@ -165,7 +165,8 @@ function installNativePlayback(){
|
||||
case 'error':
|
||||
N.paused = true;
|
||||
fire('pause');
|
||||
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
|
||||
toast('Unable to play this file. Try again, or download it again.', true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
|
||||
return ready.then(()=>{
|
||||
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',true);
|
||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||
hls.loadSource(url); hls.attachMedia(audio);
|
||||
});
|
||||
@@ -48,10 +48,18 @@ function markPlayed(){
|
||||
setRead(e,true).catch(()=>{});
|
||||
}
|
||||
|
||||
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
|
||||
function playFailed(err: Error){
|
||||
// A newer load took its place: nothing failed.
|
||||
if(err.name==='AbortError') return;
|
||||
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
|
||||
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
|
||||
: 'Unable to play this file. Try again, or download it again.', true);
|
||||
}
|
||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
if(!enc||!isPlayable(enc)){
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet. Download it first, with its button on the item.', true);
|
||||
return;
|
||||
}
|
||||
// The same file carries on where it was; another of the item's files starts from its top. So
|
||||
@@ -76,8 +84,8 @@ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
player.marked=false;
|
||||
paintDead();
|
||||
}
|
||||
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
|
||||
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
||||
const go=()=>audio.play().catch(playFailed);
|
||||
if(wait) wait.then(go,playFailed); else go();
|
||||
renderEntries();
|
||||
}
|
||||
function mediaSession(e: Entry,f: Feed|undefined){
|
||||
@@ -95,6 +103,8 @@ audio.addEventListener('timeupdate',()=>{
|
||||
$('#pcur').textContent=clock(audio.currentTime);
|
||||
$('#pdur').textContent=clock(d);
|
||||
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
// Its value is thousandths, which a screen reader would read out as they are (#301).
|
||||
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
|
||||
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||
// that would write back whatever the list said, however old.
|
||||
if(!audio.paused) player.moved=true;
|
||||
|
||||
@@ -30,6 +30,7 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||
const el=document.createElement('div');
|
||||
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ open(); nav(false); };
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,18 @@ const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = docum
|
||||
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
/// A row that opens a feed, a place or a show (the feed list, the Directory): a link to a screen
|
||||
/// reader, named by what it shows, the open one marked current. Not a button, whose contents a
|
||||
/// screen reader flattens, which would hide a folder's triangle inside it; as a bare div it read as
|
||||
/// text, nothing to press (#298).
|
||||
function asLink(el: HTMLElement, current: boolean){
|
||||
const said=(s: string)=>el.querySelector(s)?.textContent?.trim();
|
||||
const marks=$$('[role=img][aria-label],.subbed[aria-label]',el).map(m=>m.getAttribute('aria-label'));
|
||||
el.setAttribute('role','link');
|
||||
el.setAttribute('aria-label',[said('b'),said('small'),...marks,el.querySelector<HTMLElement>('.badge:not(.zero)')?.title].filter(Boolean).join(', '));
|
||||
if(current) el.setAttribute('aria-current','page');
|
||||
}
|
||||
const ESCAPED: Record<string,string> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
|
||||
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||
@@ -116,11 +127,18 @@ window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.
|
||||
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
|
||||
|
||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||
// The browser's "Failed to fetch" said nothing anyone could act on (#304).
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts})
|
||||
.catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); });
|
||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||
if(!r.ok){
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219).
|
||||
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||
// whole 502 page (#219). A 500's words are the daemon's own workings, which its log already
|
||||
// has; they are not something to show the person who pressed the button (#304).
|
||||
const said=!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>'');
|
||||
throw new Error(r.status===500 ? 'iPX could not do that just now. Try again in a minute.'
|
||||
: said || 'Unable to reach iPX just now. Try again in a minute.');
|
||||
}
|
||||
return r.status===204?null:r.json().catch((): null=>null);
|
||||
}
|
||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||
@@ -175,10 +193,19 @@ async function share(title: string, url: string, btn?: HTMLElement){
|
||||
}
|
||||
|
||||
function toast(msg: string, bad?: boolean){
|
||||
const t=document.createElement('div');
|
||||
const t=document.createElement('div'), box=$('#toasts');
|
||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||
$('#toasts').appendChild(t);
|
||||
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
|
||||
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
|
||||
// be read again (#303).
|
||||
if(bad){
|
||||
const x=document.createElement('button');
|
||||
x.type='button'; x.className='toastx'; x.title='Dismiss'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=ICON.close;
|
||||
x.onclick=go; t.appendChild(x);
|
||||
}else setTimeout(go,3200);
|
||||
box.appendChild(t);
|
||||
// A run of failed downloads would otherwise stack errors up the screen.
|
||||
while(box.children.length>4) box.firstElementChild!.remove();
|
||||
}
|
||||
const clock = (s: number) => {
|
||||
s=Math.max(0,Math.floor(s||0));
|
||||
|
||||
Reference in New Issue
Block a user