diff --git a/apps/extensions/README.md b/apps/extensions/README.md index a6d1fa91b..a737859a0 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. @@ -109,6 +109,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 075cac73b..4e14ad7e9 100644 --- a/apps/extensions/src/entry.tsx +++ b/apps/extensions/src/entry.tsx @@ -31,6 +31,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 { ReaderPromptsProvider } from "../../web/src/components/reader-prompts"; @@ -72,6 +73,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(() => { @@ -109,12 +111,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( @@ -220,6 +233,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 f242c7a91..4de06d256 100644 --- a/apps/extensions/src/routes.ts +++ b/apps/extensions/src/routes.ts @@ -16,7 +16,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; }; @@ -41,6 +41,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 dcba95e5b..1be09414e 100644 --- a/apps/extensions/tests/auth.browser.mjs +++ b/apps/extensions/tests/auth.browser.mjs @@ -1,3 +1,4 @@ +import { checkArticleViews } from "../../web/tests/browser/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"; @@ -661,6 +662,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") @@ -747,6 +754,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 73f68ecc2..1ef857ac9 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 dfad7a8ff..d9aeff863 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 "../../web/tests/browser/article-views.mjs"; import { avatarFixtureVariants, avatarFixtureImage, @@ -10,7 +11,7 @@ import { engagementFeed, engagementRows, checkEngagementPagination, -} from "../../../scripts/testing/engagement-pagination.mjs"; +} from "../../web/tests/browser/engagement-pagination.mjs"; import { checkPreviewBackground } from "../../../scripts/testing/preview-background.mjs"; import { blockedFeed, checkArticleFirst } from "../../web/tests/browser/article-first.mjs"; import { @@ -169,7 +170,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" && @@ -262,6 +265,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$/); for (const status of [404, 503]) { diff --git a/apps/web/README.md b/apps/web/README.md index d0049b82b..201f36597 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -252,6 +252,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 && ( + <> + +