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/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 b986d7b..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 => { @@ -339,61 +344,57 @@ 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. */} + {/* 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. */} + {/* 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" && ( - <> - - - {/* 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 - - + + )} + {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. */} - + {deck.description &&

{deck.description}

} {isOwner && ( @@ -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 && ( (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/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 + + )}