Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions web/src/components/community/Community.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
16 changes: 15 additions & 1 deletion web/src/components/community/LikeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>(null);

const toggle = async () => {
if (!session) {
Expand All @@ -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`),
Expand All @@ -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 };
Expand All @@ -55,6 +63,7 @@ export default function LikeButton({
} catch {
setLiked(previous.liked);
setCount(previous.count);
setNote("Network error.");
} finally {
setBusy(false);
}
Expand All @@ -74,6 +83,11 @@ export default function LikeButton({
<span aria-hidden="true">LIK</span>
<span>{String(count).padStart(2, "0")}</span>
</button>
{note && (
<span role="alert" className={styles.likeNote}>
{note}
</span>
)}

{authOpen && (
<AuthModal onClose={() => setAuthOpen(false)} onAuthed={() => router.refresh()} />
Expand Down
33 changes: 23 additions & 10 deletions web/src/components/community/PatternCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(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<string | null>(null);
const noteTimer = useRef<number | undefined>(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();
Expand Down Expand Up @@ -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 };
Expand All @@ -198,6 +213,7 @@ export default function PatternCard({
} catch {
setLiked(previous.liked);
setLikeCount(previous.likeCount);
flashNote("Network error.");
} finally {
setLikeBusy(false);
}
Expand All @@ -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
Expand Down Expand Up @@ -502,11 +517,9 @@ export default function PatternCard({
>
<HeartIcon filled={liked} />
</button>
{deckNote && (
<span
className={`${styles.cardDeckNote} ${deckNoteEdge}`}
>
{deckNote}
{cardNote && (
<span role="alert" className={`${styles.cardDeckNote} ${deckNoteEdge}`}>
{cardNote}
</span>
)}

Expand Down
148 changes: 148 additions & 0 deletions web/src/components/community/likeErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement> & { href: string; prefetch?: boolean; children: ReactNode }) => {
delete (rest as { prefetch?: boolean }).prefetch;
return (
<a href={href} {...rest}>
{children}
</a>
);
},
}));
// 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 =
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>";

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<typeof vi.fn>;
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(<LikeButton patternId="p1" initialCount={0} initialLiked={false} />);

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(<PatternCard item={ITEM} interactive={false} />);

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();
});
});
Loading