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
38 changes: 9 additions & 29 deletions web/scripts/deck-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion web/src/app/api/community/patterns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]);

Expand Down
138 changes: 77 additions & 61 deletions web/src/app/community/d/[id]/DeckDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,7 +58,7 @@ export default function DeckDetailClient({
const [confirmReplace, setConfirmReplace] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [packNote, setPackNote] = useState<string | null>(null);
const [linkNote, setLinkNote] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const { patternsUrl } = useDeviceHost();

// patternsUrl needs `window` and answers "#" without it. The other callers
Expand All @@ -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…");
Expand Down Expand Up @@ -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<boolean> => {
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<string, unknown>): Promise<boolean> => {
Expand Down Expand Up @@ -339,61 +344,57 @@ export default function DeckDetailClient({
>
{confirmCopy ? "Press again — this replaces your deck" : "Copy into my deck"}
</button>
{/* 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" && (
<>
<button
type="button"
className={styles.btn}
disabled={busy || playable.length === 0}
title="Copy this deck's pack address — the link to paste where you're sharing it"
onClick={() => void copyPackLink()}
>
{linkNote ?? "Copy pack link"}
</button>
<button
type="button"
className={styles.btn}
disabled={busy || playable.length === 0}
title="Download this deck as a .zip you can drop on your device's Patterns page"
onClick={() => void downloadPack()}
>
{packNote ?? "Download pack (.zip)"}
</button>
{/* 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. */}
<a
className={styles.btnLink}
href={
hydrated && playable.length > 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
</a>
</>
<button
type="button"
className={styles.btn}
disabled={playable.length === 0}
title="Get a link to this deck's pack, or download it as a .zip"
onClick={() => setShareOpen(true)}
>
Share
</button>
)}
{deck.visibility === "public" ? (
<a
className={styles.btnAccentLink}
href={
hydrated && playable.length > 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
</a>
) : (
<button
type="button"
className={styles.btnAccent}
disabled={busy || playable.length === 0}
title="Load this deck and build it as loadable modules for your board"
onClick={() => void sendToBoard()}
>
{confirmCopy ? "Press again" : "Send to my board"}
</button>
)}
{/* The deck's whole point: onto a board, in this order. Copying is
the editing gesture; this is the one it exists for. */}
<button
type="button"
className={styles.btnAccent}
disabled={busy || playable.length === 0}
title="Load this deck and build it as loadable modules for your board"
onClick={() => void sendToBoard()}
>
{confirmCopy ? "Press again" : "Send to my board"}
</button>
</div>


{deck.description && <p className={styles.metaDescription}>{deck.description}</p>}

{isOwner && (
Expand Down Expand Up @@ -474,6 +475,21 @@ export default function DeckDetailClient({
/>
)}

{shareOpen && (
<ShareDeckPackModal
packUrl={absolutePackUrl}
installUrl={
hydrated && playable.length > 0
? patternsUrl(`/api/community/decks/${deck.id}/zip`)
: null
}
onCopyLink={copyPackLink}
onDownload={() => void downloadPack()}
downloadNote={packNote}
onClose={() => setShareOpen(false)}
/>
)}

{editOpen && (
<EditDeckModal
deckId={deck.id}
Expand Down
47 changes: 7 additions & 40 deletions web/src/app/community/p/[id]/PatternDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
useEffect(() => {
const sync = () => {
setInDeck(deckHas(pattern.id));
setIsSaved(savedHas(pattern.id));
};
sync();
window.addEventListener(COLLECTION_EVENT, sync);
Expand All @@ -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<string | null>(null);

const knobSetup = useMemo(() => knobSetupFromCode(pattern.code), [pattern.code]);
Expand Down Expand Up @@ -321,17 +295,10 @@ export default function PatternDetailClient({
↗ Send to my Patternflow
</button>
)}
{/* 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. */}
<button
type="button"
className={styles.btn}
title={isSaved ? "Remove from your saved patterns" : "Save for later — no limit"}
onClick={toggleSaved}
>
{isSaved ? "★ Saved" : "☆ Save"}
</button>
{/* "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 && (
<button
type="button"
Expand Down
11 changes: 9 additions & 2 deletions web/src/app/community/patterns/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { headers } from "next/headers";
import type { Metadata } from "next";
import { getAuth } from "@/lib/community/auth";
import { communityEnabled } from "@/lib/community/db";
import { countFeed, listFeed, parseFeedSort } from "@/lib/community/queries";
import { toCardItem } from "@/lib/community/serialize";
Expand Down Expand Up @@ -38,8 +40,11 @@ export default async function CommunityWallPage(props: {
const sort = parseFeedSort(rawSort);
const hardwareOnly = hw === "1";

const session = await getAuth().api.getSession({ headers: await headers() });
const viewerId = session?.user.id ?? null;

const [items, total] = await Promise.all([
listFeed({ sort, hardwareOnly, limit: FEED_FIRST_PAINT }),
listFeed({ sort, hardwareOnly, limit: FEED_FIRST_PAINT, viewerId }),
countFeed(hardwareOnly),
]);

Expand All @@ -50,7 +55,9 @@ export default async function CommunityWallPage(props: {
items={items.map(toCardItem)}
sort={sort}
hardwareOnly={hardwareOnly}
total={total}
// The liked list is a subset, so the wall's total would overstate it.
total={sort === "liked" ? items.length : total}
signedIn={viewerId !== null}
/>
);
}
Loading
Loading