👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

use constellation for counts and social functionality

+595 -72
+182
.claude/CONSTELLATION.md
··· 1 + # Constellation API Reference 2 + 3 + Constellation is an ATProto-wide backlink indexer that tracks links between records. It's part of the [microcosm](https://microcosm.blue/) project. 4 + 5 + ## Base URL 6 + 7 + ``` 8 + https://constellation.microcosm.blue 9 + ``` 10 + 11 + ## Required Headers 12 + 13 + ```typescript 14 + headers: { 15 + "Accept": "application/json", 16 + "User-Agent": "deckbelcher.com by @aviva.gay" 17 + } 18 + ``` 19 + 20 + The `Accept` header is required for JSON responses. The `User-Agent` is a courtesy request from the maintainer. 21 + 22 + ## Endpoints 23 + 24 + ### Get Backlinks 25 + 26 + Find records that link to a target. 27 + 28 + ``` 29 + GET /xrpc/blue.microcosm.links.getBacklinks 30 + ``` 31 + 32 + **Parameters:** 33 + - `subject` (required): Target URI being linked to (e.g., `scry:uuid`, `at://did/collection/rkey`) 34 + - `source` (required): Collection and path in format `collection:path` (e.g., `com.deckbelcher.collection.list:.items[com.deckbelcher.collection.list#cardItem].ref.scryfallUri`) 35 + - `did` (optional, repeatable): Filter to specific user(s) 36 + - `limit` (optional): Max results, default 16, max 100 37 + 38 + **Response:** 39 + ```typescript 40 + { 41 + total: number; 42 + records: Array<{ 43 + uri: string; // AT URI of linking record 44 + cid: string; // CID of linking record 45 + did: string; // DID of record owner 46 + indexedAt: string; 47 + }>; 48 + cursor?: string; 49 + } 50 + ``` 51 + 52 + ### Count Links 53 + 54 + Get total count of records linking to a target. 55 + 56 + ``` 57 + GET /links/count 58 + ``` 59 + 60 + **Parameters:** 61 + - `target` (required): Target URI (URL-encoded) 62 + - `collection` (required): Collection NSID 63 + - `path` (required): JSON path (URL-encoded) 64 + 65 + **Response:** 66 + ```typescript 67 + { total: number } 68 + ``` 69 + 70 + ### Count Distinct DIDs 71 + 72 + Get count of unique users linking to a target. 73 + 74 + ``` 75 + GET /links/count/distinct-dids 76 + ``` 77 + 78 + Same parameters as `/links/count`. 79 + 80 + ## Path Syntax 81 + 82 + Constellation uses JSONPath-like notation. 83 + 84 + **IMPORTANT**: The two endpoints expect different path formats: 85 + - `getBacklinks` source: `collection:path` where path has NO leading dot 86 + - `/links/count` path: path WITH leading dot 87 + 88 + Example for the same query: 89 + - getBacklinks: `source=com.deckbelcher.collection.list:items[...].ref.oracleUri` 90 + - count: `path=.items[...].ref.oracleUri` 91 + 92 + ### Basic Paths 93 + 94 + ``` 95 + .field # Direct field 96 + .nested.field # Nested object 97 + .array[] # Array elements (no $type) 98 + .array[].nested # Nested in array 99 + ``` 100 + 101 + ### Union Array Elements ($type) 102 + 103 + **IMPORTANT**: When an array element is a union type (has `$type` field), constellation includes the type in the path: 104 + 105 + ``` 106 + .items[com.deckbelcher.collection.list#cardItem].ref.scryfallUri 107 + ``` 108 + 109 + NOT: 110 + ``` 111 + .items[].ref.scryfallUri # WRONG for union types 112 + ``` 113 + 114 + This is because constellation's link extractor (`links/src/record.rs`) uses the `$type` value when present: 115 + 116 + ```rust 117 + if let Some(JsonValue::String(t)) = o.get("$type") { 118 + format!("{path}[{t}]") // Uses $type in path 119 + } else { 120 + format!("{path}[]") // Plain array notation 121 + } 122 + ``` 123 + 124 + ## DeckBelcher-Specific Paths 125 + 126 + We use `oracleUri` for card aggregation so counts include all printings of a card. 127 + 128 + ### Cards in Collection Lists (saves/bookmarks) 129 + 130 + ``` 131 + collection: com.deckbelcher.collection.list 132 + path: .items[com.deckbelcher.collection.list#cardItem].ref.oracleUri 133 + target: oracle:<uuid> 134 + ``` 135 + 136 + ### Decks in Collection Lists 137 + 138 + ``` 139 + collection: com.deckbelcher.collection.list 140 + path: .items[com.deckbelcher.collection.list#deckItem].deckUri 141 + target: at://<did>/com.deckbelcher.deck.list/<rkey> 142 + ``` 143 + 144 + ### Cards in Deck Lists (future: "decks containing") 145 + 146 + ``` 147 + collection: com.deckbelcher.deck.list 148 + path: .cards[].ref.oracleUri 149 + target: oracle:<uuid> 150 + ``` 151 + 152 + Note: `deck.list` cards don't have `$type`, so use `[]`. 153 + 154 + ## Example Queries 155 + 156 + Check if a user saved a card (by oracle ID): 157 + ```bash 158 + curl -H "Accept: application/json" \ 159 + "https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?\ 160 + subject=oracle:1a62ba93-153c-4bed-9f6a-ff914df360c1&\ 161 + source=com.deckbelcher.collection.list:.items[com.deckbelcher.collection.list%23cardItem].ref.oracleUri&\ 162 + did=did:plc:xyz&\ 163 + limit=1" 164 + ``` 165 + 166 + Count total saves (by oracle ID): 167 + ```bash 168 + curl -H "Accept: application/json" \ 169 + "https://constellation.microcosm.blue/links/count?\ 170 + target=oracle:1a62ba93-153c-4bed-9f6a-ff914df360c1&\ 171 + collection=com.deckbelcher.collection.list&\ 172 + path=.items[com.deckbelcher.collection.list%23cardItem].ref.oracleUri" 173 + ``` 174 + 175 + ## Source Code 176 + 177 + - Repository: [at-microcosm/microcosm-rs](https://github.com/at-microcosm/microcosm-rs/tree/main/constellation) 178 + - Link extraction logic: `links/src/record.rs` 179 + 180 + ## Latency Considerations 181 + 182 + Constellation indexes records from the ATProto firehose. There may be a delay between when a record is written to a PDS and when constellation has indexed it. For optimistic UI updates, trust the PDS write succeeded rather than immediately re-querying constellation.
-66
src/components/list/SaveToListButton.tsx
··· 1 - import { useInfiniteQuery } from "@tanstack/react-query"; 2 - import { Bookmark } from "lucide-react"; 3 - import { useMemo, useState } from "react"; 4 - import { listUserCollectionListsQueryOptions } from "@/lib/collection-list-queries"; 5 - import { hasCard, hasDeck } from "@/lib/collection-list-types"; 6 - import { useAuth } from "@/lib/useAuth"; 7 - import { type SaveItem, SaveToListDialog } from "./SaveToListDialog"; 8 - 9 - interface SaveToListButtonProps { 10 - item: SaveItem; 11 - itemName?: string; 12 - className?: string; 13 - } 14 - 15 - export function SaveToListButton({ 16 - item, 17 - itemName, 18 - className = "", 19 - }: SaveToListButtonProps) { 20 - const { session } = useAuth(); 21 - const [isOpen, setIsOpen] = useState(false); 22 - 23 - const { data: listsData } = useInfiniteQuery({ 24 - ...listUserCollectionListsQueryOptions(session?.info.sub ?? ("" as never)), 25 - enabled: !!session, 26 - }); 27 - 28 - const lists = listsData?.pages.flatMap((p) => p.records) ?? []; 29 - 30 - const isSaved = useMemo(() => { 31 - return lists.some((record) => 32 - item.type === "card" 33 - ? hasCard(record.value, item.scryfallId) 34 - : hasDeck(record.value, item.deckUri), 35 - ); 36 - }, [lists, item]); 37 - 38 - if (!session) { 39 - return null; 40 - } 41 - 42 - return ( 43 - <> 44 - <button 45 - type="button" 46 - onClick={() => setIsOpen(true)} 47 - className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`} 48 - aria-label={isSaved ? "Saved to list" : "Save to list"} 49 - title={isSaved ? "Saved to list" : "Save to list"} 50 - > 51 - <Bookmark 52 - className={`w-5 h-5 ${isSaved ? "text-blue-500 dark:text-blue-400" : "text-gray-600 dark:text-gray-400"}`} 53 - fill={isSaved ? "currentColor" : "none"} 54 - /> 55 - </button> 56 - 57 - <SaveToListDialog 58 - item={item} 59 - itemName={itemName} 60 - userDid={session.info.sub} 61 - isOpen={isOpen} 62 - onClose={() => setIsOpen(false)} 63 - /> 64 - </> 65 - ); 66 - }
+25 -1
src/components/list/SaveToListDialog.tsx
··· 1 1 import type { Did } from "@atcute/lexicons"; 2 - import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 3 import { Bookmark, Loader2, Plus } from "lucide-react"; 4 4 import { useEffect, useId, useState } from "react"; 5 5 import { toast } from "sonner"; ··· 16 16 hasCard, 17 17 hasDeck, 18 18 } from "@/lib/collection-list-types"; 19 + import { getConstellationQueryKeys } from "@/lib/constellation-queries"; 19 20 import type { OracleId, ScryfallId } from "@/lib/scryfall-types"; 21 + import { toOracleUri } from "@/lib/scryfall-types"; 20 22 21 23 export type SaveItem = 22 24 | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } ··· 202 204 userDid, 203 205 onClose, 204 206 }: ListRowProps) { 207 + const queryClient = useQueryClient(); 205 208 const updateMutation = useUpdateCollectionListMutation(userDid, rkey as Rkey); 206 209 207 210 const alreadySaved = ··· 217 220 ? addCardToList(list, item.scryfallId, item.oracleId) 218 221 : addDeckToList(list, item.deckUri); 219 222 223 + const itemUri = 224 + item.type === "card" 225 + ? toOracleUri(item.oracleId) 226 + : (item.deckUri as `at://${string}`); 227 + const queryKeys = getConstellationQueryKeys(itemUri, userDid); 228 + 229 + const previousSaved = queryClient.getQueryData<boolean>( 230 + queryKeys.userSaved, 231 + ); 232 + const previousCount = queryClient.getQueryData<number>(queryKeys.saveCount); 233 + 234 + queryClient.setQueryData<boolean>(queryKeys.userSaved, true); 235 + queryClient.setQueryData<number>( 236 + queryKeys.saveCount, 237 + (old) => (old ?? 0) + 1, 238 + ); 239 + 220 240 updateMutation.mutate(updatedList, { 241 + onError: () => { 242 + queryClient.setQueryData<boolean>(queryKeys.userSaved, previousSaved); 243 + queryClient.setQueryData<number>(queryKeys.saveCount, previousCount); 244 + }, 221 245 onSuccess: () => { 222 246 const what = itemName ?? (item.type === "card" ? "Card" : "Deck"); 223 247 toast.success(`Saved ${what} to ${list.name}`);
+98
src/components/social/SocialStats.tsx
··· 1 + import { Bookmark } from "lucide-react"; 2 + import { useState } from "react"; 3 + import type { DeckItemUri } from "@/lib/constellation-queries"; 4 + import { useItemSocialStats } from "@/lib/constellation-queries"; 5 + import type { OracleId, OracleUri, ScryfallId } from "@/lib/scryfall-types"; 6 + import { toOracleUri } from "@/lib/scryfall-types"; 7 + import { useAuth } from "@/lib/useAuth"; 8 + import { SaveToListDialog } from "../list/SaveToListDialog"; 9 + 10 + export type SocialItem = 11 + | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 12 + | { type: "deck"; deckUri: DeckItemUri }; 13 + 14 + interface SocialStatsProps { 15 + item: SocialItem; 16 + itemName?: string; 17 + showCount?: boolean; 18 + className?: string; 19 + } 20 + 21 + function getItemUri(item: SocialItem): OracleUri | DeckItemUri { 22 + return item.type === "card" ? toOracleUri(item.oracleId) : item.deckUri; 23 + } 24 + 25 + export function SocialStats({ 26 + item, 27 + itemName, 28 + showCount = true, 29 + className = "", 30 + }: SocialStatsProps) { 31 + const { session } = useAuth(); 32 + const [isDialogOpen, setIsDialogOpen] = useState(false); 33 + 34 + const itemUri = getItemUri(item); 35 + const { isSavedByUser, saveCount, isLoading } = useItemSocialStats( 36 + itemUri, 37 + item.type, 38 + ); 39 + 40 + const handleClick = () => { 41 + if (session) { 42 + setIsDialogOpen(true); 43 + } 44 + }; 45 + 46 + return ( 47 + <> 48 + <button 49 + type="button" 50 + onClick={handleClick} 51 + disabled={!session} 52 + className={`flex items-center gap-1 p-2 rounded-lg transition-colors ${ 53 + session 54 + ? "hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" 55 + : "cursor-default opacity-75" 56 + } ${className}`} 57 + aria-label={isSavedByUser ? "Saved to list" : "Save to list"} 58 + title={ 59 + session 60 + ? isSavedByUser 61 + ? "Saved to list" 62 + : "Save to list" 63 + : "Sign in to save" 64 + } 65 + > 66 + <Bookmark 67 + className={`w-5 h-5 ${ 68 + isSavedByUser 69 + ? "text-blue-500 dark:text-blue-400" 70 + : "text-gray-600 dark:text-gray-400" 71 + }`} 72 + fill={isSavedByUser ? "currentColor" : "none"} 73 + /> 74 + {showCount && ( 75 + <span 76 + className={`text-sm tabular-nums ${isLoading ? "opacity-50" : ""} ${ 77 + isSavedByUser 78 + ? "text-blue-500 dark:text-blue-400" 79 + : "text-gray-600 dark:text-gray-400" 80 + }`} 81 + > 82 + {saveCount} 83 + </span> 84 + )} 85 + </button> 86 + 87 + {session && ( 88 + <SaveToListDialog 89 + item={item} 90 + itemName={itemName} 91 + userDid={session.info.sub} 92 + isOpen={isDialogOpen} 93 + onClose={() => setIsDialogOpen(false)} 94 + /> 95 + )} 96 + </> 97 + ); 98 + }
+141
src/lib/constellation-client.ts
··· 1 + /** 2 + * API client for Constellation backlink indexer 3 + * See .claude/CONSTELLATION.md for full API documentation 4 + */ 5 + 6 + import type { Result } from "./atproto-client"; 7 + 8 + const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 9 + export const MICROCOSM_USER_AGENT = "deckbelcher.com by @aviva.gay"; 10 + 11 + // Path constants for DeckBelcher collections 12 + // Constellation includes $type in paths for union array elements 13 + // Use oracleUri for card aggregation (counts across printings) 14 + export const COLLECTION_LIST_CARD_PATH = 15 + ".items[com.deckbelcher.collection.list#cardItem].ref.oracleUri"; 16 + export const COLLECTION_LIST_DECK_PATH = 17 + ".items[com.deckbelcher.collection.list#deckItem].deckUri"; 18 + // Future: cards in decks (also uses oracleUri for aggregation) 19 + export const DECK_LIST_CARD_PATH = ".cards[].ref.oracleUri"; 20 + 21 + export const COLLECTION_LIST_NSID = "com.deckbelcher.collection.list"; 22 + export const DECK_LIST_NSID = "com.deckbelcher.deck.list"; 23 + 24 + export interface BacklinkRecord { 25 + uri: string; 26 + cid: string; 27 + did: string; 28 + indexedAt: string; 29 + } 30 + 31 + export interface BacklinksResponse { 32 + total: number; 33 + records: BacklinkRecord[]; 34 + cursor?: string; 35 + } 36 + 37 + export interface CountResponse { 38 + total: number; 39 + } 40 + 41 + export interface GetBacklinksParams { 42 + subject: string; 43 + source: string; 44 + did?: string; 45 + limit?: number; 46 + } 47 + 48 + export interface GetLinksCountParams { 49 + target: string; 50 + collection: string; 51 + path: string; 52 + } 53 + 54 + /** 55 + * Get records that link to a target 56 + */ 57 + export async function getBacklinks( 58 + params: GetBacklinksParams, 59 + ): Promise<Result<BacklinksResponse>> { 60 + try { 61 + const url = new URL( 62 + `${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinks`, 63 + ); 64 + url.searchParams.set("subject", params.subject); 65 + url.searchParams.set("source", params.source); 66 + if (params.did) { 67 + url.searchParams.set("did", params.did); 68 + } 69 + if (params.limit !== undefined) { 70 + url.searchParams.set("limit", String(params.limit)); 71 + } 72 + 73 + const response = await fetch(url.toString(), { 74 + headers: { 75 + Accept: "application/json", 76 + "User-Agent": MICROCOSM_USER_AGENT, 77 + }, 78 + }); 79 + 80 + if (!response.ok) { 81 + return { 82 + success: false, 83 + error: new Error(`Constellation API error: ${response.statusText}`), 84 + }; 85 + } 86 + 87 + const data = (await response.json()) as BacklinksResponse; 88 + return { success: true, data }; 89 + } catch (error) { 90 + return { 91 + success: false, 92 + error: error instanceof Error ? error : new Error(String(error)), 93 + }; 94 + } 95 + } 96 + 97 + /** 98 + * Get count of records linking to a target 99 + */ 100 + export async function getLinksCount( 101 + params: GetLinksCountParams, 102 + ): Promise<Result<CountResponse>> { 103 + try { 104 + const url = new URL(`${CONSTELLATION_BASE}/links/count`); 105 + url.searchParams.set("target", params.target); 106 + url.searchParams.set("collection", params.collection); 107 + url.searchParams.set("path", params.path); 108 + 109 + const response = await fetch(url.toString(), { 110 + headers: { 111 + Accept: "application/json", 112 + "User-Agent": MICROCOSM_USER_AGENT, 113 + }, 114 + }); 115 + 116 + if (!response.ok) { 117 + return { 118 + success: false, 119 + error: new Error(`Constellation API error: ${response.statusText}`), 120 + }; 121 + } 122 + 123 + const data = (await response.json()) as CountResponse; 124 + return { success: true, data }; 125 + } catch (error) { 126 + return { 127 + success: false, 128 + error: error instanceof Error ? error : new Error(String(error)), 129 + }; 130 + } 131 + } 132 + 133 + /** 134 + * Build the source string for getBacklinks 135 + * Format: collection:path (without leading dot) 136 + * Note: getBacklinks expects path WITHOUT leading dot, but /links/count expects WITH leading dot 137 + */ 138 + export function buildSource(collection: string, path: string): string { 139 + const pathWithoutDot = path.startsWith(".") ? path.slice(1) : path; 140 + return `${collection}:${pathWithoutDot}`; 141 + }
+138
src/lib/constellation-queries.ts
··· 1 + /** 2 + * TanStack Query integration for Constellation backlink queries 3 + */ 4 + 5 + import type { Did } from "@atcute/lexicons"; 6 + import { queryOptions, useQuery } from "@tanstack/react-query"; 7 + import { 8 + buildSource, 9 + COLLECTION_LIST_CARD_PATH, 10 + COLLECTION_LIST_DECK_PATH, 11 + COLLECTION_LIST_NSID, 12 + getBacklinks, 13 + getLinksCount, 14 + } from "./constellation-client"; 15 + import type { OracleUri } from "./scryfall-types"; 16 + import { useAuth } from "./useAuth"; 17 + 18 + /** 19 + * Item types that can have social stats 20 + */ 21 + export type SocialItemType = "card" | "deck"; 22 + 23 + /** 24 + * URI types for each item type 25 + * - Cards use oracle:<uuid> URIs (aggregates across printings) 26 + * - Decks use at://<did>/com.deckbelcher.deck.list/<rkey> URIs 27 + */ 28 + export type CardItemUri = OracleUri; 29 + export type DeckItemUri = `at://${string}`; 30 + export type SocialItemUri = CardItemUri | DeckItemUri; 31 + 32 + function getPathForItemType(itemType: SocialItemType): string { 33 + return itemType === "card" 34 + ? COLLECTION_LIST_CARD_PATH 35 + : COLLECTION_LIST_DECK_PATH; 36 + } 37 + 38 + /** 39 + * Query options for checking if current user has saved an item to any list 40 + */ 41 + export function userSavedItemQueryOptions<T extends SocialItemType>( 42 + itemUri: T extends "card" ? CardItemUri : DeckItemUri, 43 + userDid: Did | undefined, 44 + itemType: T, 45 + ) { 46 + return queryOptions({ 47 + queryKey: ["constellation", "userSaved", itemUri, userDid] as const, 48 + queryFn: async (): Promise<boolean> => { 49 + if (!userDid) return false; 50 + 51 + const result = await getBacklinks({ 52 + subject: itemUri, 53 + source: buildSource(COLLECTION_LIST_NSID, getPathForItemType(itemType)), 54 + did: userDid, 55 + limit: 1, 56 + }); 57 + 58 + if (!result.success) { 59 + throw result.error; 60 + } 61 + 62 + return result.data.records.length > 0; 63 + }, 64 + enabled: !!userDid, 65 + staleTime: 30 * 1000, 66 + }); 67 + } 68 + 69 + /** 70 + * Query options for getting total save count for an item 71 + */ 72 + export function itemSaveCountQueryOptions<T extends SocialItemType>( 73 + itemUri: T extends "card" ? CardItemUri : DeckItemUri, 74 + itemType: T, 75 + ) { 76 + return queryOptions({ 77 + queryKey: ["constellation", "saveCount", itemUri] as const, 78 + queryFn: async (): Promise<number> => { 79 + const result = await getLinksCount({ 80 + target: itemUri, 81 + collection: COLLECTION_LIST_NSID, 82 + path: getPathForItemType(itemType), 83 + }); 84 + 85 + if (!result.success) { 86 + throw result.error; 87 + } 88 + 89 + return result.data.total; 90 + }, 91 + staleTime: 60 * 1000, 92 + }); 93 + } 94 + 95 + export interface ItemSocialStats { 96 + isSavedByUser: boolean; 97 + saveCount: number; 98 + isLoading: boolean; 99 + isSavedLoading: boolean; 100 + isCountLoading: boolean; 101 + } 102 + 103 + /** 104 + * Combined hook for item social stats (save state + count) 105 + */ 106 + export function useItemSocialStats<T extends SocialItemType>( 107 + itemUri: T extends "card" ? CardItemUri : DeckItemUri, 108 + itemType: T, 109 + ): ItemSocialStats { 110 + const { session } = useAuth(); 111 + 112 + const savedQuery = useQuery( 113 + userSavedItemQueryOptions(itemUri, session?.info.sub, itemType), 114 + ); 115 + 116 + const countQuery = useQuery(itemSaveCountQueryOptions(itemUri, itemType)); 117 + 118 + return { 119 + isSavedByUser: savedQuery.data ?? false, 120 + saveCount: countQuery.data ?? 0, 121 + isLoading: savedQuery.isLoading || countQuery.isLoading, 122 + isSavedLoading: savedQuery.isLoading, 123 + isCountLoading: countQuery.isLoading, 124 + }; 125 + } 126 + 127 + /** 128 + * Get query keys for cache invalidation/optimistic updates 129 + */ 130 + export function getConstellationQueryKeys( 131 + itemUri: SocialItemUri, 132 + userDid?: Did, 133 + ) { 134 + return { 135 + userSaved: ["constellation", "userSaved", itemUri, userDid] as const, 136 + saveCount: ["constellation", "saveCount", itemUri] as const, 137 + }; 138 + }
+7 -1
src/lib/ufos-queries.ts
··· 6 6 import { queryOptions } from "@tanstack/react-query"; 7 7 import type { Result } from "./atproto-client"; 8 8 import { transformListRecord } from "./collection-list-queries"; 9 + import { MICROCOSM_USER_AGENT } from "./constellation-client"; 9 10 import { transformDeckRecord } from "./deck-queries"; 10 11 import type { 11 12 ComDeckbelcherCollectionList, ··· 37 38 url.searchParams.set("collection", collectionParam); 38 39 url.searchParams.set("limit", String(limit)); 39 40 40 - const response = await fetch(url.toString()); 41 + const response = await fetch(url.toString(), { 42 + headers: { 43 + Accept: "application/json", 44 + "User-Agent": MICROCOSM_USER_AGENT, 45 + }, 46 + }); 41 47 42 48 if (!response.ok) { 43 49 return {
+2 -2
src/routes/card/$id.tsx
··· 2 2 import { createFileRoute, Link } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import { CardImage } from "@/components/CardImage"; 5 - import { SaveToListButton } from "@/components/list/SaveToListButton"; 6 5 import { ManaCost } from "@/components/ManaCost"; 7 6 import { OracleText } from "@/components/OracleText"; 7 + import { SocialStats } from "@/components/social/SocialStats"; 8 8 import { getAllFaces } from "@/lib/card-faces"; 9 9 import { FORMAT_GROUPS } from "@/lib/format-utils"; 10 10 import { ··· 472 472 )} 473 473 </div> 474 474 {cardId && oracleId && ( 475 - <SaveToListButton 475 + <SocialStats 476 476 item={{ type: "card", scryfallId: cardId, oracleId }} 477 477 itemName={face.name} 478 478 />
+2 -2
src/routes/profile/$did/deck/$rkey/index.tsx
··· 20 20 import { StatsCardList } from "@/components/deck/stats/StatsCardList"; 21 21 import { TrashDropZone } from "@/components/deck/TrashDropZone"; 22 22 import { ViewControls } from "@/components/deck/ViewControls"; 23 - import { SaveToListButton } from "@/components/list/SaveToListButton"; 24 23 import { RichtextSection } from "@/components/richtext/RichtextSection"; 24 + import { SocialStats } from "@/components/social/SocialStats"; 25 25 import { asRkey } from "@/lib/atproto-client"; 26 26 import { prefetchCards } from "@/lib/card-prefetch"; 27 27 import { getDeckQueryOptions, useUpdateDeckMutation } from "@/lib/deck-queries"; ··· 582 582 onCardsChanged={handleCardsChanged} 583 583 readOnly={!isOwner} 584 584 /> 585 - <SaveToListButton 585 + <SocialStats 586 586 item={{ 587 587 type: "deck", 588 588 deckUri: `at://${did}/com.deckbelcher.deck.list/${rkey}`,