Design pass on the web UI: amber for new, EQ bars, keyboard feed list
Works through TODO.md from the 2026-09-11 review. Unread badges, dots and download bars take the icon's amber; the playing item is marked by EQ bars that move only while it plays. The feed list is usable from the keyboard, focus rings show everywhere, and folders get a mosaic of their shows' art with the triangle hung in the margin. Sentence-case labels, fewer bold weights, tinted initials tiles, shorter header lines, "Kept" everywhere, and the list gets the room the empty panes had. Reduced motion is honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -19,8 +19,33 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
|
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
|
||||||
every explicit item.
|
every explicit item.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Unread counts, unread dots and download progress are amber, the colour of the icon's EQ bars.
|
||||||
|
Blue is kept for the primary action and links, so a count no longer looks like a button.
|
||||||
|
- What is playing is marked by small EQ bars, in its row and in the player. They move only while it
|
||||||
|
plays.
|
||||||
|
- A folder in the sidebar shows its first four shows' art as a mosaic, and its shows sit under its
|
||||||
|
title. Only folders have a triangle, so every feed lines up with Directory and Popular above.
|
||||||
|
- Feeds without art get initials in a colour of their own, instead of all the same grey.
|
||||||
|
- The Flagged tab is called Kept, as the Keep button and Settings already said.
|
||||||
|
- A feed's header is one short line; when it checks next is in its tooltip.
|
||||||
|
- The item list takes more of the window, and the Files pane shows only when the item has files.
|
||||||
|
- Column headings and tags are in sentence case, and fewer things are bold.
|
||||||
|
- Unsubscribe is a round button beside the feed's other actions.
|
||||||
|
- Export and Import in Settings say what they do.
|
||||||
|
- The sign-in page shows the original icon large.
|
||||||
|
- Nothing animates when your system asks for reduced motion.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- The feed list works from the keyboard: Tab reaches every feed, Enter opens it, and Right and Left
|
||||||
|
open and close a folder. Every button shows where the focus is, including in the toolbar, which
|
||||||
|
used to clip the ring.
|
||||||
|
- "1 items" reads "1 item".
|
||||||
|
- A selected feed without art no longer loses its initials tile in Dark and Light.
|
||||||
|
- The player shows the feed's initials when there is no art, not the episode's.
|
||||||
|
|
||||||
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
|
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
|
||||||
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
|
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
|
||||||
skipped one stayed skipped whatever you changed.
|
skipped one stayed skipped whatever you changed.
|
||||||
|
|||||||
@@ -11,47 +11,47 @@ Dark and Light, the themes people actually use, read as a generic dark dashboard
|
|||||||
everywhere, one pale blue doing every job, the same radius on everything. The first three items below
|
everywhere, one pale blue doing every job, the same radius on everything. The first three items below
|
||||||
are the ones that would make Dark look like iPodderX; the rest are tidying.
|
are the ones that would make Dark look like iPodderX; the rest are tidying.
|
||||||
|
|
||||||
- [ ] **Amber means new.** The blue accent is on unread badges, unread dots, the primary Scan button,
|
- [x] **Amber means new.** The blue accent is on unread badges, unread dots, the primary Scan button,
|
||||||
selection, the playing outline, the slider thumb and links, so a count looks like a button and nothing
|
selection, the playing outline, the slider thumb and links, so a count looks like a button and nothing
|
||||||
stands out. Give the icon's EQ-bar amber (`--accent2`) to unread badges, unread dots and download
|
stands out. Give the icon's EQ-bar amber (`--accent2`) to unread badges, unread dots and download
|
||||||
progress; keep blue for selection and the one primary action. `.badge` (:169), `.ep .st` (:280),
|
progress; keep blue for selection and the one primary action. `.badge` (:169), `.ep .st` (:280),
|
||||||
`.dlbar i` (:308).
|
`.dlbar i` (:308).
|
||||||
- [ ] **EQ bars as the playing marker.** The strongest image in the brand. Small amber bars on the
|
- [x] **EQ bars as the playing marker.** The strongest image in the brand. Small amber bars on the
|
||||||
playing row and in the player bar, standing still under `prefers-reduced-motion`. This is the one
|
playing row and in the player bar, standing still under `prefers-reduced-motion`. This is the one
|
||||||
memorable detail; keep everything around it quiet.
|
memorable detail; keep everything around it quiet.
|
||||||
- [ ] **Focus you can see.** No `:focus-visible` styles anywhere, and `overflow:hidden` on `.tgroup` (:105)
|
- [x] **Focus you can see.** No `:focus-visible` styles anywhere, and `overflow:hidden` on `.tgroup` (:105)
|
||||||
and `#topbar` (:100) clips the focus ring of every toolbar button, so keyboard users cannot see where
|
and `#topbar` (:100) clips the focus ring of every toolbar button, so keyboard users cannot see where
|
||||||
they are. Add a `:focus-visible` ring and inset it, or drop the overflow clipping.
|
they are. Add a `:focus-visible` ring and inset it, or drop the overflow clipping.
|
||||||
- [ ] **Fewer bold weights.** Directory, Popular and All Subscriptions, every feed name and every unread
|
- [x] **Fewer bold weights.** Directory, Popular and All Subscriptions, every feed name and every unread
|
||||||
title are 600–700, so nothing leads. Keep bold for headings and unread titles, 500 elsewhere.
|
title are 600–700, so nothing leads. Keep bold for headings and unread titles, 500 elsewhere.
|
||||||
`font-variant-numeric: tabular-nums` on counts, sizes and durations. Keep `system-ui`: the page fetches
|
`font-variant-numeric: tabular-nums` on counts, sizes and durations. Keep `system-ui`: the page fetches
|
||||||
nothing from anyone else, on purpose.
|
nothing from anyone else, on purpose.
|
||||||
- [ ] **Sentence-case labels.** Drop `text-transform:uppercase` from `.ephead` (:262), `.fhd` (:195),
|
- [x] **Sentence-case labels.** Drop `text-transform:uppercase` from `.ephead` (:262), `.fhd` (:195),
|
||||||
`.chip` (:296) and `.tag` (:157). Classic already writes "Title, File, Size", and it reads better.
|
`.chip` (:296) and `.tag` (:157). Classic already writes "Title, File, Size", and it reads better.
|
||||||
- [ ] **The list gets the room.** With nothing selected, the reading pane takes about 60% of the height to
|
- [x] **The list gets the room.** With nothing selected, the reading pane takes about 60% of the height to
|
||||||
say "Pick an item to read it", and the Files pane keeps 270 px to say "No files". Raise the default
|
say "Pick an item to read it", and the Files pane keeps 270 px to say "No files". Raise the default
|
||||||
`--listh` from 40% to about 60% (:187), and hide `#files` (:192) when nothing is selected or the item
|
`--listh` from 40% to about 60% (:187), and hide `#files` (:192) when nothing is selected or the item
|
||||||
has no files.
|
has no files.
|
||||||
- [ ] **Shorter header lines.** "2 items · 1 downloaded · checked 1m ago · every 1 hour · next in 60m"
|
- [x] **Shorter header lines.** "2 items · 1 downloaded · checked 1m ago · every 1 hour · next in 60m"
|
||||||
cuts off at "ev…" on a phone, and "every 1 hour · next in 60m" says nothing right after a check. Show
|
cuts off at "ev…" on a phone, and "every 1 hour · next in 60m" says nothing right after a check. Show
|
||||||
"2 items, 1 downloaded · checked 1m ago"; move the schedule into the tooltip (:829, :914).
|
"2 items, 1 downloaded · checked 1m ago"; move the schedule into the tooltip (:829, :914).
|
||||||
- [ ] **One word per thing.**
|
- [x] **One word per thing.**
|
||||||
- "1 items" in the sidebar and folder rows (:794, :829, :943).
|
- "1 items" in the sidebar and folder rows (:794, :829, :943).
|
||||||
- "saved" in the sidebar (:793–794), "downloaded" in headers and on the Downloaded tab. Use
|
- "saved" in the sidebar (:793–794), "downloaded" in headers and on the Downloaded tab. Use
|
||||||
"downloaded".
|
"downloaded".
|
||||||
- One feature, three names: the Flagged tab, the "Keep the selected item" tooltip (:537), "Starred
|
- One feature, three names: the Flagged tab, the "Keep the selected item" tooltip (:537), "Starred
|
||||||
items are never touched" in Settings (:1629). Use "Keep", and call the tab "Kept".
|
items are never touched" in Settings (:1629). Use "Keep", and call the tab "Kept".
|
||||||
- [ ] **Unsubscribe with the other actions.** "Minus unsubscribes" is the rule from the icon pass and it
|
- [x] **Unsubscribe with the other actions.** "Minus unsubscribes" is the rule from the icon pass and it
|
||||||
holds, but in the feed header the minus sits bare at the far right, apart from the round buttons, and
|
holds, but in the feed header the minus sits bare at the far right, apart from the round buttons, and
|
||||||
reads as a window's minimise control. Give it the same round frame and put it with the others
|
reads as a window's minimise control. Give it the same round frame and put it with the others
|
||||||
(`.acts .btn.last`, :224).
|
(`.acts .btn.last`, :224).
|
||||||
- [ ] **Respect reduced motion.** The drawer slide, the folder chevron and the toast animation should stop
|
- [x] **Respect reduced motion.** The drawer slide, the folder chevron and the toast animation should stop
|
||||||
under `prefers-reduced-motion`.
|
under `prefers-reduced-motion`.
|
||||||
- [ ] **Art.** The player shows the episode title's initials ("SE") even when the feed has art: use the
|
- [x] **Art.** The player shows the episode title's initials ("SE") even when the feed has art: use the
|
||||||
feed's. Initials tiles for feeds without art are all the same grey, and two "TS" sit in a row in the
|
feed's. Initials tiles for feeds without art are all the same grey, and two "TS" sit in a row in the
|
||||||
fixtures: tint each from the palette by a hash of the title.
|
fixtures: tint each from the palette by a hash of the title.
|
||||||
- [ ] **Sign-in.** A plain centred card. It is the one place the original icon could be shown large.
|
- [x] **Sign-in.** A plain centred card. It is the one place the original icon could be shown large.
|
||||||
- [ ] **Settings export and import.** A floppy disk and a plus, which only make sense with the hint text
|
- [x] **Settings export and import.** A floppy disk and a plus, which only make sense with the hint text
|
||||||
under them. The one spot where the icon-only rule does not hold up; consider words there.
|
under them. The one spot where the icon-only rule does not hold up; consider words there.
|
||||||
|
|
||||||
### Folders in the sidebar
|
### Folders in the sidebar
|
||||||
@@ -60,25 +60,25 @@ From a closer look the same day: 2× crops of the sidebar in all three themes, w
|
|||||||
the browser. Closed by default, remembered across reloads (`ipx.expanded`) and opened by a search is
|
the browser. Closed by default, remembered across reloads (`ipx.expanded`) and opened by a search is
|
||||||
right, and so is the quarter turn when you click. How it is drawn is not.
|
right, and so is the quarter turn when you click. How it is drawn is not.
|
||||||
|
|
||||||
- [ ] **Keyboard.** The triangle is a `<span>` and a feed row a `<div>`, with no `tabindex` or
|
- [x] **Keyboard.** The triangle is a `<span>` and a feed row a `<div>`, with no `tabindex` or
|
||||||
`aria-expanded` anywhere: Tab goes from the feed filter straight to Sign out, so the feed list cannot be
|
`aria-expanded` anywhere: Tab goes from the feed filter straight to Sign out, so the feed list cannot be
|
||||||
used from the keyboard at all. Make the triangle a `<button aria-expanded>` and each row focusable with
|
used from the keyboard at all. Make the triangle a `<button aria-expanded>` and each row focusable with
|
||||||
a visible ring; Left and Right to close and open are a cheap extra. This one is a bug, the rest is
|
a visible ring; Left and Right to close and open are a cheap extra. This one is a bug, the rest is
|
||||||
polish. (`renderFeeds`, :786–800)
|
polish. (`renderFeeds`, :786–800)
|
||||||
- [ ] **Triangle in the margin, on folders only.** Every row keeps the 18 px `.chev` slot and a gap, so
|
- [x] **Triangle in the margin, on folders only.** Every row keeps the 18 px `.chev` slot and a gap, so
|
||||||
every feed's art starts at x=44 while the Directory, Popular and All Subscriptions icons start at x=16:
|
every feed's art starts at x=44 while the Directory, Popular and All Subscriptions icons start at x=16:
|
||||||
about 100 feeds pushed 28 px right for two triangles, in a 290 px sidebar. Hang the triangle in the left
|
about 100 feeds pushed 28 px right for two triangles, in a 290 px sidebar. Hang the triangle in the left
|
||||||
padding of folder rows only, so ordinary feeds line up with the places above them. (`.chev`, :141)
|
padding of folder rows only, so ordinary feeds line up with the places above them. (`.chev`, :141)
|
||||||
- [ ] **A target you can hit.** 18 × 18 px, under the 24 px minimum, with an 11 px glyph in `--faint`, in
|
- [x] **A target you can hit.** 18 × 18 px, under the 24 px minimum, with an 11 px glyph in `--faint`, in
|
||||||
a 48 px row where a near miss opens the folder's page instead. Keep the glyph small but give its button
|
a 48 px row where a near miss opens the folder's page instead. Keep the glyph small but give its button
|
||||||
the full height of the row.
|
the full height of the row.
|
||||||
- [ ] **Shows nest under the folder's title.** `.feed.child` steps in 16 px (:140), so a show's art lands
|
- [x] **Shows nest under the folder's title.** `.feed.child` steps in 16 px (:140), so a show's art lands
|
||||||
at x=60, between the folder's art (x=44) and its title (x=88). Indent to the title, with slightly
|
at x=60, between the folder's art (x=44) and its title (x=88). Indent to the title, with slightly
|
||||||
smaller art (about 28 px), so an open folder reads as a folder rather than a crooked list.
|
smaller art (about 28 px), so an open folder reads as a folder rather than a crooked list.
|
||||||
- [ ] **A folder that looks like one.** "TS" in a grey tile cannot be told from a feed. Draw a 2×2 mosaic
|
- [x] **A folder that looks like one.** "TS" in a grey tile cannot be told from a feed. Draw a 2×2 mosaic
|
||||||
of the first four feeds' art, the way iTunes built playlist artwork from album covers; Glass Cannon's
|
of the first four feeds' art, the way iTunes built playlist artwork from album covers; Glass Cannon's
|
||||||
shows all have art. The one bold touch the sidebar gets.
|
shows all have art. The one bold touch the sidebar gets.
|
||||||
- [ ] **The selected tile disappears.** In Dark and Light a selected feed without art loses its initials
|
- [x] **The selected tile disappears.** In Dark and Light a selected feed without art loses its initials
|
||||||
tile, because `.feed.sel` and `.art` are both `--raise`.
|
tile, because `.feed.sel` and `.art` are both `--raise`.
|
||||||
|
|
||||||
After any of these: rebuild, `node tests/page-smoke.js`, `npx playwright test`, and look at all three
|
After any of these: rebuild, `node tests/page-smoke.js`, `npx playwright test`, and look at all three
|
||||||
@@ -111,7 +111,7 @@ else a `401`.
|
|||||||
| `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/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, kept, 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` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ ipx reap # actually delete
|
|||||||
```
|
```
|
||||||
|
|
||||||
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
|
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
|
||||||
with no file are pruned from the database. **Starred by anyone keeps a file**, and one only counts
|
with no file are pruned from the database. **An item anyone kept keeps its file**, and one only counts
|
||||||
as read when everyone subscribed has read it. The enclosure row survives as `reaped`, which is what
|
as read when everyone subscribed has read it. The enclosure row survives as `reaped`, which is what
|
||||||
stops the next scan fetching it again.
|
stops the next scan fetching it again.
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ media_types = ["audio", "video"]
|
|||||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Starred items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
read it. `0` disables it entirely.
|
read it. `0` disables it entirely.
|
||||||
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
||||||
Starred ones stay. `0` disables it.
|
Kept ones stay. `0` disables it.
|
||||||
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
|
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
|
||||||
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
|
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
|
||||||
fetched 216 files and 22 GB in one scan.
|
fetched 216 files and 22 GB in one scan.
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
See [README.md](../README.md) for what the thing is.
|
||||||
|
|
||||||
|
## 2026-09-11 — A design pass on the web UI
|
||||||
|
|
||||||
|
A review against screenshots of every view in all three themes found that Dark and Light read as a
|
||||||
|
generic dark dashboard: one pale blue did every job, most labels were bold, and nothing led. The
|
||||||
|
list it produced, in `TODO.md`, was worked through in one go. What was worth knowing:
|
||||||
|
|
||||||
|
- **Amber means new.** Badges, unread dots and download bars take the icon's EQ amber; blue is left
|
||||||
|
for the primary action and links. Light's amber was `#b06f10`, which gives white text 4.1:1,
|
||||||
|
short of AA for 11 px bold. It is `#9a5f0a` now, 5.2:1.
|
||||||
|
- **EQ bars mark what is playing.** Three `<i>` bars stand at 60, 100 and 40 % and animate only
|
||||||
|
while `body.playing` is set. The first version left the animation on but paused, expecting each
|
||||||
|
bar to hold a different frame. The frames it held were within a pixel of each other, and on
|
||||||
|
screen the bars read as three dots. Under reduced motion one rule drops every animation and
|
||||||
|
transition, which leaves the bars standing.
|
||||||
|
- **The focus ring was clipped.** `.tgroup` and `#topbar` both set `overflow:hidden`, so a ring
|
||||||
|
drawn outside a toolbar button was cut off. Rings inside clipping parents are inset instead.
|
||||||
|
- **The feed list could not be used from the keyboard at all.** Rows were `<div>`s and the triangle
|
||||||
|
a `<span>`, so Tab went from the feed filter to Sign out. Rows now take focus, the triangle is a
|
||||||
|
`<button aria-expanded>`, and `renderFeeds` puts focus back on the same row after redrawing,
|
||||||
|
since every live update replaces every row. The list's own key handler stops Space and the
|
||||||
|
arrows from reaching the player's shortcuts on the document.
|
||||||
|
- **The triangle hangs in the margin.** Every row used to reserve an 18 px slot for it, pushing a
|
||||||
|
hundred feeds 28 px right of the places above for the sake of two folders. It is now absolutely
|
||||||
|
placed in the row's left padding, the full height of the row, so a near miss no longer opens
|
||||||
|
the folder's page.
|
||||||
|
- **A selected tile vanished** because the initials tile and the selected row were both `--raise`.
|
||||||
|
Tiles now mix their tint into `--bg`, which no row uses.
|
||||||
|
- **The Files pane hides itself** with `#split:has(>#files[hidden])`, which collapses its column.
|
||||||
|
The phone layout already hides the pane, and a zero-width extra track there is harmless.
|
||||||
|
- **Flagged became Kept** in the tab, and in the server's refusal to delete a file someone else
|
||||||
|
kept. The filter value and the column stay `flagged`; renaming those buys nothing.
|
||||||
|
|
||||||
## 2026-09-11 — A Patreon creator is a list of shows
|
## 2026-09-11 — A Patreon creator is a list of shows
|
||||||
|
|
||||||
Ray asked whether ipx could sync with Patreon. Not in full. The documented API (v2, the
|
Ray asked whether ipx could sync with Patreon. Not in full. The documented API (v2, the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ fetch, one parse and one file.
|
|||||||
|
|
||||||
| Yours alone | The same for everyone |
|
| Yours alone | The same for everyone |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Read, starred, playback position | The feed's URL |
|
| Read, kept, playback position | The feed's URL |
|
||||||
| Which feeds you see at all | Its download folder |
|
| Which feeds you see at all | Its download folder |
|
||||||
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
||||||
| | The file on disk |
|
| | The file on disk |
|
||||||
@@ -34,10 +34,10 @@ is shared.
|
|||||||
|
|
||||||
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
||||||
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
||||||
has starred the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
||||||
reason, and only `?force=true` goes through.
|
reason, and only `?force=true` goes through.
|
||||||
|
|
||||||
Retention follows the same rule: starred by anyone keeps a file, and it counts as read only once
|
Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
|
||||||
every subscriber has read it.
|
every subscriber has read it.
|
||||||
|
|
||||||
## Signing in
|
## Signing in
|
||||||
|
|||||||
@@ -1196,9 +1196,9 @@ async fn delete_file(
|
|||||||
let complaint = match (starred, unread) {
|
let complaint = match (starred, unread) {
|
||||||
(0, 0) => None,
|
(0, 0) => None,
|
||||||
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
|
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
|
||||||
(st, 0) => Some(format!("another {} starred it to keep", people(st))),
|
(st, 0) => Some(format!("another {} kept it", people(st))),
|
||||||
(st, u) => Some(format!(
|
(st, u) => Some(format!(
|
||||||
"another {} starred it to keep, and {} not played it yet",
|
"another {} kept it, and {} not played it yet",
|
||||||
people(st),
|
people(st),
|
||||||
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
|||||||
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
||||||
|
|
||||||
await page.locator('.tabs button', { hasText: 'Flagged' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
|
||||||
await expect(page.locator('#count')).toContainText('0 items');
|
await expect(page.locator('#count')).toContainText('0 items');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,9 +187,11 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
|||||||
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Every row reserves the chevron slot for alignment; only a folder's is clickable.
|
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
||||||
const chev = page.locator('.feed.group .chev');
|
const chev = page.locator('.feed.group .chev');
|
||||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
|
||||||
|
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
// Closed by default: the children are not listed until the folder is opened.
|
// Closed by default: the children are not listed until the folder is opened.
|
||||||
const before = await page.locator('.feed').count();
|
const before = await page.locator('.feed').count();
|
||||||
|
|||||||
207
web/index.html
207
web/index.html
@@ -37,7 +37,7 @@
|
|||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#b06f10;
|
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#b06f10;
|
||||||
@@ -75,6 +75,12 @@ body{
|
|||||||
}
|
}
|
||||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||||
a{color:var(--accent)}
|
a{color:var(--accent)}
|
||||||
|
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
|
||||||
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.tgroup button:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
*,*::before,*::after{animation:none!important;transition:none!important}
|
||||||
|
}
|
||||||
::-webkit-scrollbar{width:10px;height:10px}
|
::-webkit-scrollbar{width:10px;height:10px}
|
||||||
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
|
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
|
||||||
::-webkit-scrollbar-track{background:transparent}
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
@@ -133,18 +139,22 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
|
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
|
||||||
.feed{
|
.feed{
|
||||||
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
||||||
cursor:pointer;margin-bottom:1px;
|
cursor:pointer;margin-bottom:1px;position:relative;
|
||||||
}
|
}
|
||||||
.feed:hover{background:var(--panel2)}
|
.feed:hover{background:var(--panel2)}
|
||||||
.feed.sel{background:var(--raise)}
|
.feed.sel{background:var(--raise)}
|
||||||
.feed.child{margin-left:16px}
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||||
|
.feed.child{margin-left:44px}
|
||||||
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
|
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||||
|
above it. The button is the row's full height: a near miss used to open the folder's page. */
|
||||||
.chev{
|
.chev{
|
||||||
flex:none;width:18px;height:18px;display:grid;place-items:center;border-radius:4px;
|
position:absolute;left:-8px;top:0;bottom:0;width:16px;display:grid;place-items:center;
|
||||||
color:var(--faint);font-size:11px;transition:transform .12s;
|
border-radius:4px;color:var(--faint);
|
||||||
}
|
}
|
||||||
.chev:empty{pointer-events:none}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)}
|
.chev .i{width:12px;height:12px;transition:transform .12s}
|
||||||
.chev.open{transform:rotate(90deg)}
|
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
||||||
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
||||||
.childrow{
|
.childrow{
|
||||||
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
||||||
@@ -153,24 +163,32 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.childrow:hover{border-color:var(--accent)}
|
.childrow:hover{border-color:var(--accent)}
|
||||||
.childrow .art{width:32px;height:32px;font-size:12px}
|
.childrow .art{width:32px;height:32px;font-size:12px}
|
||||||
.childrow .txt{flex:1;min-width:0}
|
.childrow .txt{flex:1;min-width:0}
|
||||||
.childrow .txt b{display:block;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.tag{
|
.tag{
|
||||||
font-size:10px;text-transform:uppercase;letter-spacing:.04em;font-weight:700;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
||||||
}
|
}
|
||||||
.art{
|
.art{
|
||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
display:grid;place-items:center;color:var(--faint);font-weight:700;overflow:hidden;
|
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||||
}
|
}
|
||||||
|
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||||
|
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||||
|
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||||||
|
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
|
||||||
|
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
|
||||||
|
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
|
||||||
.feed .art{width:34px;height:34px;font-size:13px}
|
.feed .art{width:34px;height:34px;font-size:13px}
|
||||||
.feed .txt{min-width:0;flex:1}
|
.feed .txt{min-width:0;flex:1}
|
||||||
.feed .txt b{display:block;font-weight:600;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.badge{
|
.badge{
|
||||||
background:var(--accent);color:var(--ink);border-radius:20px;padding:1px 7px;
|
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
|
||||||
font-size:11px;font-weight:700;flex:none;min-width:26px;text-align:center;
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
}
|
}
|
||||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
|
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
/* ---------- main ---------- */
|
/* ---------- main ---------- */
|
||||||
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
||||||
@@ -184,15 +202,17 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
#split{
|
#split{
|
||||||
display:grid;flex:1;min-height:0;
|
display:grid;flex:1;min-height:0;
|
||||||
grid-template-columns:minmax(0,1fr) 270px;
|
grid-template-columns:minmax(0,1fr) 270px;
|
||||||
grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;
|
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||||
grid-template-areas:"list files" "grab grab" "detail detail";
|
grid-template-areas:"list files" "grab grab" "detail detail";
|
||||||
}
|
}
|
||||||
#list{grid-area:list;overflow:auto;padding:0 14px 10px}
|
#list{grid-area:list;overflow:auto;padding:0 14px 10px}
|
||||||
/* The selected item's files, beside the list, as the original's Files pane was. */
|
/* The selected item's files, beside the list, as the original's Files pane was. */
|
||||||
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
||||||
|
/* Nothing selected, or an item with no files: the list takes the width. */
|
||||||
|
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
|
||||||
#files .encbox{margin-top:8px}
|
#files .encbox{margin-top:8px}
|
||||||
#files .empty{padding:24px 0}
|
#files .empty{padding:24px 0}
|
||||||
.fhd{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
|
.fhd{font-size:12px;color:var(--faint)}
|
||||||
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
||||||
#grab:hover{background:var(--accent)}
|
#grab:hover{background:var(--accent)}
|
||||||
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
||||||
@@ -219,10 +239,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
||||||
.acts .btn.primary i{opacity:.9}
|
.acts .btn.primary i{opacity:.9}
|
||||||
.acts .btn:hover{background:var(--raise)}
|
.acts .btn:hover{background:var(--raise)}
|
||||||
.acts .btn.primary:hover{background:var(--accent2);border-color:var(--accent2)}
|
.acts .btn.primary:hover{background:var(--accent)}
|
||||||
/* Leaving a feed is not one of the everyday actions: keep it off on its own. */
|
|
||||||
.acts .btn.last{margin-left:auto;border-color:transparent;background:none;color:var(--faint)}
|
|
||||||
.acts .btn.last:hover{background:none;border-color:var(--bad);color:var(--bad)}
|
|
||||||
.btn{
|
.btn{
|
||||||
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||||
padding:6px 12px;font-size:13px;
|
padding:6px 12px;font-size:13px;
|
||||||
@@ -250,7 +267,7 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
}
|
}
|
||||||
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
|
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
|
||||||
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
|
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
|
||||||
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600}
|
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
|
||||||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||||
|
|
||||||
/* ---------- items ---------- */
|
/* ---------- items ---------- */
|
||||||
@@ -262,7 +279,7 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
.ephead{
|
.ephead{
|
||||||
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
|
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
|
||||||
border-bottom:1px solid var(--line);margin-bottom:3px;
|
border-bottom:1px solid var(--line);margin-bottom:3px;
|
||||||
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
|
font-size:12px;color:var(--faint);
|
||||||
}
|
}
|
||||||
/* Each heading sorts by its column; the caret says which way. */
|
/* Each heading sorts by its column; the caret says which way. */
|
||||||
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
||||||
@@ -276,10 +293,21 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
.ep>*{min-width:0}
|
.ep>*{min-width:0}
|
||||||
.ep.sel{background:var(--raise);border-color:var(--line)}
|
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||||
.ep:hover{background:var(--panel)}
|
.ep:hover{background:var(--panel)}
|
||||||
.ep.playing{border-color:var(--accent)}
|
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
|
||||||
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent)}
|
primary action and links, so a count no longer looks like a button. */
|
||||||
|
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
|
||||||
.ep .fl{color:var(--faint);font-size:13px}
|
.ep .fl{color:var(--faint);font-size:13px}
|
||||||
.ep .fl.on{color:var(--accent2)}
|
.ep .fl.on{color:var(--fg)}
|
||||||
|
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
||||||
|
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||||||
|
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||||||
|
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
|
||||||
|
.eq i:nth-child(1){height:60%}
|
||||||
|
.eq i:nth-child(3){height:40%}
|
||||||
|
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
|
||||||
|
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 .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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.ep.read .t{color:var(--dim);font-weight:500}
|
.ep.read .t{color:var(--dim);font-weight:500}
|
||||||
@@ -293,19 +321,11 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
|
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
|
||||||
#split.one .h-fd,#split.one .ep .fd{display:none}
|
#split.one .h-fd,#split.one .ep .fd{display:none}
|
||||||
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||||
.chip{
|
|
||||||
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
|
|
||||||
padding:1px 6px;border-radius:5px;background:var(--raise);color:var(--dim);
|
|
||||||
}
|
|
||||||
.chip.done{background:color-mix(in srgb,var(--good) 16%,transparent);color:var(--good)}
|
|
||||||
.chip.error{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad)}
|
|
||||||
.chip.pending{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
|
|
||||||
.chip.new{background:var(--accent);color:var(--ink)}
|
|
||||||
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||||||
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
|
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
|
||||||
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
||||||
.dlbar.live{background:var(--raise)}
|
.dlbar.live{background:var(--raise)}
|
||||||
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
|
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
|
||||||
.empty{color:var(--faint);text-align:center;padding:50px 0}
|
.empty{color:var(--faint);text-align:center;padding:50px 0}
|
||||||
#more{display:block;width:100%;margin-top:10px}
|
#more{display:block;width:100%;margin-top:10px}
|
||||||
|
|
||||||
@@ -320,6 +340,7 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
#pnow .art{width:44px;height:44px;font-size:13px}
|
#pnow .art{width:44px;height:44px;font-size:13px}
|
||||||
#pnow .txt{min-width:0}
|
#pnow .txt{min-width:0}
|
||||||
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#pnow .eq{color:var(--accent2);margin-right:6px}
|
||||||
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
|
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
|
||||||
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
|
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||||
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
|
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
|
||||||
@@ -400,14 +421,13 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
|
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
|
||||||
.fhead.slim .acts{margin-top:0;flex:none}
|
.fhead.slim .acts{margin-top:0;flex:none}
|
||||||
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
|
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
|
||||||
.fhead.slim .acts .btn.last{margin-left:0}
|
|
||||||
/* Places above the feeds: not subscriptions, but where to look. */
|
/* Places above the feeds: not subscriptions, but where to look. */
|
||||||
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
|
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
|
||||||
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
|
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
|
||||||
.place:hover{background:var(--panel2)}
|
.place:hover{background:var(--panel2)}
|
||||||
.place.sel{background:var(--raise)}
|
.place.sel{background:var(--raise)}
|
||||||
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
|
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
|
||||||
.place b{flex:1;font-weight:600;font-size:13.5px}
|
.place b{flex:1;font-weight:500;font-size:13.5px}
|
||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#shell{grid-template-columns:1fr}
|
||||||
@@ -495,7 +515,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||||
:root[data-theme="classic"] .ephead,
|
:root[data-theme="classic"] .ephead,
|
||||||
:root[data-theme="classic"] .fhd{
|
:root[data-theme="classic"] .fhd{
|
||||||
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;text-transform:none;letter-spacing:0;font-size:11px;
|
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
|
||||||
}
|
}
|
||||||
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
|
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
|
||||||
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
|
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
|
||||||
@@ -565,7 +585,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
<div id="player">
|
<div id="player">
|
||||||
<div id="pnow">
|
<div id="pnow">
|
||||||
<div id="partwrap"></div>
|
<div id="partwrap"></div>
|
||||||
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
|
<div class="txt"><b><span class="eq" aria-hidden="true"><i></i><i></i><i></i></span><span id="ptitle"></span></b><small id="pfeed"></small></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pmid">
|
<div id="pmid">
|
||||||
<div id="pbtns">
|
<div id="pbtns">
|
||||||
@@ -642,6 +662,8 @@ const ICON={
|
|||||||
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
|
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
|
||||||
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
};
|
};
|
||||||
|
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||||||
|
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||||
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
// 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.
|
// 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.
|
||||||
@@ -707,10 +729,25 @@ const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'shor
|
|||||||
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||||
|
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||||||
|
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||||||
|
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||||||
|
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||||
|
function tileHTML(name,cls){
|
||||||
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
|
}
|
||||||
function artHTML(url,name,cls){
|
function artHTML(url,name,cls){
|
||||||
return url
|
return url
|
||||||
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'art ${cls||''}',textContent:'${esc(initials(name))}'}))">`
|
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
|
: tileHTML(name,cls);
|
||||||
|
}
|
||||||
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
|
function folderArt(f,kids){
|
||||||
|
const art=kids.filter(c=>c.image).slice(0,4);
|
||||||
|
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||||
|
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||||
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
|
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||||
@@ -747,13 +784,23 @@ const VIEWS={
|
|||||||
};
|
};
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
|
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||||
|
const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]');
|
||||||
|
const back=was&&[was.dataset.id,a.classList.contains('chev')];
|
||||||
|
const done=()=>{
|
||||||
|
list.scrollTop=top;
|
||||||
|
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
|
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||||
|
};
|
||||||
|
list.innerHTML='';
|
||||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
const places=document.createElement('div');
|
const places=document.createElement('div');
|
||||||
places.className='places';
|
places.className='places';
|
||||||
for(const [id,v] of Object.entries(VIEWS)){
|
for(const [id,v] of Object.entries(VIEWS)){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='place'+(S.feed===id?' sel':'');
|
el.className='place'+(S.feed===id?' sel':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=id;
|
||||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
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>`:'');
|
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||||
@@ -761,7 +808,7 @@ function renderFeeds(){
|
|||||||
}
|
}
|
||||||
list.appendChild(places);
|
list.appendChild(places);
|
||||||
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||||
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return; }
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||||
|
|
||||||
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
|
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
|
||||||
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
@@ -785,21 +832,21 @@ function renderFeeds(){
|
|||||||
: [f.unread,f.entries,f.downloaded];
|
: [f.unread,f.entries,f.downloaded];
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
||||||
const open = kids && (expanded.has(f.id) || q);
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
|
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?ICON.caret:''}</span>`+
|
(kids?`<button class="chev" aria-expanded="${open}" title="Show or hide the feeds inside" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
||||||
artHTML(f.image,f.title||f.id)+
|
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
||||||
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
||||||
(mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
|
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||||
:`${eps} items · ${saved} saved`)+
|
|
||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">gone</span>':'')+
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
list.scrollTop=top;
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||||
@@ -826,8 +873,8 @@ function renderFeed(){
|
|||||||
${artHTML(f.image,name)}
|
${artHTML(f.image,name)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(name)}</h2>
|
<h2>${esc(name)}</h2>
|
||||||
<div class="sub stat">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||||
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</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>`:''}
|
||||||
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
@@ -839,7 +886,7 @@ function renderFeed(){
|
|||||||
<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.minus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>` : `
|
</div>` : `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
@@ -856,8 +903,8 @@ function renderFeed(){
|
|||||||
</div>`) + `
|
</div>`) + `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
${['all','unread','downloaded','flagged'].map(t=>
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=>
|
||||||
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -868,7 +915,7 @@ function renderFeed(){
|
|||||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||||
box.appendChild(pane);
|
box.appendChild(pane);
|
||||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
dragSplit(pane);
|
dragSplit(pane);
|
||||||
showDetail(null);
|
showDetail(null);
|
||||||
|
|
||||||
@@ -907,12 +954,11 @@ function renderGroup(f,kids){
|
|||||||
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||||
$('#content').innerHTML = `
|
$('#content').innerHTML = `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
${artHTML(f.image,f.title||f.id)}
|
${folderArt(f,kids)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(f.title||f.id)}</h2>
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
<div class="sub stat">${isPatreon(f)?'Patreon creator':'OPML subscription'} · ${kids.length} feed${kids.length===1?'':'s'}
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||||
· ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)}
|
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</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 but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
@@ -921,7 +967,7 @@ function renderGroup(f,kids){
|
|||||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="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.minus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -940,9 +986,9 @@ function renderGroup(f,kids){
|
|||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||||
`<small class="meta">${c.entries} items · ${c.downloaded} downloaded`+
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||||
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||||
(c.orphaned?'<span class="tag">gone</span>':'')+
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||||
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||||
el.onclick=()=>selectFeed(c.id);
|
el.onclick=()=>selectFeed(c.id);
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
@@ -1016,16 +1062,15 @@ function epEl(e){
|
|||||||
const playable=isPlayable(enc);
|
const playable=isPlayable(enc);
|
||||||
const others=e.enclosures.length-1;
|
const others=e.enclosures.length-1;
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
|
||||||
(player.guid===e.guid?' playing':'');
|
|
||||||
el.dataset.guid=e.guid;
|
el.dataset.guid=e.guid;
|
||||||
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('');
|
||||||
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?ICON.play:(e.read?'':'●')}</button>
|
player.guid===e.guid?EQ:(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, so it is never deleted'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="t">${esc(e.title||'(untitled)')}</span>
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
||||||
<div class="line">${[
|
<div class="line">${[
|
||||||
@@ -1160,6 +1205,8 @@ function syncTools(e){
|
|||||||
/// The item's text in the pane below the list, and its files in the pane beside it.
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||||
function showDetail(e){
|
function showDetail(e){
|
||||||
const box=$('#detail'), files=$('#files'); if(!box) return;
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
document.body.classList.toggle('reading',!!e);
|
document.body.classList.toggle('reading',!!e);
|
||||||
syncTools(e);
|
syncTools(e);
|
||||||
if(!e){
|
if(!e){
|
||||||
@@ -1307,7 +1354,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
audio.src=`/media/${enc.id}`;
|
audio.src=`/media/${enc.id}`;
|
||||||
audio.currentTime=0;
|
audio.currentTime=0;
|
||||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title);
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||||||
$('#ptitle').textContent=e.title||'(untitled)';
|
$('#ptitle').textContent=e.title||'(untitled)';
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
$('#pfeed').textContent=f?(f.title||f.id):'';
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
@@ -1347,8 +1395,9 @@ function savePos(){
|
|||||||
}
|
}
|
||||||
audio.addEventListener('pause',savePos);
|
audio.addEventListener('pause',savePos);
|
||||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||||
audio.addEventListener('play',()=>$('#pplay').innerHTML=ICON.pause);
|
// body.playing is what sets the EQ bars moving.
|
||||||
audio.addEventListener('pause',()=>$('#pplay').innerHTML=ICON.play);
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
|
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||||
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;
|
||||||
@@ -1626,7 +1675,7 @@ async function prefsModal(){
|
|||||||
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
||||||
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
||||||
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
||||||
<span class="hint">Over this, the oldest played items are deleted first. Starred
|
<span class="hint">Over this, the oldest played items are deleted first. Kept
|
||||||
items are never touched.</span></div>
|
items are never touched.</span></div>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
@@ -1634,8 +1683,9 @@ 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 ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
<button class="btn ico" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus}</button>
|
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
||||||
|
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
subscribes you to every feed in one.</span></div>
|
subscribes you to every feed in one.</span></div>
|
||||||
@@ -1666,7 +1716,7 @@ async function usersModal(){
|
|||||||
openModal(`<h3>Users</h3>
|
openModal(`<h3>Users</h3>
|
||||||
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
||||||
<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 ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</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>
|
||||||
@@ -1840,6 +1890,17 @@ api('/api/me').then(u=>{
|
|||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
on('#logs','onclick',logsModal);
|
on('#logs','onclick',logsModal);
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
|
$('#feedlist').onkeydown=ev=>{
|
||||||
|
const row=ev.target.closest('[data-id]'); if(!row) return;
|
||||||
|
const id=row.dataset.id;
|
||||||
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
|
else if(row.classList.contains('group')&&
|
||||||
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
|
else return;
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
};
|
||||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
$('#scrim').onclick=()=>nav(false);
|
$('#scrim').onclick=()=>nav(false);
|
||||||
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ form{
|
|||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px}
|
/* The one place the 2004 icon is shown at the size it was drawn for. */
|
||||||
.brand img{width:34px;height:34px;object-fit:contain}
|
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
||||||
h1{font-size:17px;margin:0;font-weight:650}
|
.brand img{width:96px;height:auto}
|
||||||
|
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
||||||
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
||||||
input{
|
input{
|
||||||
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);
|
||||||
@@ -60,6 +61,7 @@ button{
|
|||||||
background:var(--accent);border:0;border-radius:8px;padding:10px;
|
background:var(--accent);border:0;border-radius:8px;padding:10px;
|
||||||
}
|
}
|
||||||
.msg{color:var(--bad);font-size:13px;min-height:19px;margin:10px 0 0;text-align:center}
|
.msg{color:var(--bad);font-size:13px;min-height:19px;margin:10px 0 0;text-align:center}
|
||||||
|
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
|
|||||||
Reference in New Issue
Block a user