👁️
6
fork

Configure Feed

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

comments on lists and decks

+230 -47
+70
src/components/Drawer.tsx
··· 1 + import type { ReactNode } from "react"; 2 + import { useEffect } from "react"; 3 + 4 + interface DrawerProps { 5 + isOpen: boolean; 6 + onClose: () => void; 7 + children: ReactNode; 8 + side?: "left" | "right"; 9 + size?: "sm" | "md" | "lg"; 10 + "aria-label"?: string; 11 + "aria-labelledby"?: string; 12 + } 13 + 14 + const SIZE_CLASSES = { 15 + sm: "w-80", 16 + md: "w-[30rem]", 17 + lg: "w-[40rem]", 18 + } as const; 19 + 20 + export function Drawer({ 21 + isOpen, 22 + onClose, 23 + children, 24 + side = "right", 25 + size = "md", 26 + "aria-label": ariaLabel, 27 + "aria-labelledby": ariaLabelledby, 28 + }: DrawerProps) { 29 + useEffect(() => { 30 + if (!isOpen) return; 31 + 32 + const handleKeyDown = (e: KeyboardEvent) => { 33 + if (e.key === "Escape") { 34 + onClose(); 35 + } 36 + }; 37 + 38 + document.addEventListener("keydown", handleKeyDown); 39 + return () => document.removeEventListener("keydown", handleKeyDown); 40 + }, [isOpen, onClose]); 41 + 42 + if (!isOpen) return null; 43 + 44 + const positionClass = side === "left" ? "left-0" : "right-0"; 45 + const borderClass = side === "left" ? "border-r" : "border-l"; 46 + 47 + return ( 48 + <> 49 + {/* Backdrop */} 50 + <div 51 + className="fixed inset-0 bg-black/50 z-40" 52 + onClick={onClose} 53 + aria-hidden="true" 54 + /> 55 + 56 + {/* Drawer panel */} 57 + <div className="fixed inset-0 z-50 pointer-events-none"> 58 + <div 59 + role="dialog" 60 + aria-modal="true" 61 + aria-label={ariaLabel} 62 + aria-labelledby={ariaLabelledby} 63 + className={`fixed inset-y-0 ${positionClass} ${SIZE_CLASSES[size]} max-w-[90vw] bg-white dark:bg-zinc-900 ${borderClass} border-gray-200 dark:border-zinc-600 shadow-2xl pointer-events-auto flex flex-col motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-out`} 64 + > 65 + {children} 66 + </div> 67 + </div> 68 + </> 69 + ); 70 + }
+5 -2
src/components/comments/CommentsPanel.tsx
··· 8 8 } from "@/lib/constellation-queries"; 9 9 import type { ComDeckbelcherSocialComment } from "@/lib/lexicons/index"; 10 10 import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 11 - import { getSocialItemUri, type SaveableItem } from "@/lib/social-item-types"; 11 + import { 12 + type CommentableItem, 13 + getSocialItemUri, 14 + } from "@/lib/social-item-types"; 12 15 import { useAuth } from "@/lib/useAuth"; 13 16 import { CommentForm } from "./CommentForm"; 14 17 import { CommentThread } from "./CommentThread"; ··· 17 20 18 21 interface CommentsPanelProps { 19 22 subject: CommentSubject; 20 - item: SaveableItem; 23 + item: CommentableItem; 21 24 title?: string; 22 25 onClose?: () => void; 23 26 availableTags?: string[];
+1 -3
src/components/list/SaveToListDialog.tsx
··· 70 70 if (!newListName.trim()) return; 71 71 72 72 const itemUri = 73 - item.type === "card" 74 - ? toOracleUri(item.oracleId) 75 - : (item.uri as `at://${string}`); 73 + item.type === "card" ? toOracleUri(item.oracleId) : item.uri; 76 74 const queryKeys = getConstellationQueryKeys(itemUri, userDid); 77 75 78 76 const previousSaved = queryClient.getQueryData<boolean>(
+5 -7
src/components/social/BacklinkRow.tsx
··· 55 55 } 56 56 57 57 function SaveRow({ did, rkey }: { did: Did; rkey: Rkey }) { 58 - const { 59 - data: list, 60 - isLoading, 61 - isError, 62 - } = useQuery(getCollectionListQueryOptions(did, rkey)); 58 + const { data, isLoading, isError } = useQuery( 59 + getCollectionListQueryOptions(did, rkey), 60 + ); 63 61 64 62 if (isLoading) { 65 63 return <RowSkeleton />; 66 64 } 67 65 68 - if (isError || !list) { 66 + if (isError || !data) { 69 67 return null; 70 68 } 71 69 72 - return <ListPreview did={did} rkey={rkey} list={list} showHandle />; 70 + return <ListPreview did={did} rkey={rkey} list={data.value} showHandle />; 73 71 } 74 72 75 73 function DeckRow({ did, rkey }: { did: Did; rkey: Rkey }) {
+28 -18
src/lib/collection-list-queries.ts
··· 44 44 optimisticBacklinks, 45 45 optimisticBoolean, 46 46 optimisticCount, 47 - optimisticRecordWithIndex, 47 + optimisticInfiniteRecord, 48 + optimisticRecord, 48 49 runOptimistic, 49 50 } from "./optimistic-utils"; 50 51 import { ··· 53 54 toOracleUri, 54 55 toScryfallUri, 55 56 } from "./scryfall-types"; 56 - import type { SaveableItem } from "./social-item-types"; 57 + import { getSocialItemUri, type SaveableItem } from "./social-item-types"; 57 58 import { useAuth } from "./useAuth"; 58 59 import { useMutationWithToast } from "./useMutationWithToast"; 59 60 ··· 97 98 }; 98 99 } 99 100 101 + export interface CollectionListRecord { 102 + uri: string; 103 + cid: string; 104 + value: CollectionList; 105 + } 106 + 100 107 /** 101 108 * Query options for fetching a single collection list 102 109 */ 103 110 export const getCollectionListQueryOptions = (did: Did, rkey: Rkey) => 104 111 queryOptions({ 105 112 queryKey: ["collection-list", did, rkey] as const, 106 - queryFn: async (): Promise<CollectionList> => { 113 + queryFn: async (): Promise<CollectionListRecord> => { 107 114 const result = await getCollectionListRecord(did, rkey); 108 115 if (!result.success) { 109 116 throw result.error; 110 117 } 111 - return transformListRecord(result.data.value); 118 + return { 119 + uri: result.data.uri, 120 + cid: result.data.cid, 121 + value: transformListRecord(result.data.value), 122 + }; 112 123 }, 113 124 staleTime: 30 * 1000, 114 125 }); 115 126 116 - export interface CollectionListRecord { 117 - uri: string; 118 - cid: string; 119 - value: CollectionList; 120 - } 121 - 122 127 /** 123 128 * Query options for listing all collection lists for a user 124 129 */ ··· 274 279 }, 275 280 onMutate: async (newList) => { 276 281 const rollback = await runOptimistic([ 277 - optimisticRecordWithIndex<CollectionList>( 282 + optimisticRecord<CollectionListRecord>( 278 283 queryClient, 279 284 ["collection-list", did, rkey], 285 + (old) => (old ? { ...old, value: newList } : undefined), 286 + ), 287 + optimisticInfiniteRecord<CollectionList>( 288 + queryClient, 280 289 ["collection-lists", did], 281 - rkey, 290 + `/${rkey}`, 282 291 newList, 283 292 ), 284 293 ]); ··· 418 427 ? addCardToList(list, item.scryfallId, item.oracleId) 419 428 : addDeckToList(list, item.uri, item.cid); 420 429 421 - const itemUri = 422 - item.type === "card" 423 - ? toOracleUri(item.oracleId) 424 - : (item.uri as `at://${string}`); 430 + const itemUri = getSocialItemUri(item); 425 431 const keys = getConstellationQueryKeys(itemUri, did); 426 432 const newSavedState = !isSaved; 427 433 428 434 const rollback = await runOptimistic([ 429 - optimisticRecordWithIndex<CollectionList>( 435 + optimisticRecord<CollectionListRecord>( 430 436 queryClient, 431 437 ["collection-list", did, rkey], 438 + (old) => (old ? { ...old, value: updatedList } : undefined), 439 + ), 440 + optimisticInfiniteRecord<CollectionList>( 441 + queryClient, 432 442 ["collection-lists", did], 433 - rkey, 443 + `/${rkey}`, 434 444 updatedList, 435 445 ), 436 446 optimisticBoolean(queryClient, keys.userSaved, (qc) => {
+13 -6
src/lib/constellation-queries.ts
··· 30 30 } from "./constellation-client"; 31 31 import { 32 32 type CardItem, 33 + type CommentableItem, 33 34 getSocialItemUri, 35 + isCommentable, 34 36 isSaveable, 35 37 type SaveableItem, 36 38 type SaveableItemType, ··· 192 194 193 195 // Extract narrowed items (undefined if not applicable type) 194 196 const saveableItem = isSaveable(item) ? item : undefined; 197 + const commentableItem = isCommentable(item) ? item : undefined; 195 198 const cardItem = item.type === "card" ? item : undefined; 196 199 const isCommentOrReply = item.type === "comment" || item.type === "reply"; 197 200 ··· 213 216 ); 214 217 const deckCountQuery = useQuery(cardDeckCountQueryOptions(cardItem)); 215 218 216 - // Comment count for cards/decks, reply count for comments/replies 219 + // Comment count for cards/decks/lists, reply count for comments/replies 217 220 const commentCountQuery = useQuery( 218 - itemCommentCountQueryOptions(saveableItem), 221 + itemCommentCountQueryOptions(commentableItem), 219 222 ); 220 223 const replyCountQuery = useQuery({ 221 224 ...directReplyCountQueryOptions(getSocialItemUri(item)), ··· 437 440 // Comment Queries 438 441 // ============================================================================ 439 442 440 - function getCommentPathForItemType(itemType: SaveableItemType): string { 443 + type CommentableItemType = "card" | "deck" | "list"; 444 + 445 + function getCommentPathForItemType(itemType: CommentableItemType): string { 441 446 return itemType === "card" ? COMMENT_CARD_PATH : COMMENT_RECORD_PATH; 442 447 } 443 448 ··· 445 450 * Query options for getting top-level comment count for an item. 446 451 * Auto-disables when item is undefined. 447 452 */ 448 - export function itemCommentCountQueryOptions(item: SaveableItem | undefined) { 453 + export function itemCommentCountQueryOptions( 454 + item: CommentableItem | undefined, 455 + ) { 449 456 const itemUri = item ? getSocialItemUri(item) : undefined; 450 457 const itemType = item?.type; 451 458 return queryOptions({ ··· 471 478 } 472 479 473 480 /** 474 - * Infinite query for top-level comments on an item (card or deck/collection). 481 + * Infinite query for top-level comments on an item (card, deck, or list). 475 482 * Auto-disables when item is undefined. 476 483 */ 477 - export function itemCommentsQueryOptions(item: SaveableItem | undefined) { 484 + export function itemCommentsQueryOptions(item: CommentableItem | undefined) { 478 485 const itemUri = item ? getSocialItemUri(item) : undefined; 479 486 return infiniteQueryOptions({ 480 487 queryKey: ["constellation", "comments", itemUri] as const,
+33 -3
src/lib/social-item-types.ts
··· 12 12 * - Decks/comments/replies use at://<did>/<collection>/<rkey> URIs 13 13 */ 14 14 export type CardItemUri = OracleUri; 15 - export type DeckItemUri = `at://${string}`; 15 + export type DeckItemUri = `at://${string}/com.deckbelcher.deck.list/${string}`; 16 + export type ListItemUri = 17 + `at://${string}/com.deckbelcher.collection.list/${string}`; 16 18 export type CommentUri = 17 19 `at://${string}/com.deckbelcher.social.comment/${string}`; 18 20 export type ReplyUri = `at://${string}/com.deckbelcher.social.reply/${string}`; ··· 24 26 export type SocialItem = 25 27 | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 26 28 | { type: "deck"; uri: DeckItemUri; cid: string } 29 + | { type: "list"; uri: ListItemUri; cid: string } 27 30 | { type: "comment"; uri: CommentUri; cid: string } 28 31 | { type: "reply"; uri: ReplyUri; cid: string }; 29 32 30 33 /** 31 34 * SaveableItem - subset of SocialItem that can be saved to collection lists 32 - * Only cards and decks are saveable 35 + * Only cards and decks are saveable (you don't save lists to lists) 33 36 */ 34 37 export type SaveableItem = Extract<SocialItem, { type: "card" | "deck" }>; 35 38 36 39 /** 40 + * CommentableItem - items that can have comments (cards, decks, lists) 41 + */ 42 + export type CommentableItem = Extract< 43 + SocialItem, 44 + { type: "card" | "deck" | "list" } 45 + >; 46 + 47 + /** 37 48 * CardItem - narrowed to just cards (for deck-count queries, etc.) 38 49 */ 39 50 export type CardItem = Extract<SocialItem, { type: "card" }>; ··· 46 57 } 47 58 48 59 /** 60 + * Type guard: checks if item can have comments 61 + */ 62 + export function isCommentable(item: SocialItem): item is CommentableItem { 63 + return item.type === "card" || item.type === "deck" || item.type === "list"; 64 + } 65 + 66 + /** 49 67 * Type guard: checks if item has deck count (only cards) 50 68 */ 51 69 export function hasDeckCount( ··· 62 80 /** 63 81 * URI type for any social item (for Constellation queries) 64 82 */ 65 - export type SocialItemUri = CardItemUri | DeckItemUri | CommentUri | ReplyUri; 83 + export type SocialItemUri = 84 + | CardItemUri 85 + | DeckItemUri 86 + | ListItemUri 87 + | CommentUri 88 + | ReplyUri; 66 89 67 90 /** 68 91 * Saveable item types (can be saved to collection lists) ··· 79 102 export function getSocialItemUri( 80 103 item: Extract<SocialItem, { type: "deck" }>, 81 104 ): DeckItemUri; 105 + export function getSocialItemUri( 106 + item: Extract<SocialItem, { type: "list" }>, 107 + ): ListItemUri; 82 108 export function getSocialItemUri( 83 109 item: Extract<SocialItem, { type: "comment" }>, 84 110 ): CommentUri; ··· 93 119 return toOracleUri(item.oracleId); 94 120 case "deck": 95 121 return item.uri; 122 + case "list": 123 + return item.uri; 96 124 case "comment": 97 125 return item.uri; 98 126 case "reply": ··· 109 137 return "Card"; 110 138 case "deck": 111 139 return "Deck"; 140 + case "list": 141 + return "List"; 112 142 case "comment": 113 143 return "Comment"; 114 144 case "reply":
+30 -2
src/routes/profile/$did/deck/$rkey/index.tsx
··· 5 5 import { useCallback, useMemo, useRef, useState } from "react"; 6 6 import { ErrorBoundary } from "react-error-boundary"; 7 7 import { toast } from "sonner"; 8 + import { CommentsPanel } from "@/components/comments/CommentsPanel"; 9 + import { Drawer } from "@/components/Drawer"; 8 10 import { CardDragOverlay } from "@/components/deck/CardDragOverlay"; 9 11 import { CardModal } from "@/components/deck/CardModal"; 10 12 import { CardPreviewPane } from "@/components/deck/CardPreviewPane"; ··· 49 51 import { documentToPlainText } from "@/lib/richtext-convert"; 50 52 import type { ScryfallId } from "@/lib/scryfall-types"; 51 53 import { getImageUri } from "@/lib/scryfall-utils"; 54 + import type { DeckItemUri } from "@/lib/social-item-types"; 52 55 import { getSelectedCards, type StatsSelection } from "@/lib/stats-selection"; 53 56 import { useAuth } from "@/lib/useAuth"; 54 57 import { useDeckStats } from "@/lib/useDeckStats"; ··· 63 66 ); 64 67 65 68 const deckUri = 66 - `at://${params.did}/${DECK_LIST_NSID}/${params.rkey}` as const; 69 + `at://${params.did}/${DECK_LIST_NSID}/${params.rkey}` as DeckItemUri; 67 70 68 71 // Prefetch cards and social stats in parallel 69 72 const cardIds = deck.cards.map((card) => card.scryfallId); ··· 606 609 }, 607 610 }); 608 611 612 + const [isCommentsDrawerOpen, setIsCommentsDrawerOpen] = useState(false); 613 + 614 + const deckUri = `at://${did}/${DECK_LIST_NSID}/${rkey}` as DeckItemUri; 615 + 609 616 const scrollToTagGroup = useCallback( 610 617 (tag: string) => { 611 618 const el = document.querySelector( ··· 692 699 <SocialStats 693 700 item={{ 694 701 type: "deck", 695 - uri: `at://${did}/com.deckbelcher.deck.list/${rkey}`, 702 + uri: deckUri, 696 703 cid: deckCid, 697 704 }} 698 705 itemName={deck.name} 706 + onCommentClick={() => setIsCommentsDrawerOpen(true)} 699 707 /> 700 708 <ValidationBadge result={validation} /> 701 709 {isOwner && ( ··· 847 855 848 856 {/* Drag overlay */} 849 857 <CardDragOverlay draggedCardId={draggedCardId} /> 858 + 859 + {/* Comments drawer */} 860 + <Drawer 861 + isOpen={isCommentsDrawerOpen} 862 + onClose={() => setIsCommentsDrawerOpen(false)} 863 + size="lg" 864 + aria-label={`Comments on ${deck.name}`} 865 + > 866 + <CommentsPanel 867 + subject={{ 868 + $type: "com.deckbelcher.social.comment#recordSubject", 869 + ref: { uri: deckUri, cid: deckCid }, 870 + }} 871 + item={{ type: "deck", uri: deckUri, cid: deckCid }} 872 + title={`Comments on ${deck.name}`} 873 + onClose={() => setIsCommentsDrawerOpen(false)} 874 + availableTags={allTags} 875 + maxHeight="max-h-screen" 876 + /> 877 + </Drawer> 850 878 </div> 851 879 ); 852 880 }
+45 -6
src/routes/profile/$did/list/$rkey/index.tsx
··· 6 6 import { ErrorBoundary } from "react-error-boundary"; 7 7 import { CardImage } from "@/components/CardImage"; 8 8 import { ClientDate } from "@/components/ClientDate"; 9 + import { CommentsPanel } from "@/components/comments/CommentsPanel"; 9 10 import { DeckPreview } from "@/components/DeckPreview"; 11 + import { Drawer } from "@/components/Drawer"; 10 12 import { ListActionsMenu } from "@/components/list/ListActionsMenu"; 11 13 import { ManaCost } from "@/components/ManaCost"; 12 14 import { OracleText } from "@/components/OracleText"; 13 15 import { RichtextSection } from "@/components/richtext/RichtextSection"; 14 16 import { SetSymbol } from "@/components/SetSymbol"; 17 + import { SocialStats } from "@/components/social/SocialStats"; 15 18 import { asRkey, type Rkey } from "@/lib/atproto-client"; 16 19 import { getPrimaryFace } from "@/lib/card-faces"; 17 20 import { ··· 25 28 type ListCardItem, 26 29 type ListDeckItem, 27 30 } from "@/lib/collection-list-types"; 31 + import { COLLECTION_LIST_NSID } from "@/lib/constellation-client"; 28 32 import { getDeckQueryOptions } from "@/lib/deck-queries"; 29 33 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 30 34 import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 31 35 import { getCardByIdQueryOptions } from "@/lib/queries"; 32 36 import { documentToPlainText } from "@/lib/richtext-convert"; 33 37 import { getImageUri } from "@/lib/scryfall-utils"; 34 - import type { SaveableItem } from "@/lib/social-item-types"; 38 + import type { 39 + DeckItemUri, 40 + ListItemUri, 41 + SaveableItem, 42 + } from "@/lib/social-item-types"; 35 43 import { useAuth } from "@/lib/useAuth"; 36 44 37 45 export const Route = createFileRoute("/profile/$did/list/$rkey/")({ ··· 42 50 ); 43 51 return list; 44 52 }, 45 - head: ({ loaderData: list }) => { 46 - if (!list) { 53 + head: ({ loaderData: listRecord }) => { 54 + if (!listRecord) { 47 55 return { meta: [{ title: "List Not Found | DeckBelcher" }] }; 48 56 } 57 + const list = listRecord.value; 49 58 50 59 const title = `${list.name} | DeckBelcher`; 51 60 const cardCount = list.items.filter(isCardItem).length; ··· 98 107 function ListDetailPage() { 99 108 const { did, rkey } = Route.useParams(); 100 109 const { session } = useAuth(); 101 - const { data: list, isLoading } = useQuery( 110 + const { data, isLoading } = useQuery( 102 111 getCollectionListQueryOptions(did as Did, asRkey(rkey)), 103 112 ); 113 + const list = data?.value; 104 114 const { data: didDocument } = useQuery(didDocumentQueryOptions(did as Did)); 105 115 const handle = extractHandle(didDocument ?? null); 106 116 ··· 110 120 111 121 const [isEditingName, setIsEditingName] = useState(false); 112 122 const [editedName, setEditedName] = useState(""); 123 + const [isCommentsDrawerOpen, setIsCommentsDrawerOpen] = useState(false); 124 + 125 + const listUri = `at://${did}/${COLLECTION_LIST_NSID}/${rkey}` as ListItemUri; 126 + const listCid = data?.cid ?? ""; 113 127 114 128 if (isLoading || !list) { 115 129 return ( ··· 131 145 const handleRemoveDeck = (item: ListDeckItem) => { 132 146 const saveItem: SaveableItem = { 133 147 type: "deck", 134 - uri: item.ref.uri, 148 + uri: item.ref.uri as DeckItemUri, 135 149 cid: item.ref.cid, 136 150 }; 137 151 toggleMutation.mutate({ list, item: saveItem }); ··· 218 232 <span className="inline-block h-4 w-20 bg-gray-200 dark:bg-zinc-700 rounded animate-pulse align-middle" /> 219 233 )} 220 234 </p> 221 - <p className="text-sm text-gray-500 dark:text-zinc-400 mb-4"> 235 + <p className="text-sm text-gray-500 dark:text-zinc-400 mb-2"> 222 236 Updated <ClientDate dateString={dateString} /> 223 237 </p> 238 + <div className="mb-4"> 239 + <SocialStats 240 + item={{ type: "list", uri: listUri, cid: listCid }} 241 + itemName={list.name} 242 + onCommentClick={() => setIsCommentsDrawerOpen(true)} 243 + /> 244 + </div> 224 245 225 246 <div className="mb-8"> 226 247 <ErrorBoundary fallback={null}> ··· 258 279 </div> 259 280 )} 260 281 </div> 282 + 283 + <Drawer 284 + isOpen={isCommentsDrawerOpen} 285 + onClose={() => setIsCommentsDrawerOpen(false)} 286 + size="lg" 287 + aria-label={`Comments on ${list.name}`} 288 + > 289 + <CommentsPanel 290 + subject={{ 291 + $type: "com.deckbelcher.social.comment#recordSubject", 292 + ref: { uri: listUri, cid: listCid }, 293 + }} 294 + item={{ type: "list", uri: listUri, cid: listCid }} 295 + title={`Comments on ${list.name}`} 296 + onClose={() => setIsCommentsDrawerOpen(false)} 297 + maxHeight="max-h-screen" 298 + /> 299 + </Drawer> 261 300 </div> 262 301 ); 263 302 }