From 956d8e41f92104a6372504526d8973b6be7af755 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:41:14 +0900 Subject: [PATCH 1/3] fix(web): five buttons on a deck were one intention wearing disguises "Copy into my deck", "Send to my board", "Install to my board", "Copy pack link" and "Download pack" all sat in the same row, and three of them were the same wish. "Send to my board" does not send anything - it loads the deck into your working deck and opens the panel, which is what "Copy into my deck" already did, and then asks you to sign in to build what a public deck has had built and cached the whole time. So only one of them shows now. A public deck has a pack at a stable URL and offers "Install to my board", where the board fetches it itself: no sign-in, no working deck, no queue. Anything else has no pack to fetch and keeps the long way round. Sharing moves out of the action row into a quiet line under it, because it is what the deck's author does once and what a visitor never does. That line also says the thing the buttons could not: the pack is built on first request and rebuilt whenever the running order changes. There is no bake step to go looking for - which is worth stating, because a row of buttons with no "publish pack" in it reads like a missing feature rather than an automatic one. Also fixes the accent link arriving without padding or a border: composing btnAccent alone emits btnAccentLink and btnAccent but drops the btn underneath, so it got the fill and none of the shape. Both are named explicitly now. Co-Authored-By: Claude Opus 5 --- .../app/community/d/[id]/DeckDetailClient.tsx | 104 +++++++++--------- .../components/community/Community.module.css | 42 +++++++ 2 files changed, 94 insertions(+), 52 deletions(-) diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index b986d7b..9b08a85 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -339,61 +339,61 @@ export default function DeckDetailClient({ > {confirmCopy ? "Press again — this replaces your deck" : "Copy into my deck"} - {/* No sign-in, no working deck, no build queue of your own: the - pack is built once for the deck and served from a stable URL. - Three ways to reach it — the link to hand out, the file, and - the one that puts it on a board without any of the above. */} - {deck.visibility === "public" && ( - <> - - - {/* Straight onto a board with no account and no build queue: - the device fetches the pack itself. "Send to my board" - below builds into YOUR queue, which a visitor arriving from - a shared link has no reason to have. */} - 0 - ? patternsUrl(`/api/community/decks/${deck.id}/zip`) - : undefined - } - aria-disabled={!hydrated || playable.length === 0} - title="Open your board's Patterns page with this deck queued — no sign-in needed" - > - Install to my board - - + {/* Onto a board, in this order — the thing a deck exists for. + Two routes to it, and only ever one of them shown. + + A public deck has a pack already built and served from a stable + URL, so the board fetches it directly: no sign-in, no working + deck, no build queue. Anything else has no pack to fetch, so it + goes the long way — into your working deck, where the panel can + build it once you are signed in. Offering both at once was three + buttons for one intention. */} + {deck.visibility === "public" ? ( + 0 + ? patternsUrl(`/api/community/decks/${deck.id}/zip`) + : undefined + } + aria-disabled={!hydrated || playable.length === 0} + title="Open your board's Patterns page with this deck queued — no sign-in needed" + > + Install to my board + + ) : ( + )} - {/* The deck's whole point: onto a board, in this order. Copying is - the editing gesture; this is the one it exists for. */} - + {/* Sharing, kept off the main row: it is what the deck's author does + once, not what a visitor does. The pack itself needs no action — + it is built on first request and rebuilt whenever the running + order changes — so this says so rather than implying a button + somewhere bakes it. */} + {deck.visibility === "public" && playable.length > 0 && ( +

+ Share this deck + + + + The pack is built automatically and rebuilt whenever you reorder. + +

+ )} + {deck.description &&

{deck.description}

} {isOwner && ( diff --git a/web/src/components/community/Community.module.css b/web/src/components/community/Community.module.css index 52c948a..441b925 100644 --- a/web/src/components/community/Community.module.css +++ b/web/src/components/community/Community.module.css @@ -2855,6 +2855,48 @@ pointer-events: none; } +/* The accent weight of the same thing — "this leaves the page and touches + your board", which is exactly what it does. + `composes: btn btnAccent` names both on purpose: composing btnAccent alone + emits only two classes here, dropping the btn underneath it, so the fill + arrives without the padding or border that make it a button shape. */ +.btnAccentLink { + composes: btn btnAccent; + display: inline-flex; + align-items: center; + text-decoration: none; + line-height: 1; +} + +.btnAccentLink[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +/* Sharing sits under the action row, not in it: the author does this once, + a visitor never does it at all. Quieter type and small buttons say that + without needing a heading. */ +.deckShareRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 10px 0 0; +} + +.deckShareLabel { + font-family: var(--pf-mono, monospace); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pfc-ink-faint); +} + +.deckShareNote { + font-size: 11.5px; + color: var(--pfc-ink-faint); +} + /* The shipped pack carries its own actions, because unlike a deck card it is not a link to a detail page — there is nowhere else for it to go. Pushed to the bottom of the panel so its height still matches the deck cards beside From 162fb447d0ff705168980ca31c2924acdd0f9af2 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:48:57 +0900 Subject: [PATCH 2/3] feat(web): a like you can find again, and one Share button Liking was write-only. You could press it and the count went up and the pattern was gone - there was no view anywhere that listed what you had liked. The thing that did keep a list, Saved, lived in localStorage, so it was per-browser and disappeared the moment you cleared site data. The weaker half had the feature and the stronger half did not. So "Liked" joins the feed's tabs. It only appears when signed in and answers empty when not, rather than falling back to the whole wall, which would be a tab that lies about whose list it is showing. It still obeys feedVisible: having liked something is not a standing right to keep reading it after its author takes it private. The infinite scroll refills through the API route, so that had to learn the viewer too - otherwise page two of your liked list arrived as the rest of the wall. Sharing a deck becomes one button and a panel. Copy-link and download were sitting in the action row next to the thing visitors actually came for, and the row had grown to five. The panel also shows the address instead of only claiming to have copied it: "Copied" about a URL you never saw asks for trust it has not earned, and the field is the fallback when the clipboard is refused. Verified against the database rather than through a browser session, since checking it signed-in would have meant making an account: a user with 9 likes gets exactly those 9 back out of a 66-item feed, with nothing unliked leaking in, and the signed-out list comes back empty. Co-Authored-By: Claude Opus 5 --- web/src/app/api/community/patterns/route.ts | 9 +- .../app/community/d/[id]/DeckDetailClient.tsx | 74 +++++++----- web/src/app/community/patterns/page.tsx | 11 +- .../components/community/Community.module.css | 69 ++++++++--- .../community/CommunityFeedClient.tsx | 5 +- web/src/components/community/FeedControls.tsx | 16 +++ .../community/ShareDeckPackModal.tsx | 112 ++++++++++++++++++ web/src/lib/community/queries.ts | 27 ++++- 8 files changed, 273 insertions(+), 50 deletions(-) create mode 100644 web/src/components/community/ShareDeckPackModal.tsx diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 9ca653b..511340e 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -66,8 +66,15 @@ async function handleGet(request: Request) { const offset = clampInt(params.get("offset"), 0, 0, 1_000_000); const size = clampInt(params.get("size"), 12, 1, MAX_FEED_PAGE_SIZE); + // The infinite scroll refills through here, so `liked` needs the same viewer + // the first page was rendered for — without it page two of your liked list + // would come back as the whole wall. + const session = + sort === "liked" ? await getAuth().api.getSession({ headers: request.headers }) : null; + const viewerId = session?.user.id ?? null; + const [items, total] = await Promise.all([ - listFeed({ sort, hardwareOnly, limit: size, offset }), + listFeed({ sort, hardwareOnly, limit: size, offset, viewerId }), countFeed(hardwareOnly), ]); diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 9b08a85..28b05c3 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -5,6 +5,7 @@ import { useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import PatternCard from "@/components/community/PatternCard"; import ReportModal from "@/components/community/ReportModal"; +import ShareDeckPackModal from "@/components/community/ShareDeckPackModal"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { deckItems, @@ -57,7 +58,7 @@ export default function DeckDetailClient({ const [confirmReplace, setConfirmReplace] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [packNote, setPackNote] = useState(null); - const [linkNote, setLinkNote] = useState(null); + const [shareOpen, setShareOpen] = useState(false); const { patternsUrl } = useDeviceHost(); // patternsUrl needs `window` and answers "#" without it. The other callers @@ -79,6 +80,9 @@ export default function DeckDetailClient({ // after that gets a file immediately. Poll rather than spin: a deck that // has never been downloaded is exactly the case this handles. const packUrl = communityApiUrl(`/api/community/decks/${deck.id}/zip`); + // The share panel shows this, so it has to be the whole address rather than + // a path — it is going into somebody else's Discord, not back into this app. + const absolutePackUrl = hydrated ? new URL(packUrl, window.location.origin).toString() : packUrl; const downloadPack = async () => { setError(null); setPackNote("Preparing…"); @@ -125,20 +129,21 @@ export default function DeckDetailClient({ // Copying also kicks the build off. A deck nobody has downloaded compiles // on first request, and that first request should be the person who chose // to share it rather than the stranger who clicked their link. - const copyPackLink = async () => { - setError(null); + const copyPackLink = async (): Promise => { + let copied = false; try { - await navigator.clipboard.writeText(new URL(packUrl, window.location.origin).toString()); - setLinkNote("Copied"); + await navigator.clipboard.writeText(absolutePackUrl); + copied = true; captureEvent("deck_pack_link_copied", { deckId: deck.id }); } catch { - setError("Could not reach the clipboard — copy the address bar link instead."); - return; + // The modal shows the address as selectable text, so a refused + // clipboard is a smaller failure here than it looks. + copied = false; } - // Fire-and-forget: the link is already on the clipboard and works either - // way. This only decides whether the recipient waits for a compile. + // Fire-and-forget either way: the address is on screen and works + // regardless. This only decides whether the recipient waits for a compile. void fetch(packUrl, COMMUNITY_FETCH_INIT).catch(() => {}); - setTimeout(() => setLinkNote(null), 2000); + return copied; }; const patch = async (body: Record): Promise => { @@ -348,6 +353,21 @@ export default function DeckDetailClient({ goes the long way — into your working deck, where the panel can build it once you are signed in. Offering both at once was three buttons for one intention. */} + {/* Sharing is one button, not a row of them: the author does it + once and a visitor never does it at all, so the two ways out + (a link, a file) belong behind it rather than beside the + action people came for. */} + {deck.visibility === "public" && ( + + )} {deck.visibility === "public" ? ( - {/* Sharing, kept off the main row: it is what the deck's author does - once, not what a visitor does. The pack itself needs no action — - it is built on first request and rebuilt whenever the running - order changes — so this says so rather than implying a button - somewhere bakes it. */} - {deck.visibility === "public" && playable.length > 0 && ( -

- Share this deck - - - - The pack is built automatically and rebuilt whenever you reorder. - -

- )} {deck.description &&

{deck.description}

} @@ -474,6 +475,21 @@ export default function DeckDetailClient({ /> )} + {shareOpen && ( + 0 + ? patternsUrl(`/api/community/decks/${deck.id}/zip`) + : null + } + onCopyLink={copyPackLink} + onDownload={() => void downloadPack()} + downloadNote={packNote} + onClose={() => setShareOpen(false)} + /> + )} + {editOpen && ( ); } diff --git a/web/src/components/community/Community.module.css b/web/src/components/community/Community.module.css index 441b925..386acf0 100644 --- a/web/src/components/community/Community.module.css +++ b/web/src/components/community/Community.module.css @@ -2847,7 +2847,8 @@ display: inline-flex; align-items: center; text-decoration: none; - line-height: 1; + /* No line-height override: the buttons beside it inherit the body's, and + resetting it here made the link 6px shorter than everything in the row. */ } .btnLink[aria-disabled="true"] { @@ -2865,7 +2866,6 @@ display: inline-flex; align-items: center; text-decoration: none; - line-height: 1; } .btnAccentLink[aria-disabled="true"] { @@ -2873,30 +2873,69 @@ pointer-events: none; } -/* Sharing sits under the action row, not in it: the author does this once, - a visitor never does it at all. Quieter type and small buttons say that - without needing a heading. */ -.deckShareRow { +/* ── Share panel ────────────────────────────────────────────────────────── + The link is shown, not just copied: a button that says "Copied" about an + address you never saw asks for trust it has not earned, and the field is + also the fallback when the clipboard API refuses. */ +.shareLead { + margin: 0 0 12px; + font-size: 13px; + line-height: 1.6; + color: var(--pfc-ink-muted); +} + +.shareLinkRow { display: flex; - flex-wrap: wrap; - align-items: center; gap: 8px; - margin: 10px 0 0; } -.deckShareLabel { +.shareLinkField { + flex: 1; + min-width: 0; + appearance: none; + border: 1px solid var(--pfc-edge-strong); + border-radius: 0; + background: var(--pfc-well); + color: var(--pfc-ink); font-family: var(--pf-mono, monospace); - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--pfc-ink-faint); + font-size: 12px; + padding: 8px 10px; + /* Long URLs scroll rather than stretching the dialog. */ + text-overflow: ellipsis; +} + +.shareLinkField:focus { + outline: none; + border-color: var(--pfc-ink); } -.deckShareNote { +.shareNote { + margin: 10px 0 0; font-size: 11.5px; + line-height: 1.6; color: var(--pfc-ink-faint); } +.shareAlt { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--pfc-hair); +} + +.shareAltLink { + font-size: 12.5px; + color: var(--pfc-ink-muted); + text-decoration: none; +} + +.shareAltLink:hover { + color: var(--pfc-led); +} + /* The shipped pack carries its own actions, because unlike a deck card it is not a link to a detail page — there is nowhere else for it to go. Pushed to the bottom of the panel so its height still matches the deck cards beside diff --git a/web/src/components/community/CommunityFeedClient.tsx b/web/src/components/community/CommunityFeedClient.tsx index e47b75a..78e7f6b 100644 --- a/web/src/components/community/CommunityFeedClient.tsx +++ b/web/src/components/community/CommunityFeedClient.tsx @@ -83,11 +83,14 @@ export default function CommunityFeedClient({ sort = "new", hardwareOnly = false, total: initialTotal = 0, + signedIn = false, }: { items: PatternCardItem[]; sort?: string; hardwareOnly?: boolean; total?: number; + /** Whether to offer the "Liked" tab — it lists the viewer's own likes. */ + signedIn?: boolean; }) { const wrapperRef = useRef(null); const containerRef = useRef(null); @@ -328,7 +331,7 @@ export default function CommunityFeedClient({ id="wall" onClickCapture={rememberPlace} > - + {total === 0 ? (
diff --git a/web/src/components/community/FeedControls.tsx b/web/src/components/community/FeedControls.tsx index d6691c9..d777eb8 100644 --- a/web/src/components/community/FeedControls.tsx +++ b/web/src/components/community/FeedControls.tsx @@ -12,6 +12,9 @@ import styles from "./Community.module.css"; // not here: that is the Ctrl+scroll zoom, a per-browser preference, and the // hint on the right is its only UI. +// "Liked" is a filter rather than an ordering, and it only means anything to +// someone signed in — so it is the one tab that appears conditionally. It is +// last because it is about you, not about the wall. const SORTS = [ { id: "new", label: "Newest" }, { id: "top", label: "Most liked" }, @@ -23,10 +26,13 @@ export default function FeedControls({ sort, hardwareOnly, total, + signedIn = false, }: { sort: string; hardwareOnly: boolean; total?: number; + /** Adds the "Liked" tab. Without a viewer there is nothing for it to list. */ + signedIn?: boolean; }) { const pathname = usePathname(); const params = useSearchParams(); @@ -65,6 +71,16 @@ export default function FeedControls({ {option.label} ))} + {signedIn && ( + + Liked + + )}
+ ); +} diff --git a/web/src/lib/community/queries.ts b/web/src/lib/community/queries.ts index 570ed7e..f1e1ded 100644 --- a/web/src/lib/community/queries.ts +++ b/web/src/lib/community/queries.ts @@ -71,7 +71,13 @@ const deckCount = sql`( * redesign makes a deck the thing the community is FOR (a handful of public slots a * person, a curated shelf on the decks page), so the signal is now the * scarcest one on the site and earns its tab. */ -export const FEED_SORTS = ["new", "top", "forks", "decks"] as const; +/* "liked" is the odd one out: a filter wearing a sort's clothes. It shows only + * what the viewer has liked, newest first, and it exists because a like was + * write-only — you could press it and never find the pattern again. It replaces + * a separate "Saved" list that lived in localStorage, so it was per-browser and + * gone the moment you cleared site data. A like was already server-side and + * per-account; the list was the only part missing. */ +export const FEED_SORTS = ["new", "top", "forks", "decks", "liked"] as const; export type FeedSort = (typeof FEED_SORTS)[number]; export function parseFeedSort(raw: string | undefined): FeedSort { @@ -137,13 +143,21 @@ export async function listFeed({ hardwareOnly = false, limit = 60, offset = 0, + viewerId = null, }: { sort?: FeedSort; hardwareOnly?: boolean; limit?: number; offset?: number; + /** Required by `sort: "liked"` — whose likes to list. */ + viewerId?: string | null; } = {}): Promise { const db = getDb(); + + // A signed-out visitor has no likes, and answering with the whole wall + // would be worse than answering with nothing: the tab says "the ones you + // liked". Empty is the honest reply. + if (sort === "liked" && !viewerId) return []; // Every ordering falls back to newest-first so results are stable when the // primary key ties (which it does constantly while counts are near zero). const order = @@ -155,11 +169,20 @@ export async function listFeed({ ? [desc(deckCount), desc(patterns.createdAt)] : [desc(patterns.createdAt)]; + // Still subject to feedVisible, like every other listing. Having liked + // something is not a standing right to keep reading it: if the author takes + // it private afterwards, it leaves your list too. The alternative would turn + // a like into a way to hold a copy of work somebody withdrew. + const likedByViewer = + sort === "liked" + ? sql`EXISTS (SELECT 1 FROM ${likes} WHERE ${likes.patternId} = ${patterns.id} AND ${likes.userId} = ${viewerId})` + : undefined; + const rows = await db .select(feedColumns) .from(patterns) .innerJoin(user, eq(patterns.userId, user.id)) - .where(and(feedVisible, hardwareOnly ? hardwareReady : undefined)) + .where(and(feedVisible, likedByViewer, hardwareOnly ? hardwareReady : undefined)) .orderBy(...order) .limit(limit) .offset(offset); From a0f14bc1d77063429bca2eb836d3df7b649d0a85 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:25:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor(web):=20remove=20Saved=20=E2=80=94?= =?UTF-8?q?=20the=20like=20was=20already=20that=20gesture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved was a second keeping list beside the like, and the weaker of the two: it lived in localStorage, so it was per-browser, invisible from your phone, and gone with your site data. A like was already stored per account. The only thing it lacked was somewhere to read it back, which the feed's "Liked" tab now is. Gone with it: the star on a pattern page, the Saved tab in the deck panel, the dock's saved counter, and the promote-into-deck path. The panel holds one list now, so its tab strip is a label. Two things fell out of the removal. The rule that a pattern with no firmware header cannot enter a deck lived in savedToDeck, because the Saved list was the only way such a pattern could reach one. Deleting that function deleted the check, and deckAdd never had it - the callers were gating their own buttons and nothing underneath them agreed. It is in deckAdd now, where everything enters. The deck smoke test caught this, which is what it is for. And the full-deck message said "Save it instead", which was advice to use a feature this commit removes. The retired localStorage key is cleared on the next deck read rather than left behind. Nothing reads it any more, so it would otherwise sit in people's browsers indefinitely holding copies of patterns with no screen able to show or empty it. Co-Authored-By: Claude Opus 5 --- web/scripts/deck-smoke.ts | 38 ++---- .../community/p/[id]/PatternDetailClient.tsx | 47 ++------ web/src/components/community/DeckDock.tsx | 18 +-- web/src/components/community/DeckPanel.tsx | 96 +++------------ .../components/community/NewThreadModal.tsx | 8 +- web/src/lib/community/deck.ts | 109 ++++++------------ 6 files changed, 73 insertions(+), 243 deletions(-) diff --git a/web/scripts/deck-smoke.ts b/web/scripts/deck-smoke.ts index 3a4ed3f..a00a8ce 100644 --- a/web/scripts/deck-smoke.ts +++ b/web/scripts/deck-smoke.ts @@ -84,38 +84,18 @@ async function main() { deck.deckMove("nope", 1); check("unknown id is a no-op", deck.deckItems().length, 3); - console.log("\n── saved has no cap ──"); - deck.savedClear(); - for (let n = 1; n <= deck.DECK_MAX + 15; n += 1) deck.savedAdd(item(n)); - check("holds more than a deck", deck.savedItems().length, deck.DECK_MAX + 15); - check("newest first", deck.savedItems()[0].patternId, `p${deck.DECK_MAX + 15}`); - deck.savedAdd(item(1, "#pragma once // updated")); - check("re-saving updates in place", deck.savedItems().length, deck.DECK_MAX + 15); - - console.log("\n── promoting a saved pattern ──"); + // Saved is gone - the like replaced it, and the feed's "Liked" tab is where + // it is read back from. What survives from those tests is the rule that + // guarded the boundary between the two lists: a pattern whose author has not + // shipped a firmware header cannot enter a deck, because building one would + // hand the compiler an empty file. + console.log("\n── a deck refuses what cannot be built ──"); deck.deckClear(); - deck.savedClear(); - deck.savedAdd(item(7)); - check("promotes into the deck", deck.savedToDeck("p7").ok, true); - check("and stays saved", deck.savedHas("p7"), true); - check("now in the deck too", deck.deckHas("p7"), true); - check("unknown id is refused", deck.savedToDeck("nope").ok, false); - - // A pattern can be saved before its author has ported it to firmware. Letting - // that into a deck would send an empty file to the compiler. - deck.savedAdd(item(8, "")); - const noHeader = deck.savedToDeck("p8"); + const noHeader = deck.deckAdd(item(8, "")); check("refuses a pattern with no header", noHeader.ok, false); - check("explains why", noHeader.reason?.includes("firmware header"), true); check("and it never reaches the deck", deck.deckHas("p8"), false); - check("buildable check agrees", deck.savedIsBuildable(item(8, "")), false); - check("a real header is buildable", deck.savedIsBuildable(item(9)), true); - - console.log("\n── the two lists are independent ──"); - deck.deckClear(); - check("clearing the deck leaves saved alone", deck.savedItems().length > 0, true); - deck.savedClear(); - check("and clearing saved empties it", deck.savedItems().length, 0); + check("buildable check agrees", deck.deckIsBuildable(item(8, "")), false); + check("a real header is buildable", deck.deckIsBuildable(item(9)), true); } main() diff --git a/web/src/app/community/p/[id]/PatternDetailClient.tsx b/web/src/app/community/p/[id]/PatternDetailClient.tsx index cdab8d6..f5d34bb 100644 --- a/web/src/app/community/p/[id]/PatternDetailClient.tsx +++ b/web/src/app/community/p/[id]/PatternDetailClient.tsx @@ -15,15 +15,7 @@ import ReportModal from "@/components/community/ReportModal"; import DeletePatternButton from "@/components/community/DeletePatternButton"; import SendModuleModal from "@/components/community/SendModuleModal"; import { buildsConfigured } from "@/lib/community/apiBase"; -import { - COLLECTION_EVENT, - deckAdd, - deckHas, - deckRemove, - savedAdd, - savedHas, - savedRemove, -} from "@/lib/community/deck"; +import { COLLECTION_EVENT, deckAdd, deckHas, deckRemove } from "@/lib/community/deck"; import { knobSetupFromCode } from "@/lib/community/knobs"; import { describeMatrixShape, matrixFromCode } from "@/lib/patternMatrix"; import { writeLabHandoff } from "@/lib/community/handoff"; @@ -112,15 +104,13 @@ export default function PatternDetailClient({ const [reportOpen, setReportOpen] = useState(false); const [sendOpen, setSendOpen] = useState(false); const [savingCode, setSavingCode] = useState(false); - // Deck and saved membership are shared state (header chip, other tabs), so read - // from the store and refreshed on the change event rather than mirrored. + // Deck membership is shared state (header chip, other tabs), so read from the + // store and refreshed on the change event rather than mirrored. const [inDeck, setInDeck] = useState(false); - const [isSaved, setIsSaved] = useState(false); const [collectNote, setCollectNote] = useState(null); useEffect(() => { const sync = () => { setInDeck(deckHas(pattern.id)); - setIsSaved(savedHas(pattern.id)); }; sync(); window.addEventListener(COLLECTION_EVENT, sync); @@ -144,22 +134,6 @@ export default function PatternDetailClient({ setCollectNote(added.ok ? null : (added.reason ?? null)); }; - // Saving works with or without a firmware header: it is a bookmark, not a - // build slot. The stored `code` is only used when a saved pattern is later - // promoted into the deck, which the deck itself re-checks. - const toggleSaved = () => { - if (isSaved) { - savedRemove(pattern.id); - return; - } - savedAdd({ - patternId: pattern.id, - title: pattern.title, - code: pattern.codeCpp ?? "", - js: pattern.code, - }); - setCollectNote(null); - }; const [saveError, setSaveError] = useState(null); const knobSetup = useMemo(() => knobSetupFromCode(pattern.code), [pattern.code]); @@ -321,17 +295,10 @@ export default function PatternDetailClient({ ↗ Send to my Patternflow )} - {/* Two different gestures. Saving is "I might want this", and has - no limit. The deck is the short ordered list that becomes one - build, so it is capped at what a build holds. */} - + {/* "Save" used to sit here as a second keeping gesture beside the + like. It is gone: the like was already the same intention, kept + per-account instead of per-browser, and now has the feed's + "Liked" tab to read it back from. */} {buildsConfigured() && pattern.codeCpp && ( - )} -
{slots.map((item, index) => item ? ( diff --git a/web/src/components/community/DeckPanel.tsx b/web/src/components/community/DeckPanel.tsx index f433afb..f9a5bff 100644 --- a/web/src/components/community/DeckPanel.tsx +++ b/web/src/components/community/DeckPanel.tsx @@ -13,10 +13,6 @@ import { deckItems, deckMove, deckRemove, - savedItems, - savedIsBuildable, - savedRemove, - savedToDeck, type CollectedPattern, } from "@/lib/community/deck"; import { useDeviceHost } from "@/lib/community/deviceHost"; @@ -25,12 +21,15 @@ import AuthModal from "./AuthModal"; import ShareDeckModal from "./ShareDeckModal"; import styles from "./Community.module.css"; -// The deck and the saved list, in one panel. +// The deck, in one panel. // // A deck is what goes on the board: capped at what one build holds, and // ORDERED, because the device cycles patterns in sequence — arranging one is -// the same decision as ordering a setlist. Saved is everything else you liked, -// uncapped, waiting to be promoted. +// the same decision as ordering a setlist. +// +// A second list used to live here — Saved, uncapped, everything you might +// want later. The like already was that gesture and kept it per-account +// rather than per-browser, so this panel is one list now. // // Building turns the deck into loadable .pfm modules: ~½ s per pattern, one // zip, installed from the device's own /patterns page with no reflash. @@ -51,8 +50,6 @@ export default function DeckPanel() { const { data: session } = authClient.useSession(); const [deck, setDeck] = useState([]); - const [saved, setSaved] = useState([]); - const [tab, setTab] = useState<"deck" | "saved">("deck"); const [open, setOpen] = useState(false); const [build, setBuild] = useState(null); const [error, setError] = useState(null); @@ -63,8 +60,8 @@ export default function DeckPanel() { // dialogs would fight over the overlay click. const [shareOpen, setShareOpen] = useState(false); // Sign-in is asked for at the moment it is needed — building — and never for - // looking at your own lists. Saving is a local bookmark; gating the panel on - // an account would mean a signed-out visitor cannot see what they saved. + // looking at your own deck, which is local to this browser and none of the + // server's business until something is built from it. const [needsAuth, setNeedsAuth] = useState(false); const { deviceHost, changeDeviceHost, patternsUrl } = useDeviceHost(); @@ -72,7 +69,6 @@ export default function DeckPanel() { useEffect(() => { const sync = () => { setDeck(deckItems()); - setSaved(savedItems()); }; sync(); window.addEventListener(COLLECTION_EVENT, sync); @@ -84,13 +80,10 @@ export default function DeckPanel() { }, []); // The dock along the bottom of the page owns arranging the deck; this panel - // owns the two things it cannot do inline — building, and the saved list — - // so the dock asks for it by name and says which one it wants. - // See DECK_PANEL_EVENT in lib/community/deck.ts. + // owns the one thing it cannot do inline — building — so the dock asks for + // it by name. See DECK_PANEL_EVENT in lib/community/deck.ts. useEffect(() => { - const open = (event: Event) => { - const wanted = (event as CustomEvent<{ tab?: "deck" | "saved" }>).detail?.tab; - if (wanted) setTab(wanted); + const open = () => { setOpen(true); }; window.addEventListener(DECK_PANEL_EVENT, open); @@ -187,12 +180,6 @@ export default function DeckPanel() { setOpen(false); }; - const promote = (patternId: string) => { - const result = savedToDeck(patternId); - setNote(result.ok ? null : (result.reason ?? null)); - if (result.ok) setTab("deck"); - }; - // No builds configured (e.g. the Vercel mirror) → nothing here works. if (!buildsConfigured()) return null; @@ -204,9 +191,7 @@ export default function DeckPanel() {
) : (
+ {/* One list now, so the tab strip is a label. Saved lived in + the other tab; the like replaced it. */} {note &&
{note}
} - {tab === "deck" ? ( - deck.length === 0 ? ( + {deck.length === 0 ? (

Your deck is empty. On any pattern that ships a firmware header, use “Add to deck” — then build them all as loadable modules in one @@ -428,44 +403,7 @@ export default function DeckPanel() { {confirmEmpty ? "Press again to empty" : `Empty deck (${deck.length})`}

- - ) - ) : saved.length === 0 ? ( -

- Nothing saved yet. Save anything you might want later — there is no limit - here. The deck is the short list you actually build. -

- ) : ( -
    - {saved.map((item) => ( -
  • - - {item.title} - - - - -
  • - ))} -
+ )} )} diff --git a/web/src/components/community/NewThreadModal.tsx b/web/src/components/community/NewThreadModal.tsx index 9f34ad7..d4611b7 100644 --- a/web/src/components/community/NewThreadModal.tsx +++ b/web/src/components/community/NewThreadModal.tsx @@ -4,7 +4,7 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import BodyComposer from "./BodyComposer"; -import { deckItems, savedItems } from "@/lib/community/deck"; +import { deckItems } from "@/lib/community/deck"; import { communityPatternUrl } from "@/lib/community/license"; import { ATTACHMENT_EXTENSIONS, @@ -47,9 +47,9 @@ export default function NewThreadModal({ const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - // Whatever this person has collected — the deck and the saved list are the - // only pattern index the browser has without a round trip. - const linkable = picking ? [...deckItems(), ...savedItems()] : []; + // Whatever this person has collected — the working deck is the only pattern + // index the browser has without a round trip. + const linkable = picking ? deckItems() : []; const canSubmit = title.trim().length > 0 && body.trim().length > 0; diff --git a/web/src/lib/community/deck.ts b/web/src/lib/community/deck.ts index e39bbad..47dccc4 100644 --- a/web/src/lib/community/deck.ts +++ b/web/src/lib/community/deck.ts @@ -1,22 +1,20 @@ -// Two collections, not one. +// The working deck: capped, ORDERED, and buildable. What goes on the board +// tonight. // -// This started as a single "cart" capped at ten, which conflated two different -// things because they happened to share a button. The cap is a *build* limit -// (MAX_MODULE_PATTERNS_PER_BUILD) and has nothing to do with how many patterns -// somebody wants to keep. Telling a person they may only like ten things is -// absurd; telling them how many a build carries is a fact about the builder. +// The order is the point. The device cycles patterns with a long press on +// encoder 4, so a deck is a setlist, not a folder — and arranging one is a +// decision the person made, not a detail of storage. // -// Saved — unbounded, unordered. The "I might want this" gesture. A pin. -// Deck — capped, ORDERED, and buildable. What goes on the board tonight. -// -// The order is the point of a deck. The device cycles patterns with a long -// press on encoder 4, so a deck is a setlist, not a folder — and arranging one -// is a decision the person made, not a detail of storage. -// -// Both live in localStorage on purpose. This is browsing state: it holds copies +// It lives in localStorage on purpose. This is browsing state: it holds copies // of headers that could be re-collected in a minute, so losing it costs // nothing, and keeping it out of the database means no schema, no "whose deck // is this" question, and no sync. +// +// There used to be a second list here — Saved, unbounded and unordered, the +// "I might want this" pin. It was removed because a like already was that +// gesture, and a better one: likes are server-side and per-account, while +// Saved was per-browser and died with the site data. What likes lacked was +// somewhere to read them back, which is now the feed's "Liked" tab. export type CollectedPattern = { /** Community pattern id — de-duplicates, and links back. */ @@ -115,19 +113,17 @@ export async function collectPattern( */ export const DECK_PANEL_EVENT = "pf-open-deck-panel"; -/** Which list the panel should open on. The dock now owns arranging the deck, - * so it mostly asks for the parts it does NOT do: building, and Saved. */ -export type DeckPanelTab = "deck" | "saved"; - -export function openDeckPanel(tab: DeckPanelTab = "deck"): void { +export function openDeckPanel(): void { if (typeof window === "undefined") return; - window.dispatchEvent(new CustomEvent(DECK_PANEL_EVENT, { detail: { tab } })); + window.dispatchEvent(new CustomEvent(DECK_PANEL_EVENT)); } const DECK_KEY = "pf-deck"; -const SAVED_KEY = "pf-saved"; /** What the deck was called when it was the only list. Migrated once, on read. */ const LEGACY_CART_KEY = "pf-module-cart"; +/** The removed Saved list. Cleared on first read so it does not sit in + * people's browsers forever holding copies of patterns nothing can show. */ +const RETIRED_SAVED_KEY = "pf-saved"; function parse(raw: string | null): CollectedPattern[] { if (!raw) return []; @@ -158,6 +154,10 @@ function read(key: string): CollectedPattern[] { window.localStorage.setItem(DECK_KEY, legacy); window.localStorage.removeItem(LEGACY_CART_KEY); } + // Same idea for the list that went away: nothing reads it now, and leaving + // it behind would keep a copy of somebody's saved patterns in their + // browser indefinitely with no screen able to show or clear it. + window.localStorage.removeItem(RETIRED_SAVED_KEY); } return parse(window.localStorage.getItem(key)); } @@ -184,6 +184,17 @@ export function deckHas(patternId: string): boolean { /** Add, or refresh the stored header if the pattern is already in the deck. */ export function deckAdd(item: CollectedPattern): { ok: boolean; reason?: string } { + // A deck is compiled into firmware, so a pattern whose author has not + // shipped a `.h` has nothing to build. This check used to live on the way + // out of the Saved list, which was the only route in for a pattern without + // one; with that list gone the rule belongs here, where everything enters. + // The callers also gate their buttons on it — this is the floor under them. + if (!deckIsBuildable(item)) { + return { + ok: false, + reason: `"${item.title}" has no firmware header yet, so it cannot be built. Open it and check back once its author adds one.`, + }; + } const items = read(DECK_KEY); const existing = items.findIndex((entry) => entry.patternId === item.patternId); if (existing >= 0) { @@ -194,7 +205,7 @@ export function deckAdd(item: CollectedPattern): { ok: boolean; reason?: string if (items.length >= DECK_MAX) { return { ok: false, - reason: `A deck holds ${DECK_MAX} patterns — that is what fits in one build. Save it instead.`, + reason: `A deck holds ${DECK_MAX} patterns — that is what fits in one build. Take one out to make room.`, }; } items.push(item); @@ -256,57 +267,7 @@ export function deckMove(patternId: string, direction: -1 | 1): void { write(DECK_KEY, items); } -// ── Saved ──────────────────────────────────────────────────────────────────── - -export function savedItems(): CollectedPattern[] { - return read(SAVED_KEY); -} - -export function savedHas(patternId: string): boolean { - return read(SAVED_KEY).some((item) => item.patternId === patternId); -} - -/** No cap. Saving is "I liked this", and there is no reason to ration that. */ -export function savedAdd(item: CollectedPattern): void { - const items = read(SAVED_KEY); - const existing = items.findIndex((entry) => entry.patternId === item.patternId); - if (existing >= 0) items[existing] = item; - else items.unshift(item); // newest first — this is a list you browse - write(SAVED_KEY, items); -} - -export function savedRemove(patternId: string): void { - write( - SAVED_KEY, - read(SAVED_KEY).filter((item) => item.patternId !== patternId), - ); -} - -export function savedClear(): void { - write(SAVED_KEY, []); -} - -/** - * Promote a saved pattern into the deck, leaving it saved. - * - * Saving works on any pattern, but a deck is built into firmware, so one - * without a hardware-tested `.h` has nothing to compile. Caught here rather - * than at build time, where the failure would be a compiler error about an - * empty file. - */ -export function savedToDeck(patternId: string): { ok: boolean; reason?: string } { - const item = read(SAVED_KEY).find((entry) => entry.patternId === patternId); - if (!item) return { ok: false, reason: "That pattern is no longer saved." }; - if (item.code.trim().length === 0) { - return { - ok: false, - reason: `"${item.title}" has no firmware header yet, so it cannot be built. Open it and check back once its author adds one.`, - }; - } - return deckAdd(item); -} - -/** Whether a saved pattern can go into the deck at all. Drives the UI. */ -export function savedIsBuildable(item: CollectedPattern): boolean { +/** Whether a pattern can go into the deck at all. Drives the UI. */ +export function deckIsBuildable(item: CollectedPattern): boolean { return item.code.trim().length > 0; }