Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128)

The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs
tabs, a wall of about 30 category buttons and a sort menu, with a picked category's
subcategories appearing as a second row of buttons that looked like the first. It read as a
list to scroll, and the two rows were hard to tell apart.

It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with
three of its shows' covers fanned in its corner on the tint its initials would get, so the
shows colour it and no theme's palette changes. A category has a page of its own: its most
subscribed, its subcategories with how many each holds, and its grid. See all is every feed,
as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed,
had a place of its own in the feed list; it is the Directory's first section instead, and its
key, g p, is gone. /api/popular stays for scripts.

The search box finds a feed in the Directory by name (#126). It said "Search items…" there and
did nothing, since loadEntries returns early for a place that lists feeds.

A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to
that category's page), how many here subscribe, a subscribe button and its latest twenty items,
each with its title, linking to the post where it has one, its first lines, date and length.
Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id},
which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and
carries no file or its address. The feed's own description would belong at the top, but the
database does not keep one.

Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain
keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:19:38 +00:00
parent 0b4f5443b7
commit 95acde3046
13 changed files with 426 additions and 181 deletions

View File

@@ -49,7 +49,6 @@ const drive = PAGE === 'admin.html' ? [
['prefsModal', () => ctx.prefsModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['keysModal', () => ctx.keysModal()],

View File

@@ -242,7 +242,7 @@ test('an item with several enclosures lists them all', async ({ page }) => {
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
});
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
test('Currently Listening, its own place below the Directory, resumes an episode you started or forgets it', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
// and opening marks it read; it is listed all the same, because read is not finished. This
// test used to set it unread first, which hid exactly the bug in issue #14.
@@ -251,12 +251,12 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#popular')).toBeVisible();
// The Directory lists feeds and nothing else; the episodes have a place of their own under it.
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(page.locator('#directory')).toBeVisible();
await expect(page.locator('#listening')).toHaveCount(0);
const places = await page.locator('#feedlist .place b').allTextContents();
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Directory') + 1);
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
@@ -812,7 +812,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
});
test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
@@ -833,21 +833,23 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// The most subscribed are the Directory's first section; Popular, their own place, is gone (#124).
await expect(piper.locator('#feedlist .place', { hasText: 'Popular' })).toHaveCount(0);
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
const chart = piper.locator('#dchart .rank');
await expect(chart.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(chart.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(chart.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
const listed = await (await piper.request.get('/api/popular')).text();
const listed = await (await piper.request.get('/api/directory')).text();
expect(listed).not.toContain('secret123');
expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
// with an OPML's feeds in place of the OPML in both.
// The chart is /api/popular's top ten, and the directory is every listed feed A to Z, with an
// OPML's feeds in place of the OPML in both.
const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase());
@@ -855,41 +857,40 @@ test('Popular lists what everyone here reads, but never a private feed', async (
const ids = dir.map(p => p.id);
expect(ids).not.toContain('test-subscriptions');
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
expect(top.length).toBe(Math.min(10, dir.length));
expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
const tiles = piper.locator('#popular .tile');
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chart).toHaveCount(top.length);
await expect(chart.first().locator('.no')).toHaveText('1');
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
// Then the categories, as tiles, each a page of its own.
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
const tiles = piper.locator('#dtiles .tile');
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await cat('Technology').click();
await expect(piper.locator('#directory h2')).toHaveText('Technology');
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
// Its way back lands on the tile it went in by.
await piper.locator('#directory .dback').click();
await expect(cat('Technology')).toBeFocused();
// What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile.
const kind = name => piper.locator('#directory .tabs button', { hasText: new RegExp(`^${name}$`) });
await kind('Blogs').click();
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
await expect(cat('Technology')).toHaveCount(0);
await piper.locator('#directory .dall').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
await kind('All').click();
await expect(tiles).toHaveCount(dir.length);
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length);
// Two filters that combine: what a feed is, and what it is about.
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await pick('tabs', 'Blogs').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
// No empty chips: no blog here names Technology, so Blogs does not offer it.
await expect(pick('chips', 'Technology')).toHaveCount(0);
await pick('tabs', 'Podcasts').click();
await pick('chips', 'Technology').click();
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
// A second press lifts the chip and leaves the kind as it was.
await pick('chips', 'Technology').click();
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length);
// Sorted by name either way, A to Z as it opens, or by subscribers (#122).
const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
await piper.locator('#dirsort').selectOption('az');
await expect(tiles.first()).toContainText(byName[0]);
await piper.locator('#dirsort').selectOption('za');
await expect(tiles.first()).toContainText(byName[byName.length - 1]);
@@ -898,15 +899,35 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await expect(tiles).toHaveCount(dir.length);
await piper.locator('#dirsort').selectOption('az');
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
// The search box looks through the Directory by name (#126), and clearing it goes back.
await piper.locator('#epSearch').fill('test show');
await expect(piper.locator('#directory h2')).toHaveText('Results for “test show”');
await expect(tiles).toHaveCount(dir.filter(p => (p.title || p.id).toLowerCase().includes('test show')).length);
await piper.locator('#epSearch').fill('');
await expect(tiles).toHaveCount(dir.length);
// A feed you do not subscribe to opens its page here: what it is and its latest items (#128).
await tiles.filter({ hasText: 'Test Show' }).locator('.txt').click();
await expect(piper.locator('.show h2')).toHaveText('Test Show');
await expect(piper.locator('#dshow .lep', { hasText: 'First Episode' })).toBeVisible();
// Back, onto the tile it was opened from, the page under it as it was.
await piper.locator('#directory .dback').click();
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
await expect(tiles).toHaveCount(dir.length);
// Only a listed feed has one, and it carries no file or its address.
const preview = await (await piper.request.get('/api/directory/test-show')).text();
expect(preview).toContain('First Episode');
expect(preview).not.toContain('.mp3');
expect((await piper.request.get('/api/directory/paid-show')).status()).toBe(404);
// Add a feed is for an address; the Directory is where you browse (issue #30).
await piper.locator('#addFeed').click();
await expect(piper.locator('#nurl')).toBeVisible();
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length);
await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0);
await piper.locator('#modalCard button[title="Cancel"]').click();
const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
(await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show');
const before = await row();
expect(before.subscribed).toBe(false);
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
@@ -914,9 +935,9 @@ test('Popular lists what everyone here reads, but never a private feed', async (
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
await expect(chart.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
// Blog is not among them.
@@ -1216,14 +1237,14 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
await patch('picture-blog', 'Visual Arts');
// Apple's subcategory, under its category, as the Directory browses them (#118).
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
// A subcategory's chips show once its category is picked, as Apple's directory does.
const chip = (row, name) => page.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
// A category's subcategories are on its own page, as Apple's directory has them.
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chip('subs', 'Visual Arts')).toHaveCount(0);
await chip('chips', 'Arts').click();
await chip('subs', 'Visual Arts').click();
await expect(chip('subs', 'Visual Arts')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#popular .tile')).toHaveCount(1);
await expect(page.locator('#directory .pills')).toHaveCount(0);
await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click();
await pill('Visual Arts').click();
await expect(pill('Visual Arts')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
// A feed's own iTunes category wins over one given here.
await patch('test-show', 'Comedy');
expect((await listed('test-show')).category).toBe('Technology');