👁️
5
fork

Configure Feed

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

mutation for deck save, allow toggling card in save to list view

+245 -55
+23 -49
src/components/list/SaveToListDialog.tsx
··· 2 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 - import { toast } from "sonner"; 6 5 import type { Rkey } from "@/lib/atproto-client"; 7 6 import { 8 7 listUserCollectionListsQueryOptions, 9 8 useCreateCollectionListMutation, 10 - useUpdateCollectionListMutation, 9 + useToggleListItemMutation, 11 10 } from "@/lib/collection-list-queries"; 12 11 import { 13 - addCardToList, 14 - addDeckToList, 15 12 type CollectionList, 16 13 hasCard, 17 14 hasDeck, ··· 223 220 userDid, 224 221 onClose, 225 222 }: ListRowProps) { 226 - const queryClient = useQueryClient(); 227 - const updateMutation = useUpdateCollectionListMutation(userDid, rkey as Rkey); 223 + const toggleMutation = useToggleListItemMutation(userDid, rkey as Rkey); 228 224 229 - const alreadySaved = 225 + const isSaved = 230 226 item.type === "card" 231 227 ? hasCard(list, item.scryfallId) 232 228 : hasDeck(list, item.deckUri); 233 229 234 230 const handleClick = () => { 235 - if (alreadySaved) return; 236 - 237 - const updatedList = 238 - item.type === "card" 239 - ? addCardToList(list, item.scryfallId, item.oracleId) 240 - : addDeckToList(list, item.deckUri); 241 - 242 - const itemUri = 243 - item.type === "card" 244 - ? toOracleUri(item.oracleId) 245 - : (item.deckUri as `at://${string}`); 246 - const queryKeys = getConstellationQueryKeys(itemUri, userDid); 247 - 248 - const previousSaved = queryClient.getQueryData<boolean>( 249 - queryKeys.userSaved, 250 - ); 251 - const previousCount = queryClient.getQueryData<number>(queryKeys.saveCount); 252 - 253 - queryClient.setQueryData<boolean>(queryKeys.userSaved, true); 254 - queryClient.setQueryData<number>( 255 - queryKeys.saveCount, 256 - (old) => (old ?? 0) + 1, 257 - ); 258 - 259 - updateMutation.mutate(updatedList, { 260 - onError: () => { 261 - queryClient.setQueryData<boolean>(queryKeys.userSaved, previousSaved); 262 - queryClient.setQueryData<number>(queryKeys.saveCount, previousCount); 263 - }, 264 - onSuccess: () => { 265 - const what = itemName ?? (item.type === "card" ? "Card" : "Deck"); 266 - toast.success(`Saved ${what} to ${list.name}`); 267 - onClose(); 268 - }, 269 - }); 231 + const isAdding = !isSaved; 232 + toggleMutation.mutate({ list, item, itemName }); 233 + if (isAdding) { 234 + onClose(); 235 + } 270 236 }; 271 237 272 238 return ( 273 239 <button 274 240 type="button" 275 241 onClick={handleClick} 276 - disabled={alreadySaved || updateMutation.isPending} 277 - className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:hover:bg-gray-50 dark:disabled:hover:bg-slate-800 rounded-lg transition-colors disabled:cursor-not-allowed" 242 + disabled={toggleMutation.isPending} 243 + className={`w-full flex items-center justify-between px-4 py-3 rounded-lg transition-colors ${ 244 + isSaved 245 + ? "bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30" 246 + : "bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700" 247 + }`} 278 248 > 279 - <span className="font-medium text-gray-900 dark:text-white"> 249 + <span 250 + className={`font-medium ${isSaved ? "text-blue-700 dark:text-blue-300" : "text-gray-900 dark:text-white"}`} 251 + > 280 252 {list.name} 281 253 </span> 282 - <span className="text-sm text-gray-500 dark:text-gray-400"> 283 - {alreadySaved ? ( 284 - "Already saved" 285 - ) : updateMutation.isPending ? ( 254 + <span 255 + className={`text-sm ${isSaved ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"}`} 256 + > 257 + {toggleMutation.isPending ? ( 286 258 <Loader2 className="w-4 h-4 animate-spin" /> 259 + ) : isSaved ? ( 260 + "Saved" 287 261 ) : ( 288 262 `${list.items.length} items` 289 263 )}
+208
src/lib/collection-list-queries.ts
··· 22 22 updateCollectionListRecord, 23 23 } from "./atproto-client"; 24 24 import { 25 + addCardToList, 26 + addDeckToList, 25 27 type CollectionList, 28 + hasCard, 29 + hasDeck, 26 30 isCardItem, 27 31 isDeckItem, 28 32 type ListItem, 33 + removeCardFromList, 34 + removeDeckFromList, 29 35 type SaveItem, 30 36 } from "./collection-list-types"; 37 + import { getConstellationQueryKeys } from "./constellation-queries"; 31 38 import { getPdsForDid } from "./identity"; 32 39 import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 33 40 import { ··· 358 365 errorMessage: "Failed to delete list", 359 366 }); 360 367 } 368 + 369 + interface ToggleListItemParams { 370 + list: CollectionList; 371 + item: SaveItem; 372 + itemName?: string; 373 + } 374 + 375 + /** 376 + * Mutation for toggling an item in a collection list (add/remove) 377 + * Handles optimistic updates for constellation queries 378 + */ 379 + export function useToggleListItemMutation(did: Did, rkey: Rkey) { 380 + const { agent } = useAuth(); 381 + const queryClient = useQueryClient(); 382 + 383 + return useMutationWithToast({ 384 + mutationFn: async ({ list, item }: ToggleListItemParams) => { 385 + if (!agent) { 386 + throw new Error("Must be authenticated to update a list"); 387 + } 388 + 389 + const isSaved = 390 + item.type === "card" 391 + ? hasCard(list, item.scryfallId) 392 + : hasDeck(list, item.deckUri); 393 + 394 + const updatedList = isSaved 395 + ? item.type === "card" 396 + ? removeCardFromList(list, item.scryfallId) 397 + : removeDeckFromList(list, item.deckUri) 398 + : item.type === "card" 399 + ? addCardToList(list, item.scryfallId, item.oracleId) 400 + : addDeckToList(list, item.deckUri); 401 + 402 + const result = await updateCollectionListRecord(agent, rkey, { 403 + $type: "com.deckbelcher.collection.list", 404 + name: updatedList.name, 405 + description: updatedList.description, 406 + items: updatedList.items.map((listItem) => { 407 + if (isCardItem(listItem)) { 408 + const { scryfallId, oracleId, ...rest } = listItem; 409 + const mapped = { 410 + ...rest, 411 + ref: { 412 + scryfallUri: toScryfallUri(scryfallId), 413 + oracleUri: toOracleUri(oracleId), 414 + }, 415 + }; 416 + assertHasType(mapped); 417 + return mapped; 418 + } 419 + if (isDeckItem(listItem)) { 420 + assertHasType(listItem); 421 + return listItem; 422 + } 423 + throw new Error( 424 + `Unknown list item type: ${(listItem as { $type?: string }).$type}`, 425 + ); 426 + }), 427 + createdAt: updatedList.createdAt, 428 + updatedAt: new Date().toISOString(), 429 + }); 430 + 431 + if (!result.success) { 432 + throw result.error; 433 + } 434 + 435 + return { ...result.data, wasSaved: isSaved }; 436 + }, 437 + onMutate: async ({ list, item }: ToggleListItemParams) => { 438 + const isSaved = 439 + item.type === "card" 440 + ? hasCard(list, item.scryfallId) 441 + : hasDeck(list, item.deckUri); 442 + 443 + // Compute updated list optimistically 444 + const updatedList = isSaved 445 + ? item.type === "card" 446 + ? removeCardFromList(list, item.scryfallId) 447 + : removeDeckFromList(list, item.deckUri) 448 + : item.type === "card" 449 + ? addCardToList(list, item.scryfallId, item.oracleId) 450 + : addDeckToList(list, item.deckUri); 451 + 452 + // Cancel in-flight queries 453 + await queryClient.cancelQueries({ 454 + queryKey: ["collection-list", did, rkey], 455 + }); 456 + await queryClient.cancelQueries({ 457 + queryKey: ["collection-lists", did], 458 + }); 459 + 460 + // Snapshot previous list state 461 + const previousList = queryClient.getQueryData<CollectionList>([ 462 + "collection-list", 463 + did, 464 + rkey, 465 + ]); 466 + 467 + type ListPage = { records: CollectionListRecord[]; cursor?: string }; 468 + const previousLists = queryClient.getQueryData<InfiniteData<ListPage>>([ 469 + "collection-lists", 470 + did, 471 + ]); 472 + 473 + // Optimistically update list queries 474 + queryClient.setQueryData<CollectionList>( 475 + ["collection-list", did, rkey], 476 + updatedList, 477 + ); 478 + 479 + if (previousLists) { 480 + queryClient.setQueryData<InfiniteData<ListPage>>( 481 + ["collection-lists", did], 482 + { 483 + ...previousLists, 484 + pages: previousLists.pages.map((page) => ({ 485 + ...page, 486 + records: page.records.map((record) => 487 + record.uri.endsWith(`/${rkey}`) 488 + ? { ...record, value: updatedList } 489 + : record, 490 + ), 491 + })), 492 + }, 493 + ); 494 + } 495 + 496 + // Optimistically update constellation queries 497 + const itemUri = 498 + item.type === "card" 499 + ? toOracleUri(item.oracleId) 500 + : (item.deckUri as `at://${string}`); 501 + const constellationKeys = getConstellationQueryKeys(itemUri, did); 502 + 503 + await queryClient.cancelQueries({ 504 + queryKey: constellationKeys.userSaved, 505 + }); 506 + await queryClient.cancelQueries({ 507 + queryKey: constellationKeys.saveCount, 508 + }); 509 + 510 + const previousSaved = queryClient.getQueryData<boolean>( 511 + constellationKeys.userSaved, 512 + ); 513 + const previousCount = queryClient.getQueryData<number>( 514 + constellationKeys.saveCount, 515 + ); 516 + 517 + queryClient.setQueryData<boolean>(constellationKeys.userSaved, !isSaved); 518 + queryClient.setQueryData<number>(constellationKeys.saveCount, (old) => 519 + isSaved ? Math.max(0, (old ?? 1) - 1) : (old ?? 0) + 1, 520 + ); 521 + 522 + return { 523 + previousList, 524 + previousLists, 525 + previousSaved, 526 + previousCount, 527 + constellationKeys, 528 + isSaved, 529 + }; 530 + }, 531 + onError: (_err, _params, context) => { 532 + if (!context) return; 533 + 534 + // Rollback list queries 535 + if (context.previousList) { 536 + queryClient.setQueryData<CollectionList>( 537 + ["collection-list", did, rkey], 538 + context.previousList, 539 + ); 540 + } 541 + if (context.previousLists) { 542 + type ListPage = { records: CollectionListRecord[]; cursor?: string }; 543 + queryClient.setQueryData<InfiniteData<ListPage>>( 544 + ["collection-lists", did], 545 + context.previousLists, 546 + ); 547 + } 548 + 549 + // Rollback constellation queries 550 + queryClient.setQueryData<boolean>( 551 + context.constellationKeys.userSaved, 552 + context.previousSaved, 553 + ); 554 + queryClient.setQueryData<number>( 555 + context.constellationKeys.saveCount, 556 + context.previousCount, 557 + ); 558 + }, 559 + onSuccess: (data, { list, itemName, item }) => { 560 + const what = itemName ?? (item.type === "card" ? "Card" : "Deck"); 561 + if (data.wasSaved) { 562 + toast.success(`Removed ${what} from ${list.name}`); 563 + } else { 564 + toast.success(`Saved ${what} to ${list.name}`); 565 + } 566 + }, 567 + }); 568 + }
+14 -6
src/routes/profile/$did/list/$rkey/index.tsx
··· 12 12 import { asRkey, type Rkey } from "@/lib/atproto-client"; 13 13 import { 14 14 getCollectionListQueryOptions, 15 + useToggleListItemMutation, 15 16 useUpdateCollectionListMutation, 16 17 } from "@/lib/collection-list-queries"; 17 18 import { ··· 19 20 isDeckItem, 20 21 type ListCardItem, 21 22 type ListDeckItem, 22 - removeCardFromList, 23 - removeDeckFromList, 23 + type SaveItem, 24 24 } from "@/lib/collection-list-types"; 25 25 import { getDeckQueryOptions } from "@/lib/deck-queries"; 26 26 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; ··· 101 101 const handle = extractHandle(didDocument ?? null); 102 102 103 103 const mutation = useUpdateCollectionListMutation(did as Did, asRkey(rkey)); 104 + const toggleMutation = useToggleListItemMutation(did as Did, asRkey(rkey)); 104 105 const isOwner = session?.info.sub === did; 105 106 106 107 const [isEditingName, setIsEditingName] = useState(false); ··· 115 116 } 116 117 117 118 const handleRemoveCard = (item: ListCardItem) => { 118 - const updated = removeCardFromList(list, item.scryfallId); 119 - mutation.mutate(updated); 119 + const saveItem: SaveItem = { 120 + type: "card", 121 + scryfallId: item.scryfallId, 122 + oracleId: item.oracleId, 123 + }; 124 + toggleMutation.mutate({ list, item: saveItem }); 120 125 }; 121 126 122 127 const handleRemoveDeck = (item: ListDeckItem) => { 123 - const updated = removeDeckFromList(list, item.deckUri); 124 - mutation.mutate(updated); 128 + const saveItem: SaveItem = { 129 + type: "deck", 130 + deckUri: item.deckUri, 131 + }; 132 + toggleMutation.mutate({ list, item: saveItem }); 125 133 }; 126 134 127 135 const handleNameClick = () => {