👁️
5
fork

Configure Feed

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

live preview to bulk editor

+285 -58
+99
src/components/deck/BulkEditPreview.tsx
··· 1 + import { AlertCircle } from "lucide-react"; 2 + import { useCardHover } from "@/components/HoverCardPreview"; 3 + import { ManaCost } from "@/components/ManaCost"; 4 + import { getPrimaryFace } from "@/lib/card-faces"; 5 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 6 + 7 + export type PreviewLine = 8 + | { type: "empty"; lineKey: string } 9 + | { type: "pending"; lineKey: string; name: string } 10 + | { 11 + type: "resolved"; 12 + lineKey: string; 13 + scryfallId: ScryfallId; 14 + quantity: number; 15 + cardData: Card; 16 + isNew?: boolean; 17 + isImperfect?: boolean; 18 + } 19 + | { type: "error"; lineKey: string; message: string }; 20 + 21 + interface BulkEditPreviewProps { 22 + lines: PreviewLine[]; 23 + } 24 + 25 + export function BulkEditPreview({ lines }: BulkEditPreviewProps) { 26 + return ( 27 + <div className="flex-1 p-4 border-l border-gray-200 dark:border-slate-700"> 28 + {lines.map((line) => ( 29 + <PreviewRow key={line.lineKey} line={line} /> 30 + ))} 31 + </div> 32 + ); 33 + } 34 + 35 + const ROW_CLASS = 36 + "font-mono text-sm leading-[1.5] whitespace-nowrap [font-variant-ligatures:none]"; 37 + 38 + function PreviewRow({ line }: { line: PreviewLine }) { 39 + switch (line.type) { 40 + case "empty": 41 + return <div className={ROW_CLASS}>&nbsp;</div>; 42 + 43 + case "pending": 44 + return ( 45 + <div className={ROW_CLASS}> 46 + <span className="text-gray-400 dark:text-gray-500 italic truncate"> 47 + {line.name}... 48 + </span> 49 + </div> 50 + ); 51 + 52 + case "error": 53 + return ( 54 + <div className={`${ROW_CLASS} flex items-center gap-2`}> 55 + <AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400 flex-shrink-0" /> 56 + <span className="text-red-600 dark:text-red-400 truncate"> 57 + {line.message} 58 + </span> 59 + </div> 60 + ); 61 + 62 + case "resolved": 63 + return <ResolvedCardRow line={line} />; 64 + } 65 + } 66 + 67 + function ResolvedCardRow({ 68 + line, 69 + }: { 70 + line: Extract<PreviewLine, { type: "resolved" }>; 71 + }) { 72 + const hoverProps = useCardHover(line.scryfallId); 73 + const primaryFace = getPrimaryFace(line.cardData); 74 + 75 + const bgClass = line.isImperfect 76 + ? "bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/30" 77 + : line.isNew 78 + ? "bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30" 79 + : "hover:bg-gray-100 dark:hover:bg-slate-800"; 80 + 81 + return ( 82 + <div 83 + className={`${ROW_CLASS} flex items-center gap-2 cursor-default rounded px-1 -mx-1 ${bgClass}`} 84 + {...hoverProps} 85 + > 86 + <span className="text-gray-600 dark:text-gray-400 text-xs w-4 text-right flex-shrink-0"> 87 + {line.quantity} 88 + </span> 89 + <span className="text-gray-900 dark:text-white truncate flex-1 min-w-0"> 90 + {primaryFace?.name ?? "Unknown"} 91 + </span> 92 + <div className="flex-shrink-0 flex items-center ml-auto"> 93 + {primaryFace?.mana_cost && ( 94 + <ManaCost cost={primaryFace.mana_cost} size="small" /> 95 + )} 96 + </div> 97 + </div> 98 + ); 99 + }
+2
src/lib/deck-import.ts
··· 26 26 oracleId: OracleId; 27 27 quantity: number; 28 28 tags: string[]; 29 + raw: string; 29 30 } 30 31 31 32 export interface ImportError { ··· 215 216 oracleId: baseCard.oracle_id, 216 217 quantity: line.quantity, 217 218 tags: line.tags, 219 + raw: line.raw, 218 220 }); 219 221 } catch (err) { 220 222 errors.push({
+184 -58
src/routes/profile/$did/deck/$rkey/bulk-edit.tsx
··· 3 3 import { createFileRoute, Link } from "@tanstack/react-router"; 4 4 import { useCallback, useEffect, useMemo, useState } from "react"; 5 5 import { toast } from "sonner"; 6 + import { 7 + BulkEditPreview, 8 + type PreviewLine, 9 + } from "@/components/deck/BulkEditPreview"; 6 10 import { asRkey } from "@/lib/atproto-client"; 7 11 import { getCardDataProvider } from "@/lib/card-data-provider"; 8 12 import { ··· 15 19 import type { Deck, DeckCard, Section } from "@/lib/deck-types"; 16 20 import { getCardsInSection } from "@/lib/deck-types"; 17 21 import { getCardByIdQueryOptions } from "@/lib/queries"; 18 - import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 22 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 19 23 import { useAuth } from "@/lib/useAuth"; 24 + import { useDebounce } from "@/lib/useDebounce"; 20 25 21 26 export const Route = createFileRoute("/profile/$did/deck/$rkey/bulk-edit")({ 22 27 component: BulkEditPage, ··· 81 86 [deck, getCardData], 82 87 ); 83 88 89 + const savedText = sectionToText(activeSection); 90 + const isDirty = text !== savedText; 91 + 92 + const textLines = useMemo(() => text.split("\n"), [text]); 93 + 84 94 useEffect(() => { 85 95 setText(sectionToText(activeSection)); 86 96 setErrors([]); ··· 93 103 setErrors([]); 94 104 95 105 try { 96 - const parsed = parseCardList(text); 106 + const parsedText = parseCardList(text); 97 107 const provider = await getCardDataProvider(); 98 - 99 - const lookupByName = async (name: string): Promise<Card[]> => { 100 - if (!provider.searchCards) return []; 101 - return provider.searchCards(name, undefined, 10); 102 - }; 103 - 104 - const getPrintings = async ( 105 - oracleId: OracleId, 106 - ): Promise<ScryfallId[]> => { 107 - return provider.getPrintingsByOracleId(oracleId); 108 - }; 109 - 110 - const getCardById = async (id: ScryfallId): Promise<Card | undefined> => { 111 - return provider.getCardById(id); 112 - }; 113 - 114 108 const result = await resolveCards( 115 - parsed, 116 - lookupByName, 117 - getPrintings, 118 - getCardById, 109 + parsedText, 110 + async (name) => 111 + provider.searchCards ? provider.searchCards(name, undefined, 10) : [], 112 + (oracleId) => provider.getPrintingsByOracleId(oracleId), 113 + (id) => provider.getCardById(id), 119 114 ); 120 115 121 116 if (result.errors.length > 0) { ··· 173 168 }; 174 169 175 170 const parsed = useMemo(() => parseCardList(text), [text]); 171 + const parsedByRaw = useMemo( 172 + () => new Map(parsed.map((p) => [p.raw, p])), 173 + [parsed], 174 + ); 176 175 const cardCount = useMemo( 177 176 () => parsed.reduce((sum, p) => sum + p.quantity, 0), 178 177 [parsed], 179 178 ); 180 179 const lineCount = parsed.length; 181 180 181 + const { value: debouncedParsed } = useDebounce(parsed, 300); 182 + 183 + const [resolvedMap, setResolvedMap] = useState< 184 + Map<string, { scryfallId: ScryfallId; cardData: Card }> 185 + >(new Map()); 186 + const [errorMap, setErrorMap] = useState<Map<string, string>>(new Map()); 187 + 188 + const savedCardIds = useMemo( 189 + () => 190 + new Set(getCardsInSection(deck, activeSection).map((c) => c.scryfallId)), 191 + [deck, activeSection], 192 + ); 193 + 194 + useEffect(() => { 195 + if (!debouncedParsed || debouncedParsed.length === 0) { 196 + setResolvedMap(new Map()); 197 + setErrorMap(new Map()); 198 + return; 199 + } 200 + 201 + let cancelled = false; 202 + 203 + (async () => { 204 + const provider = await getCardDataProvider(); 205 + const result = await resolveCards( 206 + debouncedParsed, 207 + async (name) => 208 + provider.searchCards ? provider.searchCards(name, undefined, 10) : [], 209 + (oracleId) => provider.getPrintingsByOracleId(oracleId), 210 + (id) => provider.getCardById(id), 211 + ); 212 + 213 + if (cancelled) return; 214 + 215 + const newErrors = new Map<string, string>(); 216 + for (const error of result.errors) { 217 + newErrors.set(error.raw, error.error); 218 + } 219 + 220 + const cardDataList = await Promise.all( 221 + result.resolved.map((r) => provider.getCardById(r.scryfallId)), 222 + ); 223 + 224 + if (cancelled) return; 225 + 226 + const newResolved = new Map< 227 + string, 228 + { scryfallId: ScryfallId; cardData: Card } 229 + >(); 230 + for (let i = 0; i < result.resolved.length; i++) { 231 + const resolved = result.resolved[i]; 232 + const cardData = cardDataList[i]; 233 + if (!cardData) continue; 234 + newResolved.set(resolved.raw, { 235 + scryfallId: resolved.scryfallId, 236 + cardData, 237 + }); 238 + } 239 + 240 + setResolvedMap(newResolved); 241 + setErrorMap(newErrors); 242 + })(); 243 + 244 + return () => { 245 + cancelled = true; 246 + }; 247 + }, [debouncedParsed]); 248 + 249 + const previewLines = useMemo(() => { 250 + const counts = new Map<string, number>(); 251 + return textLines.map((line): PreviewLine => { 252 + const trimmed = line.trim(); 253 + const occurrence = counts.get(trimmed) ?? 0; 254 + counts.set(trimmed, occurrence + 1); 255 + const lineKey = `${trimmed}:${occurrence}`; 256 + 257 + if (!trimmed) { 258 + return { type: "empty", lineKey }; 259 + } 260 + 261 + const error = errorMap.get(trimmed); 262 + if (error) { 263 + return { type: "error", lineKey, message: error }; 264 + } 265 + 266 + const parsedLine = parsedByRaw.get(trimmed); 267 + const resolved = resolvedMap.get(trimmed); 268 + if (resolved) { 269 + const isImperfect = 270 + parsedLine && 271 + parsedLine.name.toLowerCase() !== 272 + resolved.cardData.name.toLowerCase(); 273 + const isNew = !savedCardIds.has(resolved.scryfallId); 274 + return { 275 + type: "resolved", 276 + lineKey, 277 + scryfallId: resolved.scryfallId, 278 + quantity: parsedLine?.quantity ?? 1, 279 + cardData: resolved.cardData, 280 + isImperfect, 281 + isNew, 282 + }; 283 + } 284 + 285 + return { type: "pending", lineKey, name: parsedLine?.name ?? trimmed }; 286 + }); 287 + }, [textLines, resolvedMap, errorMap, parsedByRaw, savedCardIds]); 288 + 182 289 return ( 183 290 <div className="min-h-screen bg-white dark:bg-slate-900"> 184 - <div className="max-w-4xl mx-auto px-6 py-8"> 291 + <div className="max-w-6xl mx-auto px-6 py-8"> 185 292 <div className="mb-6"> 186 293 <Link 187 294 to="/profile/$did/deck/$rkey" ··· 200 307 201 308 {/* Section tabs */} 202 309 <div className="flex gap-1 mb-4 border-b border-gray-200 dark:border-slate-700"> 203 - {SECTIONS.map((section) => ( 204 - <button 205 - type="button" 206 - key={section.value} 207 - onClick={() => setActiveSection(section.value)} 208 - className={`px-4 py-2 text-sm font-medium transition-colors ${ 209 - activeSection === section.value 210 - ? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400" 211 - : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" 212 - }`} 213 - > 214 - {section.label} 215 - <span className="ml-1 text-xs text-gray-400"> 216 - ( 217 - {getCardsInSection(deck, section.value).reduce( 218 - (s, c) => s + c.quantity, 219 - 0, 220 - )} 221 - ) 222 - </span> 223 - </button> 224 - ))} 310 + {SECTIONS.map((section) => { 311 + const isActive = activeSection === section.value; 312 + const isDisabled = isDirty && !isActive; 313 + return ( 314 + <button 315 + type="button" 316 + key={section.value} 317 + onClick={() => setActiveSection(section.value)} 318 + disabled={isDisabled} 319 + className={`px-4 py-2 text-sm font-medium transition-colors ${ 320 + isActive 321 + ? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400" 322 + : isDisabled 323 + ? "text-gray-400 dark:text-gray-600 cursor-not-allowed" 324 + : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" 325 + }`} 326 + > 327 + {section.label} 328 + <span className="ml-1 text-xs text-gray-400"> 329 + ( 330 + {getCardsInSection(deck, section.value).reduce( 331 + (s, c) => s + c.quantity, 332 + 0, 333 + )} 334 + ) 335 + </span> 336 + </button> 337 + ); 338 + })} 225 339 </div> 226 340 227 341 {/* Format hint */} ··· 237 351 </span> 238 352 </div> 239 353 240 - {/* Textarea */} 241 - <textarea 242 - value={text} 243 - onChange={(e) => setText(e.target.value)} 244 - disabled={!isOwner || isSaving} 245 - className="w-full h-96 p-4 font-mono text-sm border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 disabled:opacity-50" 246 - placeholder="1 Lightning Bolt (2XM) 141 #removal&#10;4 Llanowar Elves #dorks&#10;1 Sol Ring" 247 - /> 354 + {/* Editor container */} 355 + <div className="overflow-auto max-h-96 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"> 356 + <div className="flex"> 357 + <textarea 358 + value={text} 359 + onChange={(e) => { 360 + setText(e.target.value); 361 + setErrors([]); 362 + }} 363 + disabled={!isOwner || isSaving} 364 + wrap="off" 365 + className="flex-1 p-4 font-mono text-sm leading-[1.5] resize-none overflow-x-auto overflow-y-hidden bg-transparent text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none disabled:opacity-50 [font-variant-ligatures:none]" 366 + style={{ 367 + height: `calc(${Math.max(textLines.length, 10)} * 1.5em + 2rem)`, 368 + }} 369 + placeholder="1 Lightning Bolt (2XM) 141 #removal&#10;4 Llanowar Elves #dorks&#10;1 Sol Ring" 370 + /> 371 + <BulkEditPreview lines={previewLines} /> 372 + </div> 373 + </div> 248 374 249 375 {/* Stats */} 250 376 <div className="mt-2 text-sm text-gray-500 dark:text-gray-400"> 251 - {lineCount} unique cards, {cardCount} total 377 + {lineCount} {lineCount === 1 ? "card" : "cards"}, {cardCount} total 252 378 </div> 253 379 254 380 {/* Errors */} ··· 266 392 <span className="font-mono">Line {err.line}:</span>{" "} 267 393 {err.error} 268 394 <br /> 269 - <span className="text-red-500 dark:text-red-500 font-mono text-xs"> 395 + <span className="text-red-500 font-mono text-xs"> 270 396 {err.raw} 271 397 </span> 272 398 </li> ··· 281 407 <button 282 408 type="button" 283 409 onClick={handleSave} 284 - disabled={isSaving} 410 + disabled={isSaving || !isDirty} 285 411 className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors" 286 412 > 287 413 {isSaving ? "Saving..." : "Save Changes"} ··· 289 415 <button 290 416 type="button" 291 417 onClick={handleReset} 292 - disabled={isSaving} 293 - className="px-4 py-2 bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-900 dark:text-white font-medium rounded-lg transition-colors" 418 + disabled={isSaving || !isDirty} 419 + className="px-4 py-2 bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed text-gray-900 dark:text-white font-medium rounded-lg transition-colors" 294 420 > 295 421 Reset 296 422 </button>