diff --git a/README.md b/README.md index 1fef38b..2fc147c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ CriProx brings the fiddly parts of a playtest-card workflow into one focused des | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 🔒 **Local-first** | Projects, imported artwork, and autosaves stay on your device. No account or hosted backend. | | 📐 **Physical dimensions** | Millimeter-based geometry, standard 63 × 88 mm cards, matched PNG/SVG output, and 300–1200 DPI export. | -| 🃏 **Flexible artwork** | Search cards, browse sets or commander precons, choose Scryfall printings or MPC Autofill community art, or use local images. | +| 🃏 **Flexible artwork** | Search cards, browse sets, commander precons, or tokens, choose Scryfall printings or MPC Autofill community art, or use local images. | | ✨ **Optional upscale** | Choose high-detail local 4× enhancement for Scryfall card images when preparing a print PDF; ordinary exports use the originals. | | ✂️ **Reusable cuts** | Capture a Design Space template once, print CriProx PDFs from a PDF application, and cut with the same saved project. | | ✅ **Compact print workflow** | Completed setup and settings collapse into summaries with green checks. Reopen any section whenever you need it. | @@ -49,7 +49,7 @@ CriProx brings the fiddly parts of a playtest-card workflow into one focused des ## From card list to cut -1. **Build the sheet.** Import a Moxfield or Archidekt link, paste a deck list, search cards, browse a set or commander precon, or add local artwork. +1. **Build the sheet.** Import a Moxfield or Archidekt link, paste a deck list, search cards, browse sets, commander precons, or tokens, or add local artwork. 2. **Dial in the output.** Choose the machine, paper, layout, resolution, bleed, and optional card backs. Edit your preferred setup directly in Settings and save it for future projects, or also apply it to the current deck. 3. **Set up the cut template once.** In Create print PDF, download the setup PNG. Upload it to Design Space as a flat Print Then Cut image, preserve the supplied dimensions, and save the project and mat arrangement. 4. **Capture the template once.** Save that Design Space job's print output as a one-page PDF and import it into CriProx to verify and save its registration marks. The setup sections collapse with green checks; choose Done for reviewed settings, and reopen any section from its header. diff --git a/docs/CRICUT-WORKFLOW.md b/docs/CRICUT-WORKFLOW.md index 541c9ea..044f74f 100644 --- a/docs/CRICUT-WORKFLOW.md +++ b/docs/CRICUT-WORKFLOW.md @@ -46,6 +46,23 @@ day. Availability follows the source catalog rather than a fixed bundled list. ![Find a card showing commander precon contents and quantities](assets/criprox-precon-browser.png) +## Add tokens to a sheet + +1. Open **Find a card → Tokens** to browse paper token printings, starting with the newest. +2. Enter a token name such as `Treasure` or `Goblin`, optionally enter a token set code such as + `TMH2`, and choose **Search tokens**. Leave both filters empty to browse all tokens. + Token set codes can differ from the main set code; **Browse sets** lists their names and codes. +3. Choose **Sort tokens** and **Direction** to sort by release date, name, set/collector number, + color, or artist. **Load more tokens** continues through the matching printings in that order. +4. Choose **Add card** under the artwork you want. Repeated additions increase that printing's + quantity; double-faced tokens retain their matching reverse artwork. Choose **Done** to review + your sheet and prepare the print PDF. Tokens share the 500-card project limit with other cards. + +Token browsing needs an internet connection on first use. Successful searches are cached for one +day, and previews use the same image cache as card and set searches. + +![Find a card showing the Tokens category, name and set filters, and token artwork](assets/criprox-token-browser.png) + ## Understand the integration boundary CriProx does **not** generate or reproduce Cricut registration marks, create native Design Space project files, or control the machine. Design Space creates the sensor marks and owns the actual Print Then Cut job. CriProx's registered-print workflow preserves marks from a PDF that Design Space produced for the exact saved job. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 859a06a..c7690b0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -19,6 +19,12 @@ quantities, including basic lands. Tokens, display commanders, and other supplemental cards are excluded. Whole-deck additions require every printing to have artwork and enough room within the 500-card project limit; unavailable printings are listed with a retry option. +- Choose **Find a card → Tokens** to browse paper token printings, newest first by default. + Search by token name, optionally narrow to a token set code such as `TMH2`, or leave both + filters empty to browse all tokens. Sort by release date, name, set/collector number, color, + or artist in either direction; **Load more tokens** continues in that order. +- Add the exact token artwork shown, including double-faced tokens with their matching reverse + artwork. Repeated additions increase that printing's quantity within the 500-card project limit. - Start with four example Scryfall card records in a new workspace. - Search loaded cards and filter printings by set and collector number. - Select either face of a double-faced card from the inspector. diff --git a/docs/assets/criprox-token-browser.png b/docs/assets/criprox-token-browser.png new file mode 100644 index 0000000..0e87d2e Binary files /dev/null and b/docs/assets/criprox-token-browser.png differ diff --git a/src/App.tsx b/src/App.tsx index d288947..db6ae1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import SettingsModal from './components/SettingsModal'; import CardSearchResults from './components/CardSearchResults'; import SetBrowser from './components/SetBrowser'; import PreconBrowser from './components/PreconBrowser'; +import TokenBrowser from './components/TokenBrowser'; import type { PreconEntry } from './lib/precons'; import { useCallback, @@ -685,7 +686,7 @@ function CardSearchModal({ remaining: number; close: () => void; }) { - const [mode, setMode] = useState<'search' | 'sets' | 'precons'>('search'); + const [mode, setMode] = useState<'search' | 'sets' | 'precons' | 'tokens'>('search'); const searchInput = useRef(null); const [query, setQuery] = useState(''), [cards, setCards] = useState([]), @@ -763,7 +764,7 @@ function CardSearchModal({ className="card-search-modal" initialFocus={searchInput} title="Find a card" - subtitle="Search cards and tokens, browse sets, or explore commander precon decks." + subtitle="Search cards, browse sets or tokens, or explore commander precon decks." close={() => { if (!busy) close(); }} @@ -794,6 +795,14 @@ function CardSearchModal({ > Commander precons + {Object.values(added).reduce((sum, count) => sum + count, 0) > 0 && (
@@ -806,6 +815,8 @@ function CardSearchModal({ ) : mode === 'precons' ? ( + ) : mode === 'tokens' ? ( + ) : ( <>
diff --git a/src/components/TokenBrowser.tsx b/src/components/TokenBrowser.tsx new file mode 100644 index 0000000..26c3c39 --- /dev/null +++ b/src/components/TokenBrowser.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; +import { Grid2X2, LoaderCircle, Search } from 'lucide-react'; +import { + searchTokens, + TOKEN_CARD_SORTS, + type SetCardDirection, + type TokenCardSort, +} from '../lib/scryfall'; +import type { Card } from '../lib/types'; +import CardSearchResults, { type CardSearchResultsProps } from './CardSearchResults'; + +export default function TokenBrowser(props: Omit) { + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const [filter, setFilter] = useState({ name: '', code: '' }); + const [order, setOrder] = useState('released'); + const [direction, setDirection] = useState('desc'); + const [cards, setCards] = useState([]); + const [total, setTotal] = useState(0); + const [next, setNext] = useState(); + const [busy, setBusy] = useState('Loading tokens…'); + const [error, setError] = useState(''); + const [retry, setRetry] = useState(0); + const generation = useRef(0); + + useEffect(() => { + const current = ++generation.current; + setCards([]); + setTotal(0); + setNext(undefined); + setError(''); + setBusy('Loading tokens…'); + void searchTokens(filter.name, filter.code, order, direction) + .then((result) => { + if (generation.current !== current) return; + setCards(result.cards); + setTotal(result.total); + setNext(result.next); + }) + .catch((reason) => { + if (generation.current === current) + setError(reason instanceof Error ? reason.message : 'Could not load tokens. Try again.'); + }) + .finally(() => { + if (generation.current === current) setBusy(''); + }); + return () => { + ++generation.current; + }; + }, [filter, order, direction, retry]); + + async function loadMore() { + if (!next || busy) return; + const current = generation.current; + setBusy('Loading more tokens…'); + setError(''); + try { + const result = await searchTokens(filter.name, filter.code, order, direction, next); + if (generation.current !== current) return; + setCards((previous) => [ + ...previous, + ...result.cards.filter((card) => !previous.some((item) => item.id === card.id)), + ]); + setNext(result.next); + } catch (reason) { + if (generation.current === current) + setError(reason instanceof Error ? reason.message : 'Could not load tokens. Try again.'); + } finally { + if (generation.current === current) setBusy(''); + } + } + + return ( +
+ { + event.preventDefault(); + setFilter({ name: name.trim(), code: code.trim() }); + }} + > +
+ + setName(event.target.value)} + /> +
+
+ + setCode(event.target.value)} + /> +
+ + +
+ Browse paper token printings. Leave both filters empty to see all tokens. +
+
+ + +
+
+ {!busy && !error && ( + + {cards.length} of {total} token printings shown + + )} +
+ {error && ( +
+ {error} + {!next && ( + + )} +
+ )} + {busy && ( +
+ {busy} +
+ )} + {!busy && !error && !cards.length && ( +
+ + No matching tokens + Try another token name or token set code, or clear both filters. +
+ )} + + {next && !busy && ( + + )} +
+ ); +} diff --git a/src/lib/scryfall.ts b/src/lib/scryfall.ts index fa4276b..faf3c0c 100644 --- a/src/lib/scryfall.ts +++ b/src/lib/scryfall.ts @@ -271,6 +271,42 @@ export const SET_CARD_SORTS = [ export type SetCardSort = (typeof SET_CARD_SORTS)[number]['value']; export type SetCardDirection = 'asc' | 'desc'; +export const TOKEN_CARD_SORTS = [ + { value: 'released', label: 'Release date' }, + { value: 'name', label: 'Name' }, + { value: 'set', label: 'Set / collector number' }, + { value: 'color', label: 'Color' }, + { value: 'artist', label: 'Artist' }, +] as const; +export type TokenCardSort = (typeof TOKEN_CARD_SORTS)[number]['value']; + +export function scryfallTokenSearchPath( + name = '', + code = '', + order: TokenCardSort = 'released', + direction: SetCardDirection = 'desc', +) { + const normalized = code.trim().toLowerCase(); + if (normalized && !/^[a-z0-9]+$/.test(normalized)) + throw new Error('Enter a valid token set code, such as TMH2.'); + const escapedName = name.trim().replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const query = `is:token game:paper${escapedName ? ` name:"${escapedName}"` : ''}${normalized ? ` set:${normalized}` : ''}`; + return `/cards/search?q=${encodeURIComponent(query)}&unique=prints&order=${order}&dir=${direction}&include_extras=true&include_variations=true`; +} + +export async function searchTokens( + name = '', + code = '', + order: TokenCardSort = 'released', + direction: SetCardDirection = 'desc', + next?: string, +) { + const path = next + ? new URL(next).pathname + new URL(next).search + : scryfallTokenSearchPath(name, code, order, direction); + return searchPrintings(path, 'token-cards:v1', !!next); +} + export async function listSets(): Promise { const key = 'scryfall-sets:v1'; const cached = await Promise.resolve() @@ -303,7 +339,15 @@ export async function searchSetCards( const path = next ? new URL(next).pathname + new URL(next).search : scryfallSetSearchPath(code, order, direction); - const key = `set-cards:v1:${path}`; + return searchPrintings(path, 'set-cards:v1', !!next); +} + +async function searchPrintings( + path: string, + cachePrefix: string, + isNextPage: boolean, +): Promise<{ cards: Card[]; total: number; next?: string }> { + const key = `${cachePrefix}:${path}`; const cached = await Promise.resolve() .then(() => get<{ time: number; data: Collection }>(key)) .catch(() => undefined); @@ -312,8 +356,8 @@ export async function searchSetCards( result = cached && Date.now() - cached.time < 86400000 ? cached.data : await request(path); } catch (error) { - // Announced sets can exist in the catalog before any paper cards are searchable. - if (!next && error instanceof ScryfallRequestError && error.status === 404) + // Scryfall returns 404 for searches without matches, including announced sets. + if (!isNextPage && error instanceof ScryfallRequestError && error.status === 404) return { cards: [], total: 0 }; throw error; } diff --git a/tests/token-browser.test.ts b/tests/token-browser.test.ts new file mode 100644 index 0000000..49239c2 --- /dev/null +++ b/tests/token-browser.test.ts @@ -0,0 +1,105 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { searchTokens, scryfallTokenSearchPath } from '../src/lib/scryfall'; + +test('token browsing starts with all paper printings and safely scopes optional filters', () => { + const all = new URL(scryfallTokenSearchPath(), 'https://api.scryfall.com'); + assert.equal(all.searchParams.get('q'), 'is:token game:paper'); + assert.equal(all.searchParams.get('unique'), 'prints'); + assert.equal(all.searchParams.get('order'), 'released'); + assert.equal(all.searchParams.get('dir'), 'desc'); + assert.equal(all.searchParams.get('include_extras'), 'true'); + assert.equal(all.searchParams.get('include_variations'), 'true'); + const filtered = new URL( + scryfallTokenSearchPath(' Treasure ', ' TMH2 ', 'artist', 'asc'), + 'https://api.scryfall.com', + ); + assert.equal(filtered.searchParams.get('q'), 'is:token game:paper name:"Treasure" set:tmh2'); + assert.equal(filtered.searchParams.get('order'), 'artist'); + assert.equal(filtered.searchParams.get('dir'), 'asc'); + const escaped = new URL( + scryfallTokenSearchPath('" OR game:arena \\'), + 'https://api.scryfall.com', + ); + assert.equal(escaped.searchParams.get('q'), 'is:token game:paper name:"\\" OR game:arena \\\\"'); + assert.throws(() => scryfallTokenSearchPath('', 'tmh2 OR game:arena'), /valid token set code/); +}); + +const token = (id: string, collector: string) => ({ + id, + oracle_id: 'treasure-oracle', + name: 'Treasure', + layout: 'token', + set: 'tmh2', + set_name: 'Modern Horizons 2 Tokens', + collector_number: collector, + image_uris: { + png: `https://cards.scryfall.io/${id}.png`, + normal: `https://cards.scryfall.io/${id}.jpg`, + }, +}); + +test('token pagination preserves different artwork and double-faced tokens in server order', async (t) => { + const next = `https://api.scryfall.com${scryfallTokenSearchPath('Treasure', '', 'name', 'asc')}&page=2`; + const urls: string[] = []; + t.mock.method(globalThis, 'fetch', async (url: string) => { + urls.push(url); + return Response.json( + urls.length === 1 + ? { + data: [ + token('art-1', '1'), + token('art-2', '2'), + { ...token('no-art', '3'), image_uris: undefined }, + ], + total_cards: 4, + has_more: true, + next_page: next, + } + : { + data: [ + { + ...token('double', '4'), + layout: 'double_faced_token', + image_uris: undefined, + card_faces: [ + { name: 'Treasure', image_uris: token('front', '4').image_uris }, + { name: 'Goblin', image_uris: token('back', '4').image_uris }, + ], + }, + ], + total_cards: 4, + has_more: false, + }, + ); + }); + const first = await searchTokens('Treasure', '', 'name', 'asc'); + assert.deepEqual( + first.cards.map((card) => card.id), + ['art-1', 'art-2'], + ); + assert.equal(first.total, 4); + assert.equal(first.next, next); + const second = await searchTokens('Treasure', '', 'name', 'asc', first.next); + assert.equal(urls[1], next); + assert.equal(second.cards[0].kind, 'token'); + assert.equal(second.cards[0].faces[1].name, 'Goblin'); + assert.equal(second.next, undefined); +}); + +test('unmatched token filters are empty while service and pagination errors remain retryable', async (t) => { + let status = 404; + t.mock.method(globalThis, 'fetch', async () => + Response.json({ details: 'Unavailable' }, { status }), + ); + assert.deepEqual(await searchTokens('No such token'), { cards: [], total: 0 }); + status = 503; + await assert.rejects(searchTokens(), /Unavailable/); + status = 429; + await assert.rejects(searchTokens(), /rate limiting/); + status = 404; + await assert.rejects( + searchTokens('', '', 'released', 'desc', 'https://api.scryfall.com/cards/search?page=2'), + /Unavailable/, + ); +});