👁️
6
fork

Configure Feed

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

start on deck editor!

+1374 -3
+320
.claude/DECK_EDITOR.md
··· 1 + # Deck Editor Implementation Guide 2 + 3 + ## Overview 4 + 5 + The deck editor is a Moxfield-inspired interface for building Magic: The Gathering decklists. It emphasizes: 6 + - Fast card addition via autocomplete search 7 + - Flexible organization (sections, tags, grouping) 8 + - Rich visualization (preview pane, multiple view styles) 9 + - Desktop-first with mobile support 10 + 11 + ## User Interface Layout 12 + 13 + ### Desktop Layout (Split Pane) 14 + 15 + ``` 16 + ┌─────────────────────────────────────────────────────────────┐ 17 + │ Header: [Deck Name] [Format ▾] [Search cards...] │ 18 + ├─────────────────────┬───────────────────────────────────────┤ 19 + │ │ [View: Text ▾] [Group: Tag ▾] [Sort ▾]│ 20 + │ Card Preview │ │ 21 + │ (40% width) │ Commander (1) │ 22 + │ │ └─ [Commander card] │ 23 + │ [Large card │ │ 24 + │ image of │ Mainboard (97) │ 25 + │ hovered card] │ Group: "Ramp" (12) │ 26 + │ │ └─ [card] [card] [card] │ 27 + │ │ Group: "Removal" (15) │ 28 + │ │ └─ [card] [card] │ 29 + │ │ Group: "(No Tags)" (70) │ 30 + │ │ └─ [many cards] │ 31 + │ │ │ 32 + │ │ Sideboard (15) │ 33 + │ │ └─ [cards] │ 34 + │ │ │ 35 + │ │ Maybeboard (23) │ 36 + │ │ └─ [cards] │ 37 + │ │ │ 38 + │ │ ─────────────────────────── │ 39 + │ │ Stats (collapsible) │ 40 + │ │ [Mana curve histogram] │ 41 + │ │ [Color distribution] │ 42 + │ │ [Type breakdown] │ 43 + │ │ │ 44 + │ │ Primer (collapsible) │ 45 + │ │ [Rich text editor] │ 46 + └─────────────────────┴───────────────────────────────────────┘ 47 + ``` 48 + 49 + ### Mobile Layout 50 + 51 + - Header (name, format, search) - stacks vertically 52 + - No preview pane - tap card for full preview/modal 53 + - Sections stack vertically with full width 54 + - Stats collapse by default 55 + - Cards in 2-column grid or single-column list 56 + 57 + ## Data Model 58 + 59 + ### Deck State 60 + 61 + Matches `com.deckbelcher.deck.list` lexicon schema: 62 + 63 + ```typescript 64 + interface DeckState { 65 + name: string; 66 + format?: string; 67 + cards: DeckCard[]; 68 + primer?: string; 69 + primerFacets?: RichtextFacet[]; 70 + createdAt: string; 71 + updatedAt?: string; 72 + } 73 + 74 + interface DeckCard { 75 + scryfallId: string; // Scryfall UUID 76 + quantity: number; // 1+ 77 + section: Section; // "commander" | "mainboard" | "sideboard" | "maybeboard" 78 + tags?: string[]; // ["ramp", "interaction", ...] 79 + } 80 + 81 + type Section = "commander" | "mainboard" | "sideboard" | "maybeboard" | string; 82 + ``` 83 + 84 + ### Key Behavior 85 + 86 + **Card Uniqueness:** 87 + - A card is unique by `(scryfallId, section)` combination 88 + - Same card in different sections = separate entries 89 + - Same card in same section with different tags = INVALID (one entry, multiple tags) 90 + 91 + **Example:** 92 + ```typescript 93 + // ✅ Valid: Lightning Bolt in mainboard (qty 2) and sideboard (qty 1) 94 + [ 95 + { scryfallId: "abc...", quantity: 2, section: "mainboard", tags: ["burn"] }, 96 + { scryfallId: "abc...", quantity: 1, section: "sideboard", tags: ["removal"] } 97 + ] 98 + 99 + // ❌ Invalid: Same card, same section, different tags 100 + [ 101 + { scryfallId: "abc...", quantity: 1, section: "mainboard", tags: ["burn"] }, 102 + { scryfallId: "abc...", quantity: 1, section: "mainboard", tags: ["removal"] } 103 + ] 104 + 105 + // ✅ Valid: One entry with multiple tags 106 + [ 107 + { scryfallId: "abc...", quantity: 2, section: "mainboard", tags: ["burn", "removal"] } 108 + ] 109 + ``` 110 + 111 + ## View Modes 112 + 113 + ### View Styles 114 + 115 + **Text (default):** 116 + - Compact rows: `name | mana cost | type line` 117 + - Hover shows preview in left pane 118 + - Most space-efficient 119 + 120 + **Visual Grid:** 121 + - Card thumbnails with quantity badge overlay 122 + - Click for modal (edit qty/tags) 123 + - Hover shows preview 124 + 125 + **Visual Stacks (v2):** 126 + - Overlapping card images 127 + - Grouped by type/cmc/etc 128 + 129 + ### Grouping Options 130 + 131 + **By Tag:** 132 + - Cards grouped by tags (user-defined labels) 133 + - Untagged cards in "(No Tags)" group 134 + - Cards with multiple tags appear in EACH group they belong to 135 + 136 + **By Type:** 137 + - Creature, Instant, Sorcery, Enchantment, Artifact, Land, Planeswalker, etc. 138 + 139 + **By Mana Value:** 140 + - 0, 1, 2, 3, 4, 5, 6, 7+ 141 + 142 + **No Grouping:** 143 + - Flat list, just sorted 144 + 145 + ### Sorting (within groups) 146 + 147 + - Name (alphabetical) 148 + - Mana Value (CMC ascending) 149 + - Rarity (common → mythic) 150 + 151 + ### Extra Data Toggles 152 + 153 + - Show/hide mana cost icons 154 + - Show/hide set symbols 155 + - Show/hide prices (future) 156 + 157 + ## Interactions 158 + 159 + ### Adding Cards 160 + 161 + 1. User types in search box (header) 162 + 2. Autocomplete dropdown shows legal cards for selected format 163 + 3. Hovering search results shows card in preview pane 164 + 4. Clicking result adds 1x to mainboard 165 + 5. Search stays active for rapid additions 166 + 167 + ### Editing Cards 168 + 169 + **Click card → opens modal:** 170 + - Quantity input (number) 171 + - Tag management (add/remove tags, chip UI) 172 + - Section dropdown (move to different section) 173 + - Delete button 174 + 175 + ### Drag & Drop 176 + 177 + **Between sections:** 178 + - Drag card from Mainboard to Sideboard 179 + - Changes `section` field, preserves `quantity` and `tags` 180 + 181 + **Between tag groups (within same section):** 182 + - Drag card from "Ramp" group to "Removal" group 183 + - Removes "ramp" tag, adds "removal" tag 184 + - Preserves other tags (e.g., if card also had "instant", it keeps that) 185 + - If card has multiple tags, it visually appears in multiple groups, but it's ONE card 186 + 187 + **Visual feedback:** 188 + - Drop zones highlight on drag 189 + - Ghost image follows cursor 190 + 191 + ### Preview Pane Behavior 192 + 193 + **Default (no hover):** 194 + - Shows top card in active section (probably first card in commander, or first mainboard card) 195 + 196 + **During search:** 197 + - Shows top search result by default 198 + - Updates to hovered search result 199 + 200 + **During deck browsing:** 201 + - Shows last hovered card in deck list 202 + - Persists until new card is hovered 203 + 204 + ## Stats & Analysis 205 + 206 + ### Mana Curve 207 + - Histogram by CMC (0, 1, 2, 3, 4, 5, 6, 7+) 208 + - Includes cards from mainboard + commander 209 + - Excludes sideboard/maybeboard 210 + 211 + ### Color Distribution 212 + - Pie chart or bar chart 213 + - Based on color identity 214 + - Shows mono, multi, colorless proportions 215 + 216 + ### Type Breakdown 217 + - Count by card type (Creature, Instant, Sorcery, etc.) 218 + - Useful for deck balance 219 + 220 + ### Average CMC 221 + - Mean mana value across mainboard 222 + 223 + ## Format Legality 224 + 225 + ### Supported Formats (v1) 226 + - Commander (primary) 227 + - Cube (no legality filtering, freeform) 228 + - Pauper 229 + - Pauper Commander (PDH) 230 + 231 + ### Format Filtering 232 + - When format is selected, search only shows legal cards 233 + - Use Scryfall `legalities` field 234 + - Commander: also enforce color identity restrictions (once commander is set) 235 + 236 + ### Commander-Specific Rules 237 + - Commander section limited to 1-2 cards (partner commanders) 238 + - Color identity enforcement (all cards must match commander's color identity) 239 + - 100-card total (commander + mainboard = 100) 240 + 241 + ## Technical Implementation Notes 242 + 243 + ### State Management 244 + - Local React state (useState) for deck 245 + - No persistence in v1 (can add localStorage later) 246 + - All mutations are immutable updates 247 + 248 + ### Card Data 249 + - Use existing `getCardDataProvider()` for search 250 + - Scryfall data already loaded client-side 251 + - Format legality from `card.legalities` field 252 + 253 + ### Routing 254 + - `/deck/new` - generates new deck ID (AT URI: authority + rkey), redirects to `/deck/$id` 255 + - `/deck/$id` - editor route 256 + - ID format: AT Protocol URI (authority + rkey) 257 + - Example: `did:plc:abc123/3jxyz...` (the rkey portion of the full AT URI) 258 + - For new decks, generate a TID-based rkey before redirect 259 + - For existing decks, ID comes from ATProto record 260 + 261 + ### Drag & Drop Library 262 + - Use `@dnd-kit/core` (modern, performant, accessible) 263 + - Supports touch devices 264 + - Good TypeScript support 265 + 266 + ### Performance 267 + - Virtualize long card lists (react-virtual or similar) 268 + - Debounce search input 269 + - Memoize grouping/sorting calculations 270 + 271 + ## Future Enhancements (post-v1) 272 + 273 + - localStorage draft saving 274 + - Visual Stacks view 275 + - More grouping options (color, color identity, set, artist) 276 + - Price tracking 277 + - Commander color identity validation 278 + - Deck legality checker 279 + - Export formats (text, MTGO, Arena) 280 + - Import from other tools 281 + - Card recommendations 282 + - Goldfish hand simulator 283 + - Primer with rich text (card mentions via facets) 284 + - Version history (via ATProto CIDs) 285 + - Social features (likes, replies to specific cards) 286 + 287 + ## Reference: Moxfield Features 288 + 289 + ### View Styles 290 + - Text 291 + - Condensed Text 292 + - Visual Grid 293 + - Visual Stacks 294 + - Visual Stacks (Split) 295 + - Visual Spoiler 296 + 297 + ### Group By 298 + - Type 299 + - SubType 300 + - Type & Tag 301 + - Rarity 302 + - Color 303 + - Color Identity 304 + - Mana Value 305 + - Set 306 + - Artist 307 + - No Grouping 308 + 309 + ### Sort By 310 + - Name 311 + - Mana Value 312 + - Price 313 + - Rarity 314 + 315 + ### Extra Data 316 + - Mana Cost 317 + - Price 318 + - Set Symbol 319 + 320 + We'll start with a subset and expand based on user feedback.
+6
lex.config.js
··· 1 + import { defineLexiconConfig } from "@atcute/lex-cli"; 2 + 3 + export default defineLexiconConfig({ 4 + files: ["lexicons/**/*.json"], 5 + outdir: "src/lib/lexicons/", 6 + });
+62
package-lock.json
··· 28 28 "vite-tsconfig-paths": "^5.1.4" 29 29 }, 30 30 "devDependencies": { 31 + "@atcute/lex-cli": "^2.3.1", 31 32 "@biomejs/biome": "2.2.4", 32 33 "@testing-library/dom": "^10.4.0", 33 34 "@testing-library/react": "^16.2.0", ··· 151 152 }, 152 153 "peerDependencies": { 153 154 "@atcute/identity": "^1.0.0" 155 + } 156 + }, 157 + "node_modules/@atcute/lex-cli": { 158 + "version": "2.3.1", 159 + "resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.3.1.tgz", 160 + "integrity": "sha512-HrHD91CFSFd/p0UFe3akFA1HXiboQwd5LbYiU0srKdLxGX+NLTX/EdCdhbLV6M7LsXdmxk7PB6BMcprsX4rbvg==", 161 + "dev": true, 162 + "license": "0BSD", 163 + "dependencies": { 164 + "@atcute/lexicon-doc": "^1.1.4", 165 + "@badrap/valita": "^0.4.6", 166 + "@optique/core": "^0.6.1", 167 + "@optique/run": "^0.6.1", 168 + "picocolors": "^1.1.1", 169 + "prettier": "^3.6.2" 170 + }, 171 + "bin": { 172 + "lex-cli": "cli.mjs" 173 + } 174 + }, 175 + "node_modules/@atcute/lexicon-doc": { 176 + "version": "1.1.4", 177 + "resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-1.1.4.tgz", 178 + "integrity": "sha512-OL0fsXtbnN/KwCq/L3nWGvOCdSHV0NWTatgLUIPt+T9AhcziFNaXAbbjvVHdflr3ZaLh3ksleHK0J789UBhlWQ==", 179 + "dev": true, 180 + "license": "0BSD", 181 + "dependencies": { 182 + "@badrap/valita": "^0.4.6" 154 183 } 155 184 }, 156 185 "node_modules/@atcute/lexicons": { ··· 1902 1931 "license": "MIT", 1903 1932 "engines": { 1904 1933 "node": ">=8.0" 1934 + } 1935 + }, 1936 + "node_modules/@optique/core": { 1937 + "version": "0.6.2", 1938 + "resolved": "https://registry.npmjs.org/@optique/core/-/core-0.6.2.tgz", 1939 + "integrity": "sha512-HTxIHJ8xLOSZotiU6Zc5BCJv+SJ8DMYmuiQM+7tjF7RolJn/pdZNe7M78G3+DgXL9lIf82l8aGcilmgVYRQnGQ==", 1940 + "dev": true, 1941 + "funding": [ 1942 + "https://github.com/sponsors/dahlia" 1943 + ], 1944 + "license": "MIT", 1945 + "engines": { 1946 + "bun": ">=1.2.0", 1947 + "deno": ">=2.3.0", 1948 + "node": ">=20.0.0" 1949 + } 1950 + }, 1951 + "node_modules/@optique/run": { 1952 + "version": "0.6.2", 1953 + "resolved": "https://registry.npmjs.org/@optique/run/-/run-0.6.2.tgz", 1954 + "integrity": "sha512-ERksB5bHozwEUVlTPToIc8UjZZBOgLeBhFZYh2lgldUbNDt7LItzgcErsPq5au5i5IBmmyCti4+2A3x+MRI4Xw==", 1955 + "dev": true, 1956 + "funding": [ 1957 + "https://github.com/sponsors/dahlia" 1958 + ], 1959 + "license": "MIT", 1960 + "dependencies": { 1961 + "@optique/core": "0.6.2" 1962 + }, 1963 + "engines": { 1964 + "bun": ">=1.2.0", 1965 + "deno": ">=2.3.0", 1966 + "node": ">=20.0.0" 1905 1967 } 1906 1968 }, 1907 1969 "node_modules/@rolldown/pluginutils": {
+2
package.json
··· 13 13 "check": "biome check", 14 14 "typecheck": "tsc --noEmit", 15 15 "build:typelex": "typelex compile com.deckbelcher.*", 16 + "build:lexicons": "lex-cli generate -c ./lex.config.js", 16 17 "download:scryfall": "node --experimental-strip-types scripts/download-scryfall.ts" 17 18 }, 18 19 "dependencies": { ··· 38 39 "vite-tsconfig-paths": "^5.1.4" 39 40 }, 40 41 "devDependencies": { 42 + "@atcute/lex-cli": "^2.3.1", 41 43 "@biomejs/biome": "2.2.4", 42 44 "@testing-library/dom": "^10.4.0", 43 45 "@testing-library/react": "^16.2.0",
+31
src/components/deck/CardPreviewPane.tsx
··· 1 + import type { ScryfallId } from "@/lib/scryfall-types"; 2 + import { CardImage } from "../CardImage"; 3 + 4 + interface CardPreviewPaneProps { 5 + cardId: ScryfallId | null; 6 + } 7 + 8 + export function CardPreviewPane({ cardId }: CardPreviewPaneProps) { 9 + if (!cardId) { 10 + return ( 11 + <div className="sticky top-8 bg-gray-100 dark:bg-slate-800 rounded-lg p-6 h-[600px] flex items-center justify-center border border-gray-300 dark:border-slate-700"> 12 + <p className="text-gray-500 dark:text-gray-400 text-center"> 13 + Hover over a card to preview it here 14 + </p> 15 + </div> 16 + ); 17 + } 18 + 19 + // For now, just show placeholder until we integrate with card data 20 + return ( 21 + <div className="sticky top-8 bg-gray-100 dark:bg-slate-800 rounded-lg p-6 h-[600px] flex items-center justify-center border border-gray-300 dark:border-slate-700"> 22 + <div className="max-w-full max-h-full flex items-center justify-center"> 23 + <CardImage 24 + card={{ id: cardId, name: "Card" } as any} 25 + size="large" 26 + className="shadow-[0_8px_30px_rgba(0,0,0,0.4)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.8)] max-w-full h-auto max-h-full object-contain rounded-[4.75%/3.5%]" 27 + /> 28 + </div> 29 + </div> 30 + ); 31 + }
+97
src/components/deck/DeckHeader.tsx
··· 1 + import { useState } from "react"; 2 + 3 + interface DeckHeaderProps { 4 + name: string; 5 + format?: string; 6 + onNameChange: (name: string) => void; 7 + onFormatChange: (format: string) => void; 8 + } 9 + 10 + export function DeckHeader({ 11 + name, 12 + format, 13 + onNameChange, 14 + onFormatChange, 15 + }: DeckHeaderProps) { 16 + const [isEditingName, setIsEditingName] = useState(false); 17 + const [editedName, setEditedName] = useState(name); 18 + 19 + const handleNameClick = () => { 20 + setEditedName(name); 21 + setIsEditingName(true); 22 + }; 23 + 24 + const handleNameSubmit = () => { 25 + onNameChange(editedName || "Untitled Deck"); 26 + setIsEditingName(false); 27 + }; 28 + 29 + const handleNameKeyDown = (e: React.KeyboardEvent) => { 30 + if (e.key === "Enter") { 31 + handleNameSubmit(); 32 + } else if (e.key === "Escape") { 33 + setEditedName(name); 34 + setIsEditingName(false); 35 + } 36 + }; 37 + 38 + return ( 39 + <div className="mb-6 space-y-4"> 40 + <div className="flex items-center gap-4"> 41 + {isEditingName ? ( 42 + <input 43 + type="text" 44 + value={editedName} 45 + onChange={(e) => setEditedName(e.target.value)} 46 + onBlur={handleNameSubmit} 47 + onKeyDown={handleNameKeyDown} 48 + className="text-4xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-cyan-500 focus:outline-none flex-1" 49 + autoFocus 50 + /> 51 + ) : ( 52 + <h1 53 + className="text-4xl font-bold text-gray-900 dark:text-white cursor-pointer hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors" 54 + onClick={handleNameClick} 55 + onKeyDown={(e) => { 56 + if (e.key === "Enter" || e.key === " ") { 57 + handleNameClick(); 58 + } 59 + }} 60 + tabIndex={0} 61 + > 62 + {name} 63 + </h1> 64 + )} 65 + 66 + <select 67 + value={format || ""} 68 + onChange={(e) => onFormatChange(e.target.value)} 69 + className="px-4 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500 transition-colors" 70 + > 71 + <option value="">No Format</option> 72 + <option value="commander">Commander</option> 73 + <option value="cube">Cube</option> 74 + <option value="pauper">Pauper</option> 75 + <option value="paupercommander">Pauper Commander (PDH)</option> 76 + <option value="standard">Standard</option> 77 + <option value="modern">Modern</option> 78 + <option value="legacy">Legacy</option> 79 + <option value="vintage">Vintage</option> 80 + </select> 81 + </div> 82 + 83 + {/* TODO: Search autocomplete will go here */} 84 + <div className="relative"> 85 + <input 86 + type="text" 87 + placeholder="Search for cards to add..." 88 + className="w-full px-4 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 transition-colors" 89 + disabled 90 + /> 91 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> 92 + Card search coming soon 93 + </p> 94 + </div> 95 + </div> 96 + ); 97 + }
+72
src/components/deck/DeckSection.tsx
··· 1 + import type { Section, DeckCard } from "@/lib/deck-types"; 2 + import type { ScryfallId } from "@/lib/scryfall-types"; 3 + 4 + interface DeckSectionProps { 5 + section: Section; 6 + cards: DeckCard[]; 7 + onCardHover?: (cardId: ScryfallId | null) => void; 8 + } 9 + 10 + export function DeckSection({ section, cards, onCardHover }: DeckSectionProps) { 11 + const sectionNames: Record<Section, string> = { 12 + commander: "Commander", 13 + mainboard: "Mainboard", 14 + sideboard: "Sideboard", 15 + maybeboard: "Maybeboard", 16 + }; 17 + 18 + const totalQuantity = cards.reduce((sum, card) => sum + card.quantity, 0); 19 + 20 + return ( 21 + <div className="mb-8"> 22 + <div className="flex items-center justify-between mb-4"> 23 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 24 + {sectionNames[section]} 25 + </h2> 26 + <span className="text-lg text-gray-600 dark:text-gray-400"> 27 + {totalQuantity} {totalQuantity === 1 ? "card" : "cards"} 28 + </span> 29 + </div> 30 + 31 + {cards.length === 0 ? ( 32 + <div className="bg-gray-100 dark:bg-slate-800 rounded-lg p-6 border-2 border-dashed border-gray-300 dark:border-slate-700"> 33 + <p className="text-gray-500 dark:text-gray-400 text-center"> 34 + No cards in {sectionNames[section].toLowerCase()} 35 + </p> 36 + </div> 37 + ) : ( 38 + <div className="space-y-1"> 39 + {cards.map((card, index) => ( 40 + <div 41 + key={`${card.scryfallId}-${index}`} 42 + className="bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 rounded px-4 py-2 cursor-pointer transition-colors" 43 + onMouseEnter={() => onCardHover?.(card.scryfallId)} 44 + onMouseLeave={() => onCardHover?.(null)} 45 + > 46 + <div className="flex items-center gap-4"> 47 + <span className="text-gray-600 dark:text-gray-400 font-mono w-8 text-right"> 48 + {card.quantity}x 49 + </span> 50 + <span className="text-gray-900 dark:text-white flex-1"> 51 + {card.scryfallId} 52 + </span> 53 + {card.tags && card.tags.length > 0 && ( 54 + <div className="flex gap-2"> 55 + {card.tags.map((tag) => ( 56 + <span 57 + key={tag} 58 + className="px-2 py-1 bg-cyan-100 dark:bg-cyan-900 text-cyan-800 dark:text-cyan-200 text-xs rounded" 59 + > 60 + {tag} 61 + </span> 62 + ))} 63 + </div> 64 + )} 65 + </div> 66 + </div> 67 + ))} 68 + </div> 69 + )} 70 + </div> 71 + ); 72 + }
+195
src/lib/deck-types.ts
··· 1 + /** 2 + * Type definitions for deck state 3 + * Based on generated lexicon types from com.deckbelcher.deck.list 4 + */ 5 + 6 + import type { ScryfallId } from "./scryfall-types"; 7 + import type { ComDeckbelcherDeckList } from "./lexicons/index"; 8 + 9 + export type Section = "commander" | "mainboard" | "sideboard" | "maybeboard"; 10 + 11 + export type DeckCard = Omit<ComDeckbelcherDeckList.Card, "scryfallId"> & { 12 + scryfallId: ScryfallId; 13 + }; 14 + 15 + export type Deck = Omit<ComDeckbelcherDeckList.Main, "cards"> & { 16 + cards: DeckCard[]; 17 + }; 18 + 19 + /** 20 + * View configuration for deck display 21 + */ 22 + export type ViewStyle = "text" | "grid" | "stacks"; 23 + export type GroupBy = "tag" | "type" | "manaValue" | "none"; 24 + export type SortBy = "name" | "manaValue" | "rarity"; 25 + 26 + export interface ViewConfig { 27 + style: ViewStyle; 28 + groupBy: GroupBy; 29 + sortBy: SortBy; 30 + showManaCost: boolean; 31 + showSetSymbol: boolean; 32 + } 33 + 34 + /** 35 + * Helper to get cards for a specific section 36 + */ 37 + export function getCardsInSection(deck: Deck, section: Section): DeckCard[] { 38 + return deck.cards.filter((card) => card.section === section); 39 + } 40 + 41 + /** 42 + * Helper to count cards in a section 43 + */ 44 + export function countCardsInSection(deck: Deck, section: Section): number { 45 + return getCardsInSection(deck, section).reduce( 46 + (sum, card) => sum + card.quantity, 47 + 0, 48 + ); 49 + } 50 + 51 + /** 52 + * Helper to check if a card exists in a section 53 + */ 54 + export function findCardInSection( 55 + deck: Deck, 56 + scryfallId: ScryfallId, 57 + section: Section, 58 + ): DeckCard | undefined { 59 + return deck.cards.find( 60 + (card) => card.scryfallId === scryfallId && card.section === section, 61 + ); 62 + } 63 + 64 + /** 65 + * Add a card to the deck (or increment quantity if it exists) 66 + */ 67 + export function addCardToDeck( 68 + deck: Deck, 69 + scryfallId: ScryfallId, 70 + section: Section, 71 + quantity = 1, 72 + ): Deck { 73 + const existingCard = findCardInSection(deck, scryfallId, section); 74 + 75 + if (existingCard) { 76 + return { 77 + ...deck, 78 + cards: deck.cards.map((card) => 79 + card === existingCard 80 + ? { ...card, quantity: card.quantity + quantity } 81 + : card, 82 + ), 83 + updatedAt: new Date().toISOString(), 84 + }; 85 + } 86 + 87 + return { 88 + ...deck, 89 + cards: [...deck.cards, { scryfallId, quantity, section, tags: [] }], 90 + updatedAt: new Date().toISOString(), 91 + }; 92 + } 93 + 94 + /** 95 + * Remove a card from the deck 96 + */ 97 + export function removeCardFromDeck( 98 + deck: Deck, 99 + scryfallId: ScryfallId, 100 + section: Section, 101 + ): Deck { 102 + return { 103 + ...deck, 104 + cards: deck.cards.filter( 105 + (card) => !(card.scryfallId === scryfallId && card.section === section), 106 + ), 107 + updatedAt: new Date().toISOString(), 108 + }; 109 + } 110 + 111 + /** 112 + * Update a card's quantity 113 + */ 114 + export function updateCardQuantity( 115 + deck: Deck, 116 + scryfallId: ScryfallId, 117 + section: Section, 118 + quantity: number, 119 + ): Deck { 120 + if (quantity <= 0) { 121 + return removeCardFromDeck(deck, scryfallId, section); 122 + } 123 + 124 + return { 125 + ...deck, 126 + cards: deck.cards.map((card) => 127 + card.scryfallId === scryfallId && card.section === section 128 + ? { ...card, quantity } 129 + : card, 130 + ), 131 + updatedAt: new Date().toISOString(), 132 + }; 133 + } 134 + 135 + /** 136 + * Update a card's tags 137 + */ 138 + export function updateCardTags( 139 + deck: Deck, 140 + scryfallId: ScryfallId, 141 + section: Section, 142 + tags: string[], 143 + ): Deck { 144 + return { 145 + ...deck, 146 + cards: deck.cards.map((card) => 147 + card.scryfallId === scryfallId && card.section === section 148 + ? { ...card, tags } 149 + : card, 150 + ), 151 + updatedAt: new Date().toISOString(), 152 + }; 153 + } 154 + 155 + /** 156 + * Move a card to a different section 157 + */ 158 + export function moveCardToSection( 159 + deck: Deck, 160 + scryfallId: ScryfallId, 161 + fromSection: Section, 162 + toSection: Section, 163 + ): Deck { 164 + const card = findCardInSection(deck, scryfallId, fromSection); 165 + if (!card) { 166 + return deck; 167 + } 168 + 169 + const targetCard = findCardInSection(deck, scryfallId, toSection); 170 + if (targetCard) { 171 + return { 172 + ...deck, 173 + cards: deck.cards 174 + .filter( 175 + (c) => !(c.scryfallId === scryfallId && c.section === fromSection), 176 + ) 177 + .map((c) => 178 + c.scryfallId === scryfallId && c.section === toSection 179 + ? { ...c, quantity: c.quantity + card.quantity } 180 + : c, 181 + ), 182 + updatedAt: new Date().toISOString(), 183 + }; 184 + } 185 + 186 + return { 187 + ...deck, 188 + cards: deck.cards.map((c) => 189 + c.scryfallId === scryfallId && c.section === fromSection 190 + ? { ...c, section: toSection } 191 + : c, 192 + ), 193 + updatedAt: new Date().toISOString(), 194 + }; 195 + }
+5
src/lib/lexicons/index.ts
··· 1 + export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 2 + export * as ComDeckbelcherActorProfile from "./types/com/deckbelcher/actor/profile.js"; 3 + export * as ComDeckbelcherDeckList from "./types/com/deckbelcher/deck/list.js"; 4 + export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js"; 5 + export * as ComDeckbelcherSocialLike from "./types/com/deckbelcher/social/like.js";
+18
src/lib/lexicons/types/com/atproto/repo/strongRef.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _mainSchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.atproto.repo.strongRef"), 7 + ), 8 + cid: /*#__PURE__*/ v.cidString(), 9 + uri: /*#__PURE__*/ v.resourceUriString(), 10 + }); 11 + 12 + type main$schematype = typeof _mainSchema; 13 + 14 + export interface mainSchema extends main$schematype {} 15 + 16 + export const mainSchema = _mainSchema as mainSchema; 17 + 18 + export interface Main extends v.InferInput<typeof mainSchema> {}
+70
src/lib/lexicons/types/com/deckbelcher/actor/profile.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComDeckbelcherRichtextFacet from "../richtext/facet.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.literal("self"), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.actor.profile"), 10 + /** 11 + * Timestamp when the profile was created. 12 + */ 13 + createdAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Free-form profile description. 16 + * @maxLength 2560 17 + * @maxGraphemes 256 18 + */ 19 + description: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 + /*#__PURE__*/ v.stringLength(0, 2560), 22 + /*#__PURE__*/ v.stringGraphemes(0, 256), 23 + ]), 24 + ), 25 + /** 26 + * Annotations of text in the profile description (mentions, URLs, hashtags, etc). 27 + */ 28 + get descriptionFacets() { 29 + return /*#__PURE__*/ v.optional( 30 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 31 + ); 32 + }, 33 + /** 34 + * User's display name. 35 + * @maxLength 640 36 + * @maxGraphemes 64 37 + */ 38 + displayName: /*#__PURE__*/ v.optional( 39 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 40 + /*#__PURE__*/ v.stringLength(0, 640), 41 + /*#__PURE__*/ v.stringGraphemes(0, 64), 42 + ]), 43 + ), 44 + /** 45 + * Free-form pronouns text. 46 + * @maxLength 200 47 + * @maxGraphemes 20 48 + */ 49 + pronouns: /*#__PURE__*/ v.optional( 50 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 + /*#__PURE__*/ v.stringLength(0, 200), 52 + /*#__PURE__*/ v.stringGraphemes(0, 20), 53 + ]), 54 + ), 55 + }), 56 + ); 57 + 58 + type main$schematype = typeof _mainSchema; 59 + 60 + export interface mainSchema extends main$schematype {} 61 + 62 + export const mainSchema = _mainSchema as mainSchema; 63 + 64 + export interface Main extends v.InferInput<typeof mainSchema> {} 65 + 66 + declare module "@atcute/lexicons/ambient" { 67 + interface Records { 68 + "com.deckbelcher.actor.profile": mainSchema; 69 + } 70 + }
+119
src/lib/lexicons/types/com/deckbelcher/deck/list.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComDeckbelcherRichtextFacet from "../richtext/facet.js"; 5 + 6 + const _cardSchema = /*#__PURE__*/ v.object({ 7 + $type: /*#__PURE__*/ v.optional( 8 + /*#__PURE__*/ v.literal("com.deckbelcher.deck.list#card"), 9 + ), 10 + /** 11 + * Number of copies in the deck. 12 + * @minimum 1 13 + */ 14 + quantity: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 15 + /*#__PURE__*/ v.integerRange(1), 16 + ]), 17 + /** 18 + * Scryfall UUID for the specific printing. 19 + */ 20 + scryfallId: /*#__PURE__*/ v.string(), 21 + /** 22 + * Which section of the deck this card belongs to. Extensible to support format-specific sections. 23 + */ 24 + section: /*#__PURE__*/ v.string< 25 + "commander" | "mainboard" | "maybeboard" | "sideboard" | (string & {}) 26 + >(), 27 + /** 28 + * User annotations for this card in this deck (e.g., "removal", "wincon", "ramp"). 29 + * @maxLength 128 30 + */ 31 + tags: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain( 33 + /*#__PURE__*/ v.array( 34 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 35 + /*#__PURE__*/ v.stringLength(0, 640), 36 + /*#__PURE__*/ v.stringGraphemes(0, 64), 37 + ]), 38 + ), 39 + [/*#__PURE__*/ v.arrayLength(0, 128)], 40 + ), 41 + ), 42 + }); 43 + const _mainSchema = /*#__PURE__*/ v.record( 44 + /*#__PURE__*/ v.tidString(), 45 + /*#__PURE__*/ v.object({ 46 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.deck.list"), 47 + /** 48 + * Array of cards in the decklist. 49 + */ 50 + get cards() { 51 + return /*#__PURE__*/ v.array(cardSchema); 52 + }, 53 + /** 54 + * Timestamp when the decklist was created. 55 + */ 56 + createdAt: /*#__PURE__*/ v.datetimeString(), 57 + /** 58 + * Format of the deck (e.g., "commander", "cube", "pauper"). 59 + * @maxLength 320 60 + * @maxGraphemes 32 61 + */ 62 + format: /*#__PURE__*/ v.optional( 63 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 64 + /*#__PURE__*/ v.stringLength(0, 320), 65 + /*#__PURE__*/ v.stringGraphemes(0, 32), 66 + ]), 67 + ), 68 + /** 69 + * Name of the decklist. 70 + * @maxLength 1280 71 + * @maxGraphemes 128 72 + */ 73 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 74 + /*#__PURE__*/ v.stringLength(0, 1280), 75 + /*#__PURE__*/ v.stringGraphemes(0, 128), 76 + ]), 77 + /** 78 + * Deck primer with strategy, combos, and card choices. 79 + * @maxLength 100000 80 + * @maxGraphemes 10000 81 + */ 82 + primer: /*#__PURE__*/ v.optional( 83 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 84 + /*#__PURE__*/ v.stringLength(0, 100000), 85 + /*#__PURE__*/ v.stringGraphemes(0, 10000), 86 + ]), 87 + ), 88 + /** 89 + * Annotations of text in the primer (mentions, URLs, hashtags, card references, etc). 90 + */ 91 + get primerFacets() { 92 + return /*#__PURE__*/ v.optional( 93 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 94 + ); 95 + }, 96 + /** 97 + * Timestamp when the decklist was last updated. 98 + */ 99 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 100 + }), 101 + ); 102 + 103 + type card$schematype = typeof _cardSchema; 104 + type main$schematype = typeof _mainSchema; 105 + 106 + export interface cardSchema extends card$schematype {} 107 + export interface mainSchema extends main$schematype {} 108 + 109 + export const cardSchema = _cardSchema as cardSchema; 110 + export const mainSchema = _mainSchema as mainSchema; 111 + 112 + export interface Card extends v.InferInput<typeof cardSchema> {} 113 + export interface Main extends v.InferInput<typeof mainSchema> {} 114 + 115 + declare module "@atcute/lexicons/ambient" { 116 + interface Records { 117 + "com.deckbelcher.deck.list": mainSchema; 118 + } 119 + }
+78
src/lib/lexicons/types/com/deckbelcher/richtext/facet.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _byteSliceSchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#byteSlice"), 7 + ), 8 + /** 9 + * @minimum 0 10 + */ 11 + byteEnd: /*#__PURE__*/ v.integer(), 12 + /** 13 + * @minimum 0 14 + */ 15 + byteStart: /*#__PURE__*/ v.integer(), 16 + }); 17 + const _linkSchema = /*#__PURE__*/ v.object({ 18 + $type: /*#__PURE__*/ v.optional( 19 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#link"), 20 + ), 21 + uri: /*#__PURE__*/ v.genericUriString(), 22 + }); 23 + const _mainSchema = /*#__PURE__*/ v.object({ 24 + $type: /*#__PURE__*/ v.optional( 25 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet"), 26 + ), 27 + get features() { 28 + return /*#__PURE__*/ v.array( 29 + /*#__PURE__*/ v.variant([linkSchema, mentionSchema, tagSchema]), 30 + ); 31 + }, 32 + get index() { 33 + return byteSliceSchema; 34 + }, 35 + }); 36 + const _mentionSchema = /*#__PURE__*/ v.object({ 37 + $type: /*#__PURE__*/ v.optional( 38 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#mention"), 39 + ), 40 + did: /*#__PURE__*/ v.didString(), 41 + }); 42 + const _tagSchema = /*#__PURE__*/ v.object({ 43 + $type: /*#__PURE__*/ v.optional( 44 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#tag"), 45 + ), 46 + /** 47 + * @maxLength 640 48 + * @maxGraphemes 64 49 + */ 50 + tag: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 + /*#__PURE__*/ v.stringLength(0, 640), 52 + /*#__PURE__*/ v.stringGraphemes(0, 64), 53 + ]), 54 + }); 55 + 56 + type byteSlice$schematype = typeof _byteSliceSchema; 57 + type link$schematype = typeof _linkSchema; 58 + type main$schematype = typeof _mainSchema; 59 + type mention$schematype = typeof _mentionSchema; 60 + type tag$schematype = typeof _tagSchema; 61 + 62 + export interface byteSliceSchema extends byteSlice$schematype {} 63 + export interface linkSchema extends link$schematype {} 64 + export interface mainSchema extends main$schematype {} 65 + export interface mentionSchema extends mention$schematype {} 66 + export interface tagSchema extends tag$schematype {} 67 + 68 + export const byteSliceSchema = _byteSliceSchema as byteSliceSchema; 69 + export const linkSchema = _linkSchema as linkSchema; 70 + export const mainSchema = _mainSchema as mainSchema; 71 + export const mentionSchema = _mentionSchema as mentionSchema; 72 + export const tagSchema = _tagSchema as tagSchema; 73 + 74 + export interface ByteSlice extends v.InferInput<typeof byteSliceSchema> {} 75 + export interface Link extends v.InferInput<typeof linkSchema> {} 76 + export interface Main extends v.InferInput<typeof mainSchema> {} 77 + export interface Mention extends v.InferInput<typeof mentionSchema> {} 78 + export interface Tag extends v.InferInput<typeof tagSchema> {}
+35
src/lib/lexicons/types/com/deckbelcher/social/like.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoRepoStrongRef from "../../atproto/repo/strongRef.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.social.like"), 10 + /** 11 + * Timestamp when the like was created. 12 + */ 13 + createdAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Reference to the content being liked. 16 + */ 17 + get subject() { 18 + return ComAtprotoRepoStrongRef.mainSchema; 19 + }, 20 + }), 21 + ); 22 + 23 + type main$schematype = typeof _mainSchema; 24 + 25 + export interface mainSchema extends main$schematype {} 26 + 27 + export const mainSchema = _mainSchema as mainSchema; 28 + 29 + export interface Main extends v.InferInput<typeof mainSchema> {} 30 + 31 + declare module "@atcute/lexicons/ambient" { 32 + interface Records { 33 + "com.deckbelcher.social.like": mainSchema; 34 + } 35 + }
+61 -3
src/routeTree.gen.ts
··· 13 13 import { Route as IndexRouteImport } from './routes/index' 14 14 import { Route as CardsIndexRouteImport } from './routes/cards/index' 15 15 import { Route as OauthCallbackRouteImport } from './routes/oauth/callback' 16 + import { Route as DeckNewRouteImport } from './routes/deck/new' 17 + import { Route as DeckIdRouteImport } from './routes/deck/$id' 16 18 import { Route as CardIdRouteImport } from './routes/card/$id' 17 19 18 20 const SigninRoute = SigninRouteImport.update({ ··· 35 37 path: '/oauth/callback', 36 38 getParentRoute: () => rootRouteImport, 37 39 } as any) 40 + const DeckNewRoute = DeckNewRouteImport.update({ 41 + id: '/deck/new', 42 + path: '/deck/new', 43 + getParentRoute: () => rootRouteImport, 44 + } as any) 45 + const DeckIdRoute = DeckIdRouteImport.update({ 46 + id: '/deck/$id', 47 + path: '/deck/$id', 48 + getParentRoute: () => rootRouteImport, 49 + } as any) 38 50 const CardIdRoute = CardIdRouteImport.update({ 39 51 id: '/card/$id', 40 52 path: '/card/$id', ··· 45 57 '/': typeof IndexRoute 46 58 '/signin': typeof SigninRoute 47 59 '/card/$id': typeof CardIdRoute 60 + '/deck/$id': typeof DeckIdRoute 61 + '/deck/new': typeof DeckNewRoute 48 62 '/oauth/callback': typeof OauthCallbackRoute 49 63 '/cards': typeof CardsIndexRoute 50 64 } ··· 52 66 '/': typeof IndexRoute 53 67 '/signin': typeof SigninRoute 54 68 '/card/$id': typeof CardIdRoute 69 + '/deck/$id': typeof DeckIdRoute 70 + '/deck/new': typeof DeckNewRoute 55 71 '/oauth/callback': typeof OauthCallbackRoute 56 72 '/cards': typeof CardsIndexRoute 57 73 } ··· 60 76 '/': typeof IndexRoute 61 77 '/signin': typeof SigninRoute 62 78 '/card/$id': typeof CardIdRoute 79 + '/deck/$id': typeof DeckIdRoute 80 + '/deck/new': typeof DeckNewRoute 63 81 '/oauth/callback': typeof OauthCallbackRoute 64 82 '/cards/': typeof CardsIndexRoute 65 83 } 66 84 export interface FileRouteTypes { 67 85 fileRoutesByFullPath: FileRoutesByFullPath 68 - fullPaths: '/' | '/signin' | '/card/$id' | '/oauth/callback' | '/cards' 86 + fullPaths: 87 + | '/' 88 + | '/signin' 89 + | '/card/$id' 90 + | '/deck/$id' 91 + | '/deck/new' 92 + | '/oauth/callback' 93 + | '/cards' 69 94 fileRoutesByTo: FileRoutesByTo 70 - to: '/' | '/signin' | '/card/$id' | '/oauth/callback' | '/cards' 71 - id: '__root__' | '/' | '/signin' | '/card/$id' | '/oauth/callback' | '/cards/' 95 + to: 96 + | '/' 97 + | '/signin' 98 + | '/card/$id' 99 + | '/deck/$id' 100 + | '/deck/new' 101 + | '/oauth/callback' 102 + | '/cards' 103 + id: 104 + | '__root__' 105 + | '/' 106 + | '/signin' 107 + | '/card/$id' 108 + | '/deck/$id' 109 + | '/deck/new' 110 + | '/oauth/callback' 111 + | '/cards/' 72 112 fileRoutesById: FileRoutesById 73 113 } 74 114 export interface RootRouteChildren { 75 115 IndexRoute: typeof IndexRoute 76 116 SigninRoute: typeof SigninRoute 77 117 CardIdRoute: typeof CardIdRoute 118 + DeckIdRoute: typeof DeckIdRoute 119 + DeckNewRoute: typeof DeckNewRoute 78 120 OauthCallbackRoute: typeof OauthCallbackRoute 79 121 CardsIndexRoute: typeof CardsIndexRoute 80 122 } ··· 109 151 preLoaderRoute: typeof OauthCallbackRouteImport 110 152 parentRoute: typeof rootRouteImport 111 153 } 154 + '/deck/new': { 155 + id: '/deck/new' 156 + path: '/deck/new' 157 + fullPath: '/deck/new' 158 + preLoaderRoute: typeof DeckNewRouteImport 159 + parentRoute: typeof rootRouteImport 160 + } 161 + '/deck/$id': { 162 + id: '/deck/$id' 163 + path: '/deck/$id' 164 + fullPath: '/deck/$id' 165 + preLoaderRoute: typeof DeckIdRouteImport 166 + parentRoute: typeof rootRouteImport 167 + } 112 168 '/card/$id': { 113 169 id: '/card/$id' 114 170 path: '/card/$id' ··· 123 179 IndexRoute: IndexRoute, 124 180 SigninRoute: SigninRoute, 125 181 CardIdRoute: CardIdRoute, 182 + DeckIdRoute: DeckIdRoute, 183 + DeckNewRoute: DeckNewRoute, 126 184 OauthCallbackRoute: OauthCallbackRoute, 127 185 CardsIndexRoute: CardsIndexRoute, 128 186 }
+104
src/routes/deck/$id.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { useState } from "react"; 3 + import type { Deck } from "@/lib/deck-types"; 4 + import { asScryfallId, type ScryfallId } from "@/lib/scryfall-types"; 5 + import { DeckHeader } from "@/components/deck/DeckHeader"; 6 + import { CardPreviewPane } from "@/components/deck/CardPreviewPane"; 7 + import { DeckSection } from "@/components/deck/DeckSection"; 8 + import { getCardsInSection } from "@/lib/deck-types"; 9 + 10 + export const Route = createFileRoute("/deck/$id")({ 11 + component: DeckEditorPage, 12 + }); 13 + 14 + function DeckEditorPage() { 15 + // Initialize deck with some test data 16 + const [deck, setDeck] = useState<Deck>(() => { 17 + // TODO: Load from ATProto when persistence is implemented 18 + // For now, create test deck with hardcoded cards 19 + return { 20 + $type: "com.deckbelcher.deck.list", 21 + name: "Test Commander Deck", 22 + format: "commander", 23 + cards: [ 24 + { 25 + scryfallId: asScryfallId("c73ae1f0-60b6-4c4a-975b-13e659a33f50"), 26 + quantity: 1, 27 + section: "commander", 28 + tags: [], 29 + }, 30 + { 31 + scryfallId: asScryfallId("35d73022-46ed-402b-90a1-e3e4a281ce1e"), 32 + quantity: 1, 33 + section: "mainboard", 34 + tags: ["removal", "instant"], 35 + }, 36 + { 37 + scryfallId: asScryfallId("2adc7dd4-d9c4-47ce-ac94-bb56dbf4044e"), 38 + quantity: 1, 39 + section: "mainboard", 40 + tags: ["ramp"], 41 + }, 42 + ], 43 + createdAt: new Date().toISOString(), 44 + }; 45 + }); 46 + 47 + const [hoveredCard, setHoveredCard] = useState<ScryfallId | null>(null); 48 + 49 + const handleNameChange = (name: string) => { 50 + setDeck((prev) => ({ ...prev, name, updatedAt: new Date().toISOString() })); 51 + }; 52 + 53 + const handleFormatChange = (format: string) => { 54 + setDeck((prev) => ({ 55 + ...prev, 56 + format, 57 + updatedAt: new Date().toISOString(), 58 + })); 59 + }; 60 + 61 + return ( 62 + <div className="min-h-screen bg-white dark:bg-slate-900"> 63 + <div className="max-w-7xl mx-auto px-6 py-8"> 64 + <DeckHeader 65 + name={deck.name} 66 + format={deck.format} 67 + onNameChange={handleNameChange} 68 + onFormatChange={handleFormatChange} 69 + /> 70 + 71 + <div className="grid grid-cols-1 lg:grid-cols-5 gap-6"> 72 + {/* Left pane: Card preview (40%) */} 73 + <div className="lg:col-span-2"> 74 + <CardPreviewPane cardId={hoveredCard} /> 75 + </div> 76 + 77 + {/* Right pane: Deck sections (60%) */} 78 + <div className="lg:col-span-3"> 79 + <DeckSection 80 + section="commander" 81 + cards={getCardsInSection(deck, "commander")} 82 + onCardHover={setHoveredCard} 83 + /> 84 + <DeckSection 85 + section="mainboard" 86 + cards={getCardsInSection(deck, "mainboard")} 87 + onCardHover={setHoveredCard} 88 + /> 89 + <DeckSection 90 + section="sideboard" 91 + cards={getCardsInSection(deck, "sideboard")} 92 + onCardHover={setHoveredCard} 93 + /> 94 + <DeckSection 95 + section="maybeboard" 96 + cards={getCardsInSection(deck, "maybeboard")} 97 + onCardHover={setHoveredCard} 98 + /> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + ); 104 + }
+99
src/routes/deck/new.tsx
··· 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useState } from "react"; 3 + 4 + export const Route = createFileRoute("/deck/new")({ 5 + component: NewDeckPage, 6 + }); 7 + 8 + function NewDeckPage() { 9 + const navigate = useNavigate(); 10 + const [name, setName] = useState(""); 11 + const [format, setFormat] = useState<string>("commander"); 12 + 13 + const handleSubmit = (e: React.FormEvent) => { 14 + e.preventDefault(); 15 + 16 + // TODO: When ATProto persistence is added, create the record here 17 + // and use the actual rkey. For now, use a fixed draft ID. 18 + const draftId = "draft"; 19 + 20 + // TODO: Pass initial deck data (name, format) to editor 21 + // For now, just navigate - will implement state passing later 22 + navigate({ 23 + to: "/deck/$id", 24 + params: { id: draftId }, 25 + }); 26 + }; 27 + 28 + return ( 29 + <div className="min-h-screen bg-white dark:bg-slate-900"> 30 + <div className="max-w-2xl mx-auto px-6 py-16"> 31 + <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-8"> 32 + Create New Deck 33 + </h1> 34 + 35 + <form onSubmit={handleSubmit} className="space-y-6"> 36 + <div> 37 + <label 38 + htmlFor="name" 39 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" 40 + > 41 + Deck Name 42 + </label> 43 + <input 44 + id="name" 45 + type="text" 46 + value={name} 47 + onChange={(e) => setName(e.target.value)} 48 + placeholder="Enter deck name..." 49 + className="w-full px-4 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 transition-colors" 50 + autoFocus 51 + /> 52 + </div> 53 + 54 + <div> 55 + <label 56 + htmlFor="format" 57 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" 58 + > 59 + Format 60 + </label> 61 + <select 62 + id="format" 63 + value={format} 64 + onChange={(e) => setFormat(e.target.value)} 65 + className="w-full px-4 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500 transition-colors" 66 + > 67 + <option value="commander">Commander</option> 68 + <option value="cube">Cube</option> 69 + <option value="pauper">Pauper</option> 70 + <option value="paupercommander">Pauper Commander (PDH)</option> 71 + <option value="standard">Standard</option> 72 + <option value="modern">Modern</option> 73 + <option value="legacy">Legacy</option> 74 + <option value="vintage">Vintage</option> 75 + </select> 76 + </div> 77 + 78 + {/* TODO: Add commander selection if format is commander/paupercommander */} 79 + 80 + <div className="flex gap-4 pt-4"> 81 + <button 82 + type="submit" 83 + className="flex-1 px-6 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 84 + > 85 + Create Deck 86 + </button> 87 + <button 88 + type="button" 89 + onClick={() => navigate({ to: "/" })} 90 + className="px-6 py-3 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white font-medium rounded-lg transition-colors" 91 + > 92 + Cancel 93 + </button> 94 + </div> 95 + </form> 96 + </div> 97 + </div> 98 + ); 99 + }