diff --git a/web/src/components/community/Community.module.css b/web/src/components/community/Community.module.css index 33d24c313..c8d3b66b0 100644 --- a/web/src/components/community/Community.module.css +++ b/web/src/components/community/Community.module.css @@ -2191,6 +2191,14 @@ color: var(--pfc-led); } +/* Why a like did not stick, beside the button that was pressed. The title row + wraps, so a long one drops under the title instead of squeezing it. */ +.likeNote { + font-family: var(--pf-mono, monospace); + font-size: 12px; + color: var(--pfc-danger); +} + /* ── Firmware ports ── Somebody else's board finishing the author's pattern. A quiet ledger, not a leaderboard: handle, date, state — and the author's Pin as the only verb. */ diff --git a/web/src/components/community/LikeButton.tsx b/web/src/components/community/LikeButton.tsx index d6c6eb645..75625c605 100644 --- a/web/src/components/community/LikeButton.tsx +++ b/web/src/components/community/LikeButton.tsx @@ -3,13 +3,18 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import AuthModal from "./AuthModal"; import styles from "./Community.module.css"; // Like toggle with optimistic UI. Counts are visible to everyone; only the act // of liking needs a session, so signed-out visitors get the sign-in modal at // the moment they click — the same rule as publishing and commenting. +// +// A like the server turns down rolls back, and the button says why beside it. +// Before, the flip came undone inside 150 ms with nothing on the page, so a +// 429 ("Too many likes — slow down a moment.") read as a button that ignored +// the press. export default function LikeButton({ patternId, @@ -26,6 +31,7 @@ export default function LikeButton({ const [liked, setLiked] = useState(initialLiked); const [busy, setBusy] = useState(false); const [authOpen, setAuthOpen] = useState(false); + const [note, setNote] = useState(null); const toggle = async () => { if (!session) { @@ -39,6 +45,7 @@ export default function LikeButton({ setLiked(!liked); setCount(count + (liked ? -1 : 1)); setBusy(true); + setNote(null); try { const response = await fetch( communityApiUrl(`/api/community/patterns/${patternId}/like`), @@ -47,6 +54,7 @@ export default function LikeButton({ if (!response.ok) { setLiked(previous.liked); setCount(previous.count); + setNote(await readApiError(response, "Like not saved")); return; } const payload = (await response.json()) as { liked: boolean; count: number }; @@ -55,6 +63,7 @@ export default function LikeButton({ } catch { setLiked(previous.liked); setCount(previous.count); + setNote("Network error."); } finally { setBusy(false); } @@ -74,6 +83,11 @@ export default function LikeButton({ {String(count).padStart(2, "0")} + {note && ( + + {note} + + )} {authOpen && ( setAuthOpen(false)} onAuthed={() => router.refresh()} /> diff --git a/web/src/components/community/PatternCard.tsx b/web/src/components/community/PatternCard.tsx index 68e81d444..815124e02 100644 --- a/web/src/components/community/PatternCard.tsx +++ b/web/src/components/community/PatternCard.tsx @@ -15,7 +15,12 @@ import { matrixFromCode, } from "@/lib/pattern/matrix"; import SandboxPreview from "@/components/community/SandboxPreview"; -import { buildsConfigured, communityApiUrl, COMMUNITY_FETCH_INIT } from "@/lib/community/apiBase"; +import { + buildsConfigured, + communityApiUrl, + COMMUNITY_FETCH_INIT, + readApiError, +} from "@/lib/community/apiBase"; import { COLLECTION_EVENT, DECK_DRAG_TYPE, @@ -146,7 +151,16 @@ export default function PatternCard({ // Deck membership, kept in step with every other deck surface on the page. const [inDeck, setInDeck] = useState(false); const [deckBusy, setDeckBusy] = useState(false); - const [deckNote, setDeckNote] = useState(null); + // One line over the thumbnail for 2.5 s: a deck that is full, a like the + // server turned down. A second note restarts the clock instead of being + // cleared early by the first note's timer. + const [cardNote, setCardNote] = useState(null); + const noteTimer = useRef(undefined); + const flashNote = (text: string) => { + window.clearTimeout(noteTimer.current); + setCardNote(text); + noteTimer.current = window.setTimeout(() => setCardNote(null), 2500); + }; useEffect(() => { const sync = () => setInDeck(deckHas(item.id)); sync(); @@ -190,6 +204,7 @@ export default function PatternCard({ if (!response.ok) { setLiked(previous.liked); setLikeCount(previous.likeCount); + flashNote(await readApiError(response, "Like not saved")); return; } const payload = (await response.json()) as { liked: boolean; count: number }; @@ -198,6 +213,7 @@ export default function PatternCard({ } catch { setLiked(previous.liked); setLikeCount(previous.likeCount); + flashNote("Network error."); } finally { setLikeBusy(false); } @@ -213,16 +229,15 @@ export default function PatternCard({ return; } setDeckBusy(true); - setDeckNote(null); + setCardNote(null); // `code` is the .h the deck builds; `js` is the source this card is // already rendering, carried so the dock can draw the slot. const added = await collectPattern( { patternId: item.id, title: item.title, js: item.code }, fetchPatternHeader, ); - if (!added.ok) setDeckNote(added.reason ?? "Deck is full."); + if (!added.ok) flashNote(added.reason ?? "Deck is full."); setDeckBusy(false); - window.setTimeout(() => setDeckNote(null), 2500); }; // Dragging a card onto the dock is the same act as pressing its "+", so it @@ -502,11 +517,9 @@ export default function PatternCard({ > - {deckNote && ( - - {deckNote} + {cardNote && ( + + {cardNote} )} diff --git a/web/src/components/community/likeErrors.test.tsx b/web/src/components/community/likeErrors.test.tsx new file mode 100644 index 000000000..a169384b1 --- /dev/null +++ b/web/src/components/community/likeErrors.test.tsx @@ -0,0 +1,148 @@ +// A like the server turned down came undone with nothing on the page. Asked +// to slow down (429, "Too many likes — slow down a moment."), the pattern +// page's button lit and went back to LIK 00 inside 150 ms, and the wall card's +// heart only dropped aria-pressed. Both now roll back and say why: the route's +// own words when it sent some, the status when a proxy page answered instead. + +import { fireEvent, render, screen } from "@testing-library/react"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LikeButton from "./LikeButton"; +import PatternCard, { type PatternCardItem } from "./PatternCard"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }), + }, +})); +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: AnchorHTMLAttributes & { href: string; prefetch?: boolean; children: ReactNode }) => { + delete (rest as { prefetch?: boolean }).prefetch; + return ( + + {children} + + ); + }, +})); +// The card's still and its live preview both run pattern code in an iframe. +// Neither is what these tests look at, so the still never arrives. +vi.mock("@/lib/community/thumbs", () => ({ renderPatternThumb: () => new Promise(() => {}) })); +vi.mock("./SandboxPreview", () => ({ default: () => null })); + +const SLOW_DOWN = "Too many likes — slow down a moment."; +const NGINX_502 = + "502 Bad Gateway

502 Bad Gateway


nginx
"; + +const ITEM: PatternCardItem = { + id: "p1", + title: "Tide", + code: "", + parentId: null, + createdAt: "2026-09-01T00:00:00.000Z", + username: "dora", + displayUsername: "dora", + likeCount: 3, + forkCount: 0, + hasCpp: false, + visibility: "public", + deckCount: 0, + viewerLiked: false, +}; + +let fetchMock: ReturnType; +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const press = () => fireEvent.click(screen.getByRole("button", { name: "Like this pattern" })); + +describe("the pattern page's like button", () => { + const show = () => render(); + + it("rolls back and shows the server's words on a 429", async () => { + fetchMock.mockResolvedValue(Response.json({ error: SLOW_DOWN }, { status: 429 })); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe(SLOW_DOWN); + const button = screen.getByRole("button", { name: "Like this pattern" }); + expect(button.getAttribute("aria-pressed")).toBe("false"); + expect(button.textContent).toBe("LIK00"); + }); + + it("names the status when a proxy page answers", async () => { + fetchMock.mockResolvedValue( + new Response(NGINX_502, { status: 502, headers: { "Content-Type": "text/html" } }), + ); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe("Like not saved (HTTP 502)."); + }); + + it("says Network error when nothing answered", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe("Network error."); + }); + + it("clears the note when the next press goes through", async () => { + fetchMock + .mockResolvedValueOnce(Response.json({ error: SLOW_DOWN }, { status: 429 })) + .mockResolvedValueOnce(Response.json({ liked: true, count: 1 })); + show(); + press(); + await screen.findByRole("alert"); + press(); + const button = await screen.findByRole("button", { name: "Unlike this pattern" }); + expect(button.textContent).toBe("LIK01"); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); + +describe("the wall card's heart", () => { + const show = () => render(); + + it("rolls back and shows the server's words on a 429", async () => { + fetchMock.mockResolvedValue(Response.json({ error: SLOW_DOWN }, { status: 429 })); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe(SLOW_DOWN); + expect( + screen.getByRole("button", { name: "Like this pattern" }).getAttribute("aria-pressed"), + ).toBe("false"); + expect(screen.getByTitle("3 likes").textContent).toBe("LIK 03"); + }); + + it("names the status when a proxy page answers", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 502 })); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe("Like not saved (HTTP 502)."); + }); + + it("says Network error when nothing answered", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + show(); + press(); + expect((await screen.findByRole("alert")).textContent).toBe("Network error."); + }); + + it("takes the like without a note when the server agrees", async () => { + fetchMock.mockResolvedValue(Response.json({ liked: true, count: 4 })); + show(); + press(); + await screen.findByRole("button", { name: "Unlike this pattern" }); + expect(screen.getByTitle("4 likes").textContent).toBe("LIK 04"); + expect(screen.queryByRole("alert")).toBeNull(); + }); +});