From 21104b96460891da18f3bfdee6cdf7a5047a6e82 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:28:10 +0900 Subject: [PATCH] feat(web): a deck you share is a link, not a file you re-upload The pack has been served from a stable URL since it was built, and the comment above the download button has said "this is also the link you paste somewhere" the whole time. There was no way to get it out of the page. Sharing a deck meant downloading the .zip and uploading it again wherever you were sharing it - the exact step hosting it was meant to remove. Two buttons, one for each end of that. "Copy pack link" puts the address on the clipboard and kicks the build off, so the first compile is paid by the person who chose to share rather than by the stranger who clicked their link. "Install to my board" opens the recipient's own board with the deck queued. "Send to my board" builds into THEIR OWN queue behind a sign-in, which someone arriving from a shared link has no reason to have; this needs neither, because the board fetches the pack itself. An anchor rather than a button so middle-click still works, which costs a hydration dance: patternsUrl needs the window object and answers "#" without it. The other callers only render after a build, so they are always past that - this one is in server-rendered markup, where "#" would be written into the HTML with no state change to correct it. Co-Authored-By: Claude Opus 5 --- .../app/community/d/[id]/DeckDetailClient.tsx | 94 ++++++++++++++++--- .../components/community/Community.module.css | 17 ++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index f284bb1..b986d7b 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import PatternCard from "@/components/community/PatternCard"; import ReportModal from "@/components/community/ReportModal"; @@ -18,6 +18,7 @@ import { type Visibility, } from "@/lib/community/visibility"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; +import { useDeviceHost } from "@/lib/community/deviceHost"; import type { DeckPageItem } from "@/lib/community/serialize"; import { captureEvent } from "@/lib/posthogEvents"; import styles from "@/components/community/Community.module.css"; @@ -56,6 +57,20 @@ 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 { patternsUrl } = useDeviceHost(); + + // patternsUrl needs `window` and answers "#" without it. The other callers + // only render after a build has finished, so they are always past that; + // this button is in the server-rendered markup, where "#" is what gets + // written into the HTML and hydration has no state change to correct it. + // This subscribes to nothing and only differs between server and client, + // which is precisely the re-render that fills the address in. + const hydrated = useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); const playable = items.filter((item) => item.pattern !== null); @@ -99,6 +114,33 @@ export default function DeckDetailClient({ } }; + // What a person sharing a deck actually needs: the address, not the file. + // + // The pack has lived at a stable URL since it was built, and the comment on + // the download button has said "this is also the link you paste somewhere" + // the whole time — but there was no way to get it out of the page. You + // could only download the .zip and re-upload it wherever you were sharing, + // which is the thing hosting it was supposed to remove. + // + // 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); + try { + await navigator.clipboard.writeText(new URL(packUrl, window.location.origin).toString()); + setLinkNote("Copied"); + captureEvent("deck_pack_link_copied", { deckId: deck.id }); + } catch { + setError("Could not reach the clipboard — copy the address bar link instead."); + return; + } + // Fire-and-forget: the link is already on the clipboard and works either + // way. This only decides whether the recipient waits for a compile. + void fetch(packUrl, COMMUNITY_FETCH_INIT).catch(() => {}); + setTimeout(() => setLinkNote(null), 2000); + }; + const patch = async (body: Record): Promise => { setBusy(true); setError(null); @@ -298,18 +340,46 @@ 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, - so this is also the link you paste somewhere. */} + 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 + + )} {/* The deck's whole point: onto a board, in this order. Copying is the editing gesture; this is the one it exists for. */} diff --git a/web/src/components/community/Community.module.css b/web/src/components/community/Community.module.css index d1283fa..52c948a 100644 --- a/web/src/components/community/Community.module.css +++ b/web/src/components/community/Community.module.css @@ -2838,6 +2838,23 @@ color: var(--pfc-ink-faint); } +/* An anchor that has to sit in a row of buttons and match them. It is a real + link rather than a button because it leaves for the device's own origin — + middle-click and "open in new tab" should work, and a click handler would + take those away. */ +.btnLink { + composes: btn; + display: inline-flex; + align-items: center; + text-decoration: none; + line-height: 1; +} + +.btnLink[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + /* 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