diff --git a/web/src/components/community/CommunityFeedClient.test.tsx b/web/src/components/community/CommunityFeedClient.test.tsx index c7d6442ef..4cc30904d 100644 --- a/web/src/components/community/CommunityFeedClient.test.tsx +++ b/web/src/components/community/CommunityFeedClient.test.tsx @@ -232,3 +232,55 @@ describe("the wall's first paint", () => { expect(grid(container).style.getPropertyValue("--feed-slot")).toBe(`${MOBILE_FEED_VIEW.slot}px`); }); }); + +// "Liked" is the viewer's own list. Empty, it used to borrow the wall's panel +// and say nobody had hung anything, over a wall that had thirty patterns. +describe("an empty Liked tab", () => { + it("says nothing is liked yet and links back to the wall", () => { + search = new URLSearchParams("sort=liked"); + render(); + expect(screen.getByText("You haven't liked a pattern yet.")).toBeInTheDocument(); + expect(screen.getByText(/Press the heart on a pattern and it is kept here/)).toBeInTheDocument(); + expect(screen.queryByText("Nobody has hung anything here yet.")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Pattern Lab/ })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Browse the wall →" })).toHaveAttribute( + "href", + "/community/patterns", + ); + }); + + it("drops only the filter when none of the likes is flashable", () => { + search = new URLSearchParams("sort=liked&hw=1"); + render(); + expect(screen.getByText("None of the patterns you liked is flashable yet.")).toBeInTheDocument(); + expect(screen.queryByText("Nothing flashable yet.")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Every pattern you liked →" })).toHaveAttribute( + "href", + "/community/patterns?sort=liked", + ); + }); + + it("asks a signed-out visitor to sign in, and keeps the filter on the way back", () => { + search = new URLSearchParams("sort=liked&hw=1&page=2"); + render(); + expect(screen.getByText("Sign in to see the patterns you liked.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Browse the wall →" })).toHaveAttribute( + "href", + "/community/patterns?hw=1", + ); + }); + + it("says a search ran over the likes only", () => { + search = new URLSearchParams("sort=liked&q=zzz"); + render(); + expect(screen.getByText("Nothing matches “zzz”.")).toBeInTheDocument(); + expect(screen.getByText(/Only the patterns you liked were searched/)).toBeInTheDocument(); + }); + + it("leaves the empty wall's own panel alone", () => { + search = new URLSearchParams(); + render(); + expect(screen.getByText("Nobody has hung anything here yet.")).toBeInTheDocument(); + expect(screen.queryByText(/Only the patterns you liked/)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/community/CommunityFeedClient.tsx b/web/src/components/community/CommunityFeedClient.tsx index 2813b7ca6..cdd11b0c0 100644 --- a/web/src/components/community/CommunityFeedClient.tsx +++ b/web/src/components/community/CommunityFeedClient.tsx @@ -1,9 +1,10 @@ "use client"; import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import PatternCard, { PatternCardItem } from "./PatternCard"; -import FeedControls from "./FeedControls"; +import FeedControls, { feedHref } from "./FeedControls"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { FEED_SLOT_KEY, @@ -263,6 +264,8 @@ function FeedWall({ const sentinelRef = useRef(null); const isMobile = useIsMobile(); + const pathname = usePathname(); + const params = useSearchParams(); // The zoom. Starts at the default for a hydration-stable first paint, then // adopts whatever this browser chose last time — a frame later, so the @@ -519,12 +522,49 @@ function FeedWall({ Nothing matches “{searched}”. Titles and handles are searched as typed. Start with @ to search handles only. + {sort === "liked" && " Only the patterns you liked were searched."} {hardwareOnly && " Only flashable patterns were searched — drop the filter to search all of them."} + ) : total === 0 && sort === "liked" ? ( + // "Liked" lists the viewer's own hearts, not the wall. The wall's + // empty panel said nobody had hung anything over a wall of thirty + // patterns, and sent people to Pattern Lab for a list a heart fills. +
+ Patterns · liked + + {!signedIn + ? "Sign in to see the patterns you liked." + : hardwareOnly + ? "None of the patterns you liked is flashable yet." + : "You haven't liked a pattern yet."} + + + {signedIn && hardwareOnly + ? "Drop the filter to see every pattern you liked." + : "Press the heart on a pattern and it is kept here, on any device you sign in from."} + + {signedIn && hardwareOnly ? ( + + Every pattern you liked → + + ) : ( + + Browse the wall → + + )} +
) : total === 0 ? (
Patterns · empty diff --git a/web/src/components/community/FeedControls.tsx b/web/src/components/community/FeedControls.tsx index cd553be67..cbfac40ea 100644 --- a/web/src/components/community/FeedControls.tsx +++ b/web/src/components/community/FeedControls.tsx @@ -28,6 +28,30 @@ const SORTS = [ { id: "decks", label: "In decks" }, ] as const; +/** The wall's address with `patch` laid over its query, a null dropping the + * key. The tabs below and the wall's empty panels both link this way. */ +export function feedHref( + pathname: string, + params: { toString(): string }, + patch: Record, +): string { + const next = new URLSearchParams(params.toString()); + // Parameters from the paged feed: page and size from 22 July 2026 + // (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c). + // Infinite scroll retired all three on 2 August (c4a57f1). A bookmark + // saved in those days still carries them, so every link followed from it + // drops them. + next.delete("page"); + next.delete("size"); + next.delete("view"); + for (const [key, value] of Object.entries(patch)) { + if (value === null) next.delete(key); + else next.set(key, value); + } + const query = next.toString(); + return query ? `${pathname}?${query}` : pathname; +} + export default function FeedControls({ sort, hardwareOnly, @@ -46,23 +70,7 @@ export default function FeedControls({ const pathname = usePathname(); const params = useSearchParams(); - const hrefWith = (patch: Record) => { - const next = new URLSearchParams(params.toString()); - // Parameters from the paged feed: page and size from 22 July 2026 - // (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c). - // Infinite scroll retired all three on 2 August (c4a57f1). A bookmark - // saved in those days still carries them, so every link followed from it - // drops them. - next.delete("page"); - next.delete("size"); - next.delete("view"); - for (const [key, value] of Object.entries(patch)) { - if (value === null) next.delete(key); - else next.set(key, value); - } - const query = next.toString(); - return query ? `${pathname}?${query}` : pathname; - }; + const hrefWith = (patch: Record) => feedHref(pathname, params, patch); return (