From a34499ea953f1ac7fa1b8e528e51bb8df06fffed Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 10:46:48 +0530 Subject: [PATCH] Add shared grid and list controls to article feeds --- apps/extensions/README.md | 10 +- apps/extensions/src/entry.tsx | 20 +++ apps/extensions/src/routes.ts | 3 +- apps/extensions/tests/auth.browser.mjs | 12 ++ apps/extensions/tests/newtab.test.mjs | 1 + apps/extensions/tests/reader.browser.mjs | 14 +- apps/web/README.md | 15 ++ apps/web/src/app/article-share.css | 6 - apps/web/src/app/styles/feed.css | 123 ++++++++++++++ apps/web/src/app/styles/reader-layout.css | 65 +++++++ apps/web/src/app/styles/settings.css | 31 ---- apps/web/src/app/trending/page.tsx | 33 +--- apps/web/src/components/article-table.tsx | 1 + apps/web/src/components/feed-filters.tsx | 113 +++++++------ apps/web/src/components/feed-preferences.tsx | 45 ++++- apps/web/src/components/feed-view-toggle.tsx | 48 ++++++ apps/web/src/components/read-later.tsx | 2 + apps/web/src/components/trending-content.tsx | 48 ++++++ apps/web/tests/browser/feed.mjs | 16 ++ apps/web/tests/feed-preferences.test.tsx | 169 +++++++++++++++++++ scripts/testing/article-views.mjs | 112 ++++++++++++ scripts/testing/engagement-pagination.mjs | 8 + 22 files changed, 766 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/components/feed-view-toggle.tsx create mode 100644 apps/web/src/components/trending-content.tsx create mode 100644 scripts/testing/article-views.mjs diff --git a/apps/extensions/README.md b/apps/extensions/README.md index e667ccb96..b6b86cbad 100644 --- a/apps/extensions/README.md +++ b/apps/extensions/README.md @@ -4,7 +4,7 @@ A Chrome and Microsoft Edge Manifest V3 extension that renders DevFeed directly without redirecting to the website or embedding it in an iframe. The extension bundles the web reader's React components and styles. Feed, search, -article previews, personalized feed, read later, topics, sources, source suggestions, +article previews, personalized feed, read later, trending, topics, sources, source suggestions, and all six settings sections use local hash routes. Publisher links and website sign-in open a separate tab. Chrome's own new-tab footer is browser UI and is not part of the extension's layout. @@ -108,6 +108,14 @@ Topics and sources use the existing paginated public catalog endpoints. ## Reader parity +Grid/List icons beside Filters share the website's article layout control. Grid is +the default; List uses compact rows across My feed, Latest, topic/source feeds, +Trending and Read later. The last two expose the control in their headings. Guest +choices stay in the extension's browser storage; signed-in choices use account feed +settings without changing content or language selections. Loaded pages, bookmarks, +engagement data and preview navigation survive layout changes. `/trending` is a +local reader route in both extensions. + My feed uses `/`, the public latest feed uses `/latest`, and signed-out new tabs redirect to `/latest` without Read later navigation. Both extensions use the web reader's recommendation component and retain its current generation during likes, diff --git a/apps/extensions/src/entry.tsx b/apps/extensions/src/entry.tsx index 8bb0d0564..5e54b6c43 100644 --- a/apps/extensions/src/entry.tsx +++ b/apps/extensions/src/entry.tsx @@ -36,6 +36,7 @@ import { rememberArticles } from "./public-cache"; import { AccountSession, signedOut } from "./session"; import { PersonalFeed } from "../../web/src/components/personal-feed"; import { ReadLater } from "../../web/src/components/read-later"; +import { TrendingContent } from "../../web/src/components/trending-content"; import { NotificationPreferencesProvider } from "../../web/src/components/notification-preferences-provider"; import { SignupNudge } from "../../web/src/components/signup-nudge"; import { ReaderNavigationRecovery } from "../../web/src/components/reader-navigation-recovery"; @@ -76,6 +77,7 @@ function Reader({ const key = `${route}:${revision}:${sessionLoading ? "loading" : (user?.user_id ?? "guest")}:${user?.csrf_token ?? ""}`; const url = new URL(route, publicOrigin); const search = match.page === "search"; + const trending = match.page === "trending"; const personal = match.page === "personal"; const router = useRouter(); useEffect(() => { @@ -113,12 +115,23 @@ function Reader({ key: string; feed?: FeedContentProps; search?: SearchResponse | null; + trending?: PromiseSettledResult; }>(); useEffect(() => { if (sessionLoading || personal || bookmarks) return; const controller = new AbortController(); const { signal } = controller; async function load() { + if (trending) { + const [feed] = await Promise.allSettled([ + read( + `/api/v1/user/trending?limit=24&cursor=${encodeURIComponent(filters.cursor)}`, + signal, + ), + ]); + if (!signal.aborted) setState({ key, trending: feed }); + return; + } if (search) { const result = query ? await read(`/api/v1/search?${url.searchParams}`, signal).catch( @@ -232,6 +245,13 @@ function Reader({ ))} ); + if (trending) + return ( + + ); return state?.key === key && state.feed ? ( ) : ( diff --git a/apps/extensions/src/routes.ts b/apps/extensions/src/routes.ts index 7295c8c95..56aecfd3d 100644 --- a/apps/extensions/src/routes.ts +++ b/apps/extensions/src/routes.ts @@ -15,7 +15,7 @@ export type ExtensionRoute = | { type: "profile"; username: string } | { type: "reader"; - page: "personal" | "bookmarks" | "search" | "feed"; + page: "personal" | "bookmarks" | "search" | "feed" | "trending"; detail?: { kind: CatalogKind; slug: string; contentType?: string }; contentType?: string; }; @@ -40,6 +40,7 @@ export function extensionRoute(pathname: string): ExtensionRoute | null { if (pathname === "/latest") return { type: "reader", page: "feed" }; if (pathname === "/search") return { type: "reader", page: "search" }; if (pathname === "/read-later") return { type: "reader", page: "bookmarks" }; + if (pathname === "/trending") return { type: "reader", page: "trending" }; if (pathname === "/sources/suggest") return { type: "local", page: "source-suggestion" }; if (pathname === "/mcp/authorize") return { type: "local", page: "mcp-authorize" }; diff --git a/apps/extensions/tests/auth.browser.mjs b/apps/extensions/tests/auth.browser.mjs index aa8518432..a2d5680d8 100644 --- a/apps/extensions/tests/auth.browser.mjs +++ b/apps/extensions/tests/auth.browser.mjs @@ -1,3 +1,4 @@ +import { checkArticleViews } from "../../../scripts/testing/article-views.mjs"; import { dailyFixture, checkMustReads } from "../../../scripts/testing/must-reads.mjs"; import { checkAvatarUploads } from "../../../scripts/testing/avatar-uploads.mjs"; import { checkLeaderboard } from "../../../scripts/testing/leaderboard.mjs"; @@ -643,6 +644,12 @@ test( await page.locator(".article-card").first().waitFor(); assert.equal(page.url(), personalUrl, "generation recovery stays inside the extension"); assert.equal(rejectNextPage, false, "the stale cursor was rejected"); + await checkArticleViews( + page, + personalUrl, + path.resolve(extension, `../${browser}-personal-view`), + [personalUrl.replace(/#\/$/, "#/latest"), personalUrl], + ); const feedRequests = []; page.on("request", (request) => { if (new URL(request.url()).pathname === "/api/v1/user/feed") @@ -729,6 +736,11 @@ test( await page.getByRole("button", { name: "Save article for later", exact: true }).click(); await page.getByRole("button", { name: "Remove bookmark", exact: true }).waitFor(); await page.locator(".sidebar").getByRole("link", { name: "Read later", exact: true }).click(); + await checkArticleViews( + page, + page.url(), + path.resolve(extension, `../${browser}-saved-view`), + ); await checkPreviewBackground( page, path.resolve(extension, `../${browser}-bookmarks-preview.png`), diff --git a/apps/extensions/tests/newtab.test.mjs b/apps/extensions/tests/newtab.test.mjs index c7aedc182..0bb8a4736 100644 --- a/apps/extensions/tests/newtab.test.mjs +++ b/apps/extensions/tests/newtab.test.mjs @@ -33,6 +33,7 @@ const { extensionRoute } = await bundled("../src/routes.ts"); test("one route registry classifies every extension-owned page", () => { const route = (pathname) => JSON.stringify(extensionRoute(pathname)); + assert.equal(route("/trending"), JSON.stringify({ type: "reader", page: "trending" })); assert.equal(route("/mcp/authorize"), JSON.stringify({ type: "local", page: "mcp-authorize" })); assert.equal(route("/mcp"), JSON.stringify({ type: "local", page: "mcp" })); assert.equal(route("/leaderboard"), JSON.stringify({ type: "local", page: "leaderboard" })); diff --git a/apps/extensions/tests/reader.browser.mjs b/apps/extensions/tests/reader.browser.mjs index 1fb816e90..7dbc6bd42 100644 --- a/apps/extensions/tests/reader.browser.mjs +++ b/apps/extensions/tests/reader.browser.mjs @@ -1,4 +1,5 @@ import { checkArticleGrid } from "../../../scripts/testing/article-grid.mjs"; +import { checkArticleViews } from "../../../scripts/testing/article-views.mjs"; import { avatarFixtureVariants, avatarFixtureImage, @@ -162,7 +163,9 @@ test( if (url.pathname === "/api/v1/user/auth/login") return route.fulfill(await signInResponse(url.pathname.slice(4) + url.search)); let json; - if (url.pathname === "/api/v1/feed") { + if (url.pathname === "/api/v1/user/trending") { + json = { items: feedItems, next_cursor: null }; + } else if (url.pathname === "/api/v1/feed") { if ( url.searchParams.has("cursor") && url.searchParams.get("sort") === "most_liked" && @@ -253,6 +256,15 @@ test( await page.locator(".article-card").first().waitFor(); await checkManagedImages(page); await checkArticleGrid(page, path.join(extension, "../grid-" + browser)); + const base = page.url().split("#")[0]; + await checkArticleViews( + page, + `${base}#/latest`, + path.join(extension, `../${browser}-article-view`), + [`${base}#/topics/javascript`, `${base}#/sources/publisher`, `${base}#/trending`], + ); + await page.goto(`${base}#/latest`); + await page.locator(".article-card").first().waitFor(); assert.ok(page.url().startsWith("chrome-extension://")); await page.waitForURL(/#\/latest$/); const publicProfile = await context.newPage(); diff --git a/apps/web/README.md b/apps/web/README.md index 8e416db36..37ec0fe11 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -226,6 +226,21 @@ controls. `/mcp/authorize` is a private, non-indexed consent page that preserves the pending request through normal DevFeed sign-in. See `apps/mcp/README.md` for OAuth ingress routes and user-service/Redis configuration. +### Article views + +Use the Grid and List icons beside Filters to switch layouts without resetting the +loaded articles, order or pagination. The same choice applies to My feed, Latest, +topic/source feeds, Trending and Read later; feeds without filters show the control +in their heading. Grid is the default. Guests keep their choice in this browser, +and signed-in readers save it to the existing feed preference for their account. +Content and language selections stay unchanged. Failed saves restore the previous +layout with a retryable message. + +List displays compact rows with article titles, source, publication date and article +actions. Narrow lists keep source/date details beneath the title and a 44px bookmark +target, while omitting secondary engagement controls and topic links. Layout changes +retain loaded engagement data and the article preview's background list. + ### Responsive article cards The shared web and extension card grid chooses columns from the available feed width, diff --git a/apps/web/src/app/article-share.css b/apps/web/src/app/article-share.css index ae6d723f2..b55f97db2 100644 --- a/apps/web/src/app/article-share.css +++ b/apps/web/src/app/article-share.css @@ -131,16 +131,10 @@ .article-share-popover[data-state="closed"] { animation: reader-popover 120ms ease-in reverse; } -.article-table .activity-column { - width: 19%; -} @media (max-width: 700px) { .article-quick-actions { gap: 4px; } - .article-table .activity-column { - width: 29%; - } } @media (prefers-reduced-motion: reduce) { .article-share-popover { diff --git a/apps/web/src/app/styles/feed.css b/apps/web/src/app/styles/feed.css index 602e37394..5bc7f158d 100644 --- a/apps/web/src/app/styles/feed.css +++ b/apps/web/src/app/styles/feed.css @@ -3,6 +3,129 @@ grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 12px; } +.article-table-wrap { + container: article-list / inline-size; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--card); +} +.article-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + font-size: 12px; +} +.article-table .source-column { + width: 18%; +} +.article-table .date-column { + width: 105px; +} +.article-table .activity-column { + width: 160px; +} +.article-table th { + padding: 9px 12px; + text-align: left; + font-weight: 500; + color: var(--muted-foreground); + background: var(--muted); +} +.article-table td { + border-top: 1px solid var(--border); + padding: 6px 12px; + vertical-align: middle; + overflow-wrap: anywhere; +} +.article-table tbody tr:hover { + background: var(--accent); +} +.article-list-title { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-size: 13px; + line-height: 1.4; + font-weight: 600; + color: var(--foreground); +} +.article-list-title:hover { + color: var(--primary); +} +.article-list-meta { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; + align-items: center; + margin-top: 3px; + font-size: 11px; + color: var(--muted-foreground); +} +.article-list-meta a { + color: var(--primary); +} +.article-list-type { + text-transform: capitalize; +} +.article-list-mobile-source { + display: none; +} +.article-list-meta .recommendation-reason { + margin: 0; + font-size: 11px; +} +.article-table .article-quick-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 6px; +} +.article-table .article-engagement { + flex-wrap: nowrap; + gap: 6px; +} +.article-table .heart-button { + padding: 4px; +} +.article-table td.source-column > a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@container article-list (max-width: 700px) { + .article-table .source-column, + .article-table .date-column { + display: none; + } + .article-table .activity-column { + width: 60px; + } + .article-table th, + .article-table td { + padding: 6px 8px; + } + .article-table .article-engagement { + display: none; + } + .article-list-mobile-source { + display: block; + width: 100%; + } + .article-list-meta > a { + display: none; + } + .article-table .bookmark-button { + width: 44px; + height: 44px; + padding: 0; + justify-content: center; + } +} .article-card { container: article-card / inline-size; position: relative; diff --git a/apps/web/src/app/styles/reader-layout.css b/apps/web/src/app/styles/reader-layout.css index 223f9fa4d..3ff95a908 100644 --- a/apps/web/src/app/styles/reader-layout.css +++ b/apps/web/src/app/styles/reader-layout.css @@ -390,6 +390,71 @@ button { align-items: center; gap: 18px; justify-content: space-between; + flex-wrap: wrap; +} +.feed-toolbar-actions { + margin-left: auto; +} +.feed-toolbar-actions:has(.feed-view-toggle) { + flex-wrap: wrap; + flex-shrink: 1; + max-width: 100%; +} +.feed-display-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.feed-view-toggle { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--background); +} +.feed-view-toggle button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} +.feed-view-toggle button:hover { + background: var(--muted); + color: var(--foreground); +} +.feed-view-toggle button[aria-pressed="true"] { + color: var(--primary); + border-color: var(--input); + background: var(--card); + box-shadow: var(--shadow-xs); +} +.feed-view-toggle button:disabled { + cursor: default; + opacity: 0.55; +} +.feed-view-error { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 40; + width: min(260px, 70vw); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--card); + color: var(--destructive); + font-size: 12px; + box-shadow: var(--shadow-xs); } .feed-tabs { display: flex; diff --git a/apps/web/src/app/styles/settings.css b/apps/web/src/app/styles/settings.css index c0f5f8408..080f4e379 100644 --- a/apps/web/src/app/styles/settings.css +++ b/apps/web/src/app/styles/settings.css @@ -398,37 +398,6 @@ .settings-toggle input[type="checkbox"] { margin-top: 10px; } } -/* Shared feed display preference: compact rows retain the same article actions. */ -.feed-pages:has(.article-table) { gap: 0; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--card); } -.article-table { border-collapse: collapse; table-layout: fixed; width: 100%; font-size: 12px; } -.article-table .article-column { width: 55%; } -.article-table .source-column { width: 18%; } -.article-table .date-column { width: 13%; } -.article-table .activity-column { width: 14%; } -.article-table th { text-align: left; font-weight: 500; color: var(--muted-foreground); padding: 12px 16px; background: var(--muted); } -.article-table td { border-top: 1px solid var(--border); padding: 12px 16px; vertical-align: middle; overflow-wrap: anywhere; } -.article-table tbody tr:hover { background: var(--accent); } -.article-list-title { display: block; font-size: 14px; line-height: 1.45; font-weight: 600; color: var(--foreground); } -.article-list-title:hover { color: var(--primary); } -.article-list-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; margin-top: 5px; font-size: 11px; color: var(--muted-foreground); } -.article-list-meta a { color: var(--primary); } -.article-list-type { text-transform: capitalize; } -.article-list-mobile-source { display: none; } -.article-list-meta .recommendation-reason { margin: 0; font-size: 11px; } -.article-table .article-engagement { flex-wrap: wrap; gap: 6px; } -.article-table .heart-button { padding: 4px 6px; } -@media (max-width: 1100px) { - .article-table th, .article-table td { padding: 10px 12px; } - .article-table .article-column { width: 48%; } - .article-table .activity-column { width: 21%; } -} -@media (max-width: 640px) { - .article-table .source-column, .article-table .date-column { display: none; } - .article-table .article-column { width: 76%; } - .article-table .activity-column { width: 24%; } - .article-list-mobile-source { display: block; width: 100%; } - .article-list-title { font-size: 13px; } -} /* Feed layout settings use the shared settings form spacing and actions. */ .feed-layout-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; max-width: 740px; } .feed-layout-option { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; } diff --git a/apps/web/src/app/trending/page.tsx b/apps/web/src/app/trending/page.tsx index be5f93a2a..53d77f9a8 100644 --- a/apps/web/src/app/trending/page.tsx +++ b/apps/web/src/app/trending/page.tsx @@ -1,9 +1,6 @@ import type { Metadata } from "next"; -import { TrendingUp } from "lucide-react"; -import { UserShell } from "@/components/user-shell"; -import { InfiniteFeed } from "@/components/infinite-feed"; +import { TrendingContent } from "@/components/trending-content"; import type { SearchParams } from "@/lib/feed-query"; -import { RetryFeed } from "@/components/retry-feed"; import { getTrending } from "@/lib/api"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { @@ -16,31 +13,5 @@ export default async function Trending({ searchParams }: { searchParams: Promise const cursor = typeof query.cursor === "string" ? query.cursor : ""; const result = await Promise.allSettled([getTrending(cursor)]); const feed = result[0]; - return ( - -
-
-

Trending

- - - This week - -
-

Articles users are opening and liking.

-
- {feed.status === "rejected" ? ( -
-

Couldn’t load trending articles

- -
- ) : feed.value.items.length ? ( - - ) : ( -
-

No trending articles yet

-

Popular articles will appear as users open and like them.

-
- )} -
- ); + return ; } diff --git a/apps/web/src/components/article-table.tsx b/apps/web/src/components/article-table.tsx index ebd9d246e..fddad8c06 100644 --- a/apps/web/src/components/article-table.tsx +++ b/apps/web/src/components/article-table.tsx @@ -44,6 +44,7 @@ export function ArticleTable({ - {!sourcePage && ( - - - Filters - {filterCount > 0 && {filterCount}} - - } - > -
{ - event.preventDefault(); - const values = Object.fromEntries(new FormData(event.currentTarget)) as Record< - string, - string - >; - router.push( - hrefFor(parseFilters({ ...formFilters, ...values }), { - source_slug: sourcePage - ? filters.source_slug - : sources.find((source) => source.id === values.source_id)?.slug, - }), - ); - }} - > - {[...formUrl.searchParams].map(([name, value]) => ( - - ))} - {!sourcePage && ( +
+ {!sourcePage && ( + - - + ))} + {!sourcePage && ( + <> + +