The original iPodderX layout: toolbar, places, item table, Files pane
- A toolbar across the window with the original's groups: add and unsubscribe, play, mark read and keep for the selected item, scan, a search box for what is showing, and Settings and Log (admins only). - Directory, Popular and All Subscriptions sit at the top of the feed list and open in the main pane; the Popular and Directory buttons and their dialogs are gone. - All Subscriptions lists every item from every feed you subscribe to: GET /api/entries, the per-feed query with its scope widened. The enclosure lookup after it matches files to rows by feed and guid, since a page can now span feeds. - Items are a table (unread, kept, item, feed, file, published) with a Files pane beside it, the text below, and a status bar with totals. On a phone the files follow the text and the table is title and date. - Tests: enclosures are checked in #files; the toolbar's read, keep and play act on the selected item; All Subscriptions holds only your feeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
@@ -15,7 +15,7 @@ const script = html.split('<script>')[1].split('</script>')[0];
|
||||
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
||||
|
||||
const missing = [];
|
||||
const el = (name) => new Proxy({ style: {}, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||
@@ -49,7 +49,7 @@ const ctx = {
|
||||
: /\/api\/(popular|directory)/.test(String(url))
|
||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||
: []),
|
||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||
}),
|
||||
EventSource: function () { this.close = () => {}; },
|
||||
MediaMetadata: function () {},
|
||||
@@ -86,8 +86,9 @@ const drive = [
|
||||
['prefsModal', () => ctx.prefsModal()],
|
||||
['usersModal', () => ctx.usersModal()],
|
||||
['opmlModal', () => ctx.opmlModal()],
|
||||
['listedModal (popular)', () => ctx.listedModal('Popular', 'Top ten.', '/api/popular')],
|
||||
['listedModal (directory)', () => ctx.listedModal('Directory', 'A to Z.', '/api/directory')],
|
||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||
['logsModal', () => ctx.logsModal()],
|
||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||
// of a vm context the way var and function declarations do.
|
||||
|
||||
@@ -69,20 +69,21 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
||||
|
||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
||||
// The enclosure travels with the item, into the same pane.
|
||||
await expect(page.locator('#detail .encbox')).toHaveCount(1);
|
||||
// The enclosure goes to the Files pane beside the list, as the original's did.
|
||||
await expect(page.locator('#files')).toBeVisible();
|
||||
await expect(page.locator('#files .encbox')).toHaveCount(1);
|
||||
|
||||
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
|
||||
// its chip rather than assuming which episode the daemon happened to fetch.
|
||||
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
||||
await downloaded.click();
|
||||
await expect(page.locator('#detail audio')).toBeVisible();
|
||||
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
||||
await expect(page.locator('#files audio')).toBeVisible();
|
||||
await expect(page.locator('#files .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
||||
|
||||
// Selecting another item replaces the pane rather than stacking.
|
||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
||||
await expect(page.locator('#detail audio')).toHaveCount(0);
|
||||
await expect(page.locator('#files audio')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||
@@ -96,12 +97,12 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||
await row.click();
|
||||
|
||||
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
||||
await expect(page.locator('#detail audio')).toHaveCount(0);
|
||||
await expect(page.locator('#detail .encbox')).toContainText('image');
|
||||
await expect(page.locator('#detail .encbox')).toContainText('downloaded');
|
||||
await expect(page.locator('#files audio')).toHaveCount(0);
|
||||
await expect(page.locator('#files .encbox')).toContainText('image');
|
||||
await expect(page.locator('#files .encbox')).toContainText('downloaded');
|
||||
// Still offered as a file, just not as an episode: viewable and keepable.
|
||||
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
|
||||
const view = page.locator('#detail a', { hasText: 'View' });
|
||||
await expect(page.locator('#files .btn', { hasText: 'Save' })).toBeVisible();
|
||||
const view = page.locator('#files a', { hasText: 'View' });
|
||||
await expect(view).toHaveAttribute('target', '_blank');
|
||||
await expect(view).toHaveAttribute('rel', /noopener/);
|
||||
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
||||
@@ -115,9 +116,9 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
||||
await expect(row).toContainText('+1 more file');
|
||||
|
||||
await row.click();
|
||||
// The pane below lists every one: the audio and the image.
|
||||
await expect(page.locator('#detail .encbox')).toHaveCount(2);
|
||||
await expect(page.locator('#detail .encbox').nth(1)).toContainText('image');
|
||||
// The Files pane lists every one: the audio and the image.
|
||||
await expect(page.locator('#files .encbox')).toHaveCount(2);
|
||||
await expect(page.locator('#files .encbox').nth(1)).toContainText('image');
|
||||
});
|
||||
|
||||
test('the filter tabs change what is listed', async ({ page }) => {
|
||||
@@ -291,6 +292,30 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('the toolbar acts on the selected item', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
// Nothing selected, nothing to act on.
|
||||
await expect(page.locator('#tbRead')).toBeDisabled();
|
||||
|
||||
await row().click(); // opening it reads it
|
||||
await expect(row()).toHaveClass(/read/);
|
||||
await page.locator('#tbRead').click();
|
||||
await expect(row()).not.toHaveClass(/read/);
|
||||
|
||||
await page.locator('#tbFlag').click();
|
||||
await expect(row().locator('.fl')).toHaveClass(/on/);
|
||||
await page.locator('#tbFlag').click(); // and back, so later tests see it unkept
|
||||
await expect(row().locator('.fl')).not.toHaveClass(/on/);
|
||||
|
||||
// Second Episode is the one the daemon downloaded, so it plays from the toolbar.
|
||||
await expect(page.locator('#tbPlay')).toBeEnabled();
|
||||
await page.locator('#tbPlay').click();
|
||||
await expect(page.locator('#player')).toBeVisible();
|
||||
await page.locator('#pclose').click();
|
||||
});
|
||||
|
||||
test('a second person has their own feeds and their own read state', async ({ browser }) => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
@@ -350,7 +375,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await row.click();
|
||||
|
||||
const del = page.locator('#detail button', { hasText: 'Delete' });
|
||||
const del = page.locator('#files button', { hasText: 'Delete' });
|
||||
await expect(del).toHaveText('Delete for everyone');
|
||||
|
||||
// Two prompts: the page's own, then the server's, because someone else has not played
|
||||
@@ -566,7 +591,7 @@ 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('#popularFeeds').click();
|
||||
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 });
|
||||
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
|
||||
@@ -589,8 +614,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
||||
expect(dir.map(p => p.id)).not.toContain('paid-show');
|
||||
|
||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
||||
await piper.keyboard.press('Escape');
|
||||
await piper.locator('#directoryFeeds').click();
|
||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||
|
||||
@@ -603,7 +628,16 @@ 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('#popularFeeds').click();
|
||||
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed');
|
||||
|
||||
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
|
||||
// Blog is not among them.
|
||||
await piper.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||
const first = piper.locator('.ep', { hasText: 'First Episode' });
|
||||
await expect(first).toBeVisible({ timeout: 20_000 });
|
||||
await expect(first.locator('.fd')).toHaveText('Test Show');
|
||||
await expect(piper.locator('.ep', { hasText: 'An Article' })).toHaveCount(0);
|
||||
await expect(piper.locator('#count')).toContainText('All Subscriptions:');
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user