| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| | <title>Deck Doctor</title> |
| | <meta name="description" content="Advanced MTG card database with similarity search and market prices"> |
| | <script src="https://cdn.tailwindcss.com"></script> |
| | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| | <style> |
| | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap'); |
| | |
| | :root { |
| | --glass-bg: rgba(0, 0, 0, 0.4); |
| | --glass-bg-dark: rgba(0, 0, 0, 0.6); |
| | --glass-border: rgba(255, 255, 255, 0.08); |
| | --text-primary: rgba(255, 255, 255, 0.9); |
| | --text-secondary: rgba(255, 255, 255, 0.6); |
| | --text-muted: rgba(255, 255, 255, 0.4); |
| | --accent-blue: rgba(102, 126, 234, 0.5); |
| | --border-radius: 16px; |
| | --transition-fast: 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| | --transition-normal: 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| | } |
| | |
| | * { |
| | margin: 0; |
| | padding: 0; |
| | box-sizing: border-box; |
| | } |
| | |
| | body { |
| | font-family: 'Inter', sans-serif; |
| | background: #0a0a0a; |
| | background-size: cover; |
| | background-position: center; |
| | background-repeat: no-repeat; |
| | min-height: 100vh; |
| | position: relative; |
| | overflow-x: hidden; |
| | overscroll-behavior-y: contain; |
| | touch-action: pan-y; |
| | } |
| | |
| | |
| | .background-overlay { |
| | position: fixed; |
| | inset: 0; |
| | background: rgba(0, 0, 0, 0.6); |
| | backdrop-filter: blur(20px); |
| | -webkit-backdrop-filter: blur(20px); |
| | z-index: 0; |
| | } |
| | |
| | |
| | body::before { |
| | content: ''; |
| | position: fixed; |
| | inset: 0; |
| | background-size: cover; |
| | background-position: center; |
| | background-repeat: no-repeat; |
| | filter: blur(20px) brightness(0.5); |
| | z-index: -1; |
| | opacity: 0.3; |
| | transform: scale(1.1); |
| | will-change: transform; |
| | } |
| | |
| | |
| | .orb { |
| | position: fixed; |
| | width: min(400px, 40vw); |
| | height: min(400px, 40vw); |
| | border-radius: 50%; |
| | background: radial-gradient(circle, rgba(102, 126, 234, 0.4) 0%, transparent 70%); |
| | filter: blur(60px); |
| | opacity: 0.3; |
| | top: 50%; |
| | left: 50%; |
| | transform: translate(-50%, -50%); |
| | pointer-events: none; |
| | animation: float 20s infinite ease-in-out; |
| | z-index: 1; |
| | } |
| | |
| | @keyframes float { |
| | 0%, 100% { transform: translate(-50%, -50%) scale(1); } |
| | 50% { transform: translate(-30%, -60%) scale(1.2); } |
| | } |
| | |
| | |
| | @media (max-width: 768px) { |
| | .orb { |
| | animation: none; |
| | opacity: 0.1; |
| | filter: blur(40px); |
| | width: 60vw; |
| | height: 60vw; |
| | } |
| | |
| | .background-overlay { |
| | backdrop-filter: blur(3px); |
| | -webkit-backdrop-filter: blur(3px); |
| | } |
| | |
| | body::before { |
| | filter: blur(10px) brightness(0.4); |
| | } |
| | } |
| | .container { |
| | position: relative; |
| | z-index: 20; |
| | } |
| | |
| | |
| | .glass { |
| | background: var(--glass-bg); |
| | backdrop-filter: blur(20px); |
| | -webkit-backdrop-filter: blur(20px); |
| | border: 1px solid var(--glass-border); |
| | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); |
| | transition: var(--transition-normal); |
| | } |
| | |
| | .glass-card { |
| | background: linear-gradient(135deg, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.6)); |
| | backdrop-filter: blur(30px); |
| | -webkit-backdrop-filter: blur(30px); |
| | border: 1px solid var(--glass-border); |
| | border-radius: var(--border-radius); |
| | box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.5); |
| | overflow: hidden; |
| | } |
| | |
| | |
| | .search-container { |
| | position: relative; |
| | max-width: 500px; |
| | margin: 0 auto; |
| | } |
| | .search-input { |
| | width: 100%; |
| | background: var(--glass-bg-dark); |
| | backdrop-filter: blur(20px); |
| | border: 1px solid var(--glass-border); |
| | border-radius: var(--border-radius); |
| | padding: 16px 20px 16px 50px; |
| | color: white; |
| | font-size: 16px; |
| | transition: var(--transition-fast); |
| | outline: none; |
| | position: relative; |
| | z-index: 5; |
| | min-height: 50px; |
| | } |
| | |
| | @media (max-width: 768px) { |
| | .search-input { |
| | font-size: 20px; |
| | padding: 24px 28px 24px 60px; |
| | min-height: 70px; |
| | } |
| | .search-icon { |
| | font-size: 22px; |
| | left: 22px; |
| | } |
| | .search-clear { |
| | right: 22px; |
| | font-size: 22px; |
| | } |
| | } |
| | |
| | @media (max-width: 480px) { |
| | .search-input { |
| | font-size: 18px; |
| | padding: 22px 24px 22px 56px; |
| | min-height: 68px; |
| | } |
| | } |
| | .search-input::placeholder { |
| | color: var(--text-muted); |
| | } |
| | |
| | .search-input:focus { |
| | border-color: var(--accent-blue); |
| | box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
| | } |
| | |
| | .search-icon { |
| | position: absolute; |
| | left: 16px; |
| | top: 50%; |
| | transform: translateY(-50%); |
| | color: var(--text-muted); |
| | pointer-events: none; |
| | font-size: 16px; |
| | z-index: 10; |
| | } |
| | |
| | .search-clear { |
| | position: absolute; |
| | right: 14px; |
| | top: 50%; |
| | transform: translateY(-50%); |
| | background: none; |
| | border: none; |
| | color: var(--text-muted); |
| | cursor: pointer; |
| | padding: 4px; |
| | border-radius: 50%; |
| | transition: var(--transition-fast); |
| | display: none; |
| | } |
| | |
| | .search-clear.visible { |
| | display: block; |
| | } |
| | |
| | .search-clear:hover { |
| | color: var(--text-primary); |
| | background: rgba(255, 255, 255, 0.1); |
| | } |
| | |
| | |
| | .gallery-header { |
| | display: flex; |
| | align-items: center; |
| | justify-content: space-between; |
| | margin-bottom: 24px; |
| | flex-wrap: wrap; |
| | gap: 16px; |
| | } |
| | |
| | .gallery-title { |
| | font-size: 1.5rem; |
| | font-weight: 600; |
| | color: white; |
| | display: flex; |
| | align-items: center; |
| | gap: 12px; |
| | } |
| | |
| | |
| | .color-filters-inline { |
| | display: flex; |
| | align-items: center; |
| | gap: 8px; |
| | } |
| | |
| | .color-filter-label { |
| | font-size: 0.875rem; |
| | font-weight: 500; |
| | color: var(--text-secondary); |
| | margin-right: 4px; |
| | } |
| | |
| | .color-filter-btn { |
| | width: 36px; |
| | height: 36px; |
| | border-radius: 50%; |
| | border: 2px solid transparent; |
| | cursor: pointer; |
| | transition: var(--transition-fast); |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | font-weight: bold; |
| | font-size: 13px; |
| | position: relative; |
| | background: none; |
| | outline: none; |
| | } |
| | |
| | .color-filter-btn:hover { |
| | transform: scale(1.15); |
| | box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); |
| | } |
| | |
| | .color-filter-btn.active { |
| | border-color: rgba(255, 255, 255, 0.9); |
| | box-shadow: 0 0 20px rgba(255, 255, 255, 0.5); |
| | transform: scale(1.15); |
| | } |
| | |
| | .color-filter-btn::after { |
| | content: ''; |
| | position: absolute; |
| | inset: 2px; |
| | border-radius: 50%; |
| | transition: var(--transition-fast); |
| | } |
| | |
| | |
| | .clear-filters-btn { |
| | background: rgba(239, 68, 68, 0.2); |
| | border: 1px solid rgba(239, 68, 68, 0.4); |
| | color: rgb(248, 113, 113); |
| | padding: 6px 12px; |
| | border-radius: 8px; |
| | font-size: 0.75rem; |
| | cursor: pointer; |
| | transition: var(--transition-fast); |
| | display: none; |
| | } |
| | |
| | .clear-filters-btn.visible { |
| | display: inline-flex; |
| | align-items: center; |
| | gap: 4px; |
| | } |
| | |
| | .clear-filters-btn:hover { |
| | background: rgba(239, 68, 68, 0.3); |
| | border-color: rgba(239, 68, 68, 0.6); |
| | } |
| | |
| | |
| | .autocomplete-dropdown { |
| | position: absolute; |
| | top: calc(100% + 8px); |
| | left: 0; |
| | right: 0; |
| | background: var(--glass-bg-dark); |
| | backdrop-filter: blur(20px); |
| | border: 1px solid var(--glass-border); |
| | border-radius: 12px; |
| | max-height: 300px; |
| | overflow-y: auto; |
| | z-index: 1000; |
| | opacity: 0; |
| | visibility: hidden; |
| | transform: translateY(-10px); |
| | transition: var(--transition-fast); |
| | } |
| | |
| | .autocomplete-dropdown.visible { |
| | opacity: 1; |
| | visibility: visible; |
| | transform: translateY(0); |
| | } |
| | |
| | .autocomplete-item { |
| | padding: 12px 16px; |
| | color: var(--text-secondary); |
| | cursor: pointer; |
| | border-bottom: 1px solid rgba(255, 255, 255, 0.05); |
| | transition: var(--transition-fast); |
| | display: flex; |
| | align-items: center; |
| | } |
| | |
| | .autocomplete-item:hover, |
| | .autocomplete-item.selected { |
| | background: rgba(102, 126, 234, 0.2); |
| | color: white; |
| | } |
| | |
| | .autocomplete-loading, |
| | .autocomplete-empty { |
| | padding: 16px; |
| | text-align: center; |
| | color: var(--text-muted); |
| | font-size: 14px; |
| | } |
| | |
| | .autocomplete-hint { |
| | padding: 12px 16px; |
| | background: rgba(102, 126, 234, 0.1); |
| | border-top: 1px solid rgba(255, 255, 255, 0.1); |
| | color: var(--text-secondary); |
| | font-size: 13px; |
| | display: flex; |
| | align-items: center; |
| | gap: 8px; |
| | } |
| | |
| | |
| | .header-text { |
| | font-family: 'Space Grotesk', sans-serif; |
| | font-weight: 700; |
| | background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.7) 100%); |
| | -webkit-background-clip: text; |
| | -webkit-text-fill-color: transparent; |
| | background-clip: text; |
| | font-size: clamp(2.5rem, 10vw, 6rem); |
| | letter-spacing: -1px; |
| | line-height: 1; |
| | } |
| | |
| | .subheader-text { |
| | color: var(--text-secondary); |
| | font-weight: 300; |
| | letter-spacing: 2px; |
| | text-transform: uppercase; |
| | font-size: clamp(0.75rem, 2vw, 0.875rem); |
| | } |
| | |
| | |
| | .search-results-header { |
| | background: var(--glass-bg-dark); |
| | backdrop-filter: blur(20px); |
| | border: 1px solid var(--glass-border); |
| | border-radius: 12px; |
| | padding: 16px 20px; |
| | margin-bottom: 24px; |
| | display: flex; |
| | align-items: center; |
| | justify-content: space-between; |
| | flex-wrap: wrap; |
| | gap: 12px; |
| | } |
| | |
| | .search-results-title { |
| | font-size: 1.25rem; |
| | font-weight: 600; |
| | color: white; |
| | display: flex; |
| | align-items: center; |
| | gap: 12px; |
| | } |
| | |
| | .search-query { |
| | color: rgba(102, 126, 234, 1); |
| | font-weight: 400; |
| | } |
| | |
| | .results-count { |
| | color: var(--text-muted); |
| | font-size: 0.875rem; |
| | } |
| | |
| | |
| | .search-results-filters { |
| | display: flex; |
| | align-items: center; |
| | gap: 8px; |
| | } |
| | |
| | |
| | .glass-button { |
| | background: var(--glass-bg); |
| | backdrop-filter: blur(10px); |
| | border: 1px solid var(--glass-border); |
| | color: var(--text-primary); |
| | padding: 12px 24px; |
| | border-radius: 12px; |
| | font-weight: 500; |
| | transition: var(--transition-fast); |
| | cursor: pointer; |
| | font-size: 0.9rem; |
| | white-space: nowrap; |
| | min-height: 44px; |
| | display: inline-flex; |
| | align-items: center; |
| | justify-content: center; |
| | } |
| | |
| | .glass-button:hover:not(:disabled) { |
| | background: rgba(0, 0, 0, 0.5); |
| | border-color: rgba(255, 255, 255, 0.2); |
| | transform: translateY(-2px); |
| | } |
| | |
| | .glass-button:disabled { |
| | opacity: 0.6; |
| | cursor: not-allowed; |
| | } |
| | |
| | .glass-button-small { |
| | padding: 6px 12px; |
| | font-size: 0.8rem; |
| | min-height: 32px; |
| | } |
| | |
| | |
| | .oracle-search-btn { |
| | background: rgba(102, 126, 234, 0.2); |
| | border: 1px solid rgba(102, 126, 234, 0.4); |
| | color: rgba(102, 126, 234, 1); |
| | padding: 12px 16px; |
| | border-radius: 12px; |
| | font-size: 0.85rem; |
| | cursor: pointer; |
| | transition: var(--transition-fast); |
| | display: block; |
| | margin: 8px 0; |
| | text-align: left; |
| | width: 100%; |
| | word-wrap: break-word; |
| | line-height: 1.5; |
| | white-space: normal; |
| | overflow-wrap: break-word; |
| | word-break: break-word; |
| | } |
| | |
| | .oracle-search-btn:hover { |
| | background: rgba(102, 126, 234, 0.3); |
| | border-color: rgba(102, 126, 234, 0.6); |
| | color: white; |
| | transform: translateY(-1px); |
| | } |
| | |
| | .oracle-search-btn i { |
| | margin-right: 8px; |
| | flex-shrink: 0; |
| | } |
| | |
| | |
| | @media (max-width: 768px) { |
| | .oracle-search-btn { |
| | font-size: 0.8rem; |
| | padding: 10px 14px; |
| | margin: 6px 0; |
| | line-height: 1.4; |
| | display: flex; |
| | align-items: flex-start; |
| | } |
| | |
| | .oracle-search-btn i { |
| | margin-right: 8px; |
| | font-size: 0.8rem; |
| | margin-top: 0.1em; |
| | } |
| | } |
| | |
| | @media (max-width: 480px) { |
| | .oracle-search-btn { |
| | font-size: 0.75rem; |
| | padding: 10px 12px; |
| | margin: 5px 0; |
| | line-height: 1.4; |
| | border-radius: 10px; |
| | } |
| | |
| | .oracle-search-btn i { |
| | margin-right: 6px; |
| | font-size: 0.75rem; |
| | } |
| | } |
| | |
| | @media (max-width: 360px) { |
| | .oracle-search-btn { |
| | font-size: 0.7rem; |
| | padding: 8px 10px; |
| | margin: 4px 0; |
| | } |
| | } |
| | |
| | |
| | .card-3d-container { |
| | perspective: 2000px; |
| | width: 100%; |
| | max-width: min(420px, 90vw); |
| | margin: 0 auto; |
| | position: relative; |
| | user-select: none; |
| | } |
| | |
| | .card-3d { |
| | width: 100%; |
| | aspect-ratio: 0.72; |
| | transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1); |
| | transform-style: preserve-3d; |
| | position: relative; |
| | } |
| | |
| | .card-face { |
| | position: absolute; |
| | width: 100%; |
| | height: 100%; |
| | backface-visibility: hidden; |
| | border-radius: 18px; |
| | overflow: hidden; |
| | box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.5); |
| | } |
| | |
| | .card-face-back { |
| | transform: rotateY(180deg); |
| | } |
| | |
| | |
| | .responsive-grid { |
| | display: grid; |
| | gap: 1.5rem; |
| | grid-template-columns: 1fr; |
| | } |
| | |
| | @media (min-width: 1024px) { |
| | .responsive-grid { |
| | grid-template-columns: 1fr 1fr; |
| | } |
| | } |
| | |
| | .stats-grid { |
| | display: grid; |
| | grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); |
| | gap: 0.75rem; |
| | } |
| | |
| | .stat-box { |
| | background: var(--glass-bg-dark); |
| | border: 1px solid var(--glass-border); |
| | border-radius: 12px; |
| | padding: 1rem; |
| | transition: var(--transition-fast); |
| | } |
| | |
| | .stat-box:hover { |
| | transform: translateY(-2px); |
| | } |
| | |
| | |
| | .info-label { |
| | font-size: 0.75rem; |
| | font-weight: 600; |
| | text-transform: uppercase; |
| | letter-spacing: 0.05em; |
| | color: var(--text-muted); |
| | margin-bottom: 0.5rem; |
| | } |
| | |
| | .info-value { |
| | color: var(--text-primary); |
| | font-weight: 500; |
| | font-size: 1.1rem; |
| | } |
| | |
| | |
| | .price-section { |
| | background: var(--glass-bg-dark); |
| | border: 1px solid var(--glass-border); |
| | border-radius: 12px; |
| | padding: 1rem; |
| | margin-bottom: 1rem; |
| | } |
| | |
| | .price-grid { |
| | display: grid; |
| | grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); |
| | gap: 0.75rem; |
| | margin-top: 0.75rem; |
| | } |
| | |
| | .price-item { |
| | text-align: center; |
| | } |
| | |
| | .price-value { |
| | font-size: 1.25rem; |
| | font-weight: 600; |
| | color: var(--text-primary); |
| | } |
| | |
| | .price-label { |
| | font-size: 0.75rem; |
| | color: var(--text-muted); |
| | text-transform: uppercase; |
| | margin-top: 0.25rem; |
| | } |
| | |
| | |
| | .legality-badge { |
| | padding: 6px 12px; |
| | border-radius: 8px; |
| | font-size: 0.75rem; |
| | font-weight: 500; |
| | text-transform: uppercase; |
| | display: inline-flex; |
| | align-items: center; |
| | gap: 4px; |
| | } |
| | |
| | .legality-legal { |
| | background: rgba(16, 185, 129, 0.2); |
| | border: 1px solid rgba(16, 185, 129, 0.4); |
| | color: rgb(52, 211, 153); |
| | } |
| | |
| | .legality-not-legal { |
| | background: rgba(148, 163, 184, 0.2); |
| | border: 1px solid rgba(148, 163, 184, 0.4); |
| | color: rgb(148, 163, 184); |
| | } |
| | |
| | .legality-banned { |
| | background: rgba(239, 68, 68, 0.2); |
| | border: 1px solid rgba(239, 68, 68, 0.4); |
| | color: rgb(248, 113, 113); |
| | } |
| | |
| | |
| | .gallery-grid { |
| | display: grid; |
| | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| | gap: 1.5rem; |
| | } |
| | @media (max-width: 768px) { |
| | .gallery-grid { |
| | grid-template-columns: repeat(2, 1fr); |
| | gap: 0.75rem; |
| | } |
| | |
| | .gallery-header { |
| | flex-direction: column; |
| | align-items: flex-start; |
| | margin-bottom: 1rem; |
| | } |
| | |
| | .card-3d-container { |
| | max-width: 100%; |
| | } |
| | |
| | .search-container { |
| | width: 100%; |
| | padding: 0 1rem; |
| | } |
| | |
| | .header-text { |
| | font-size: 2.5rem; |
| | margin-bottom: 1rem; |
| | } |
| | |
| | .glass-button { |
| | padding: 10px 16px; |
| | font-size: 0.85rem; |
| | } |
| | |
| | .responsive-grid { |
| | gap: 1rem; |
| | } |
| | } |
| | |
| | @media (max-width: 480px) { |
| | .gallery-grid { |
| | grid-template-columns: 1fr; |
| | gap: 0.75rem; |
| | } |
| | |
| | body { |
| | padding: 1rem; |
| | } |
| | |
| | .container { |
| | padding: 0.5rem; |
| | } |
| | |
| | .header-text { |
| | font-size: 2rem; |
| | } |
| | .price-section { |
| | padding: 0.75rem; |
| | } |
| | |
| | .stat-box { |
| | padding: 0.75rem; |
| | } |
| | } |
| | |
| | @media (max-width: 360px) { |
| | .header-text { |
| | font-size: 1.8rem; |
| | } |
| | |
| | .glass-button { |
| | padding: 8px 12px; |
| | font-size: 0.8rem; |
| | } |
| | |
| | .search-input { |
| | padding: 10px 14px 10px 36px; |
| | } |
| | } |
| | .gallery-item { |
| | position: relative; |
| | border-radius: 12px; |
| | overflow: hidden; |
| | transition: var(--transition-fast); |
| | background: var(--glass-bg); |
| | border: 1px solid var(--glass-border); |
| | } |
| | |
| | .gallery-item:hover { |
| | transform: translateY(-4px) scale(1.02); |
| | } |
| | |
| | |
| | .gallery-overlay { |
| | position: absolute; |
| | inset: 0; |
| | background: linear-gradient(to top, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.7) 50%, transparent 100%); |
| | opacity: 0; |
| | transition: var(--transition-fast); |
| | padding: 1rem; |
| | display: flex; |
| | flex-direction: column; |
| | justify-content: flex-end; |
| | } |
| | |
| | .gallery-item:hover .gallery-overlay { |
| | opacity: 1; |
| | } |
| | |
| | .gallery-overlay-content { |
| | space-y: 1rem; |
| | } |
| | |
| | .gallery-mana-cost { |
| | display: flex; |
| | align-items: center; |
| | gap: 4px; |
| | margin-bottom: 8px; |
| | } |
| | |
| | .mana-symbol { |
| | width: 16px; |
| | height: 16px; |
| | border-radius: 50%; |
| | display: inline-flex; |
| | align-items: center; |
| | justify-content: center; |
| | font-size: 10px; |
| | font-weight: bold; |
| | color: white; |
| | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); |
| | } |
| | |
| | .gallery-actions { |
| | display: flex; |
| | gap: 8px; |
| | margin-top: 12px; |
| | } |
| | |
| | .gallery-btn { |
| | background: rgba(0, 0, 0, 0.8); |
| | border: 1px solid rgba(255, 255, 255, 0.2); |
| | color: white; |
| | padding: 6px 12px; |
| | border-radius: 6px; |
| | font-size: 0.75rem; |
| | cursor: pointer; |
| | transition: var(--transition-fast); |
| | text-decoration: none; |
| | display: inline-flex; |
| | align-items: center; |
| | gap: 4px; |
| | } |
| | |
| | .gallery-btn:hover { |
| | background: rgba(102, 126, 234, 0.8); |
| | border-color: rgba(102, 126, 234, 1); |
| | } |
| | |
| | .gallery-btn-view { |
| | background: rgba(102, 126, 234, 0.8); |
| | } |
| | |
| | .gallery-btn-view:hover { |
| | background: rgba(102, 126, 234, 1); |
| | transform: translateY(-1px); |
| | } |
| | |
| | |
| | .loading-ring { |
| | width: 48px; |
| | height: 48px; |
| | border: 3px solid rgba(255, 255, 255, 0.1); |
| | border-top-color: rgba(102, 126, 234, 0.8); |
| | border-radius: 50%; |
| | animation: spin 1s linear infinite; |
| | } |
| | |
| | @keyframes spin { |
| | to { transform: rotate(360deg); } |
| | } |
| | |
| | |
| | .toast { |
| | position: fixed; |
| | top: 20px; |
| | right: 20px; |
| | z-index: 1100; |
| | padding: 12px 16px; |
| | border-radius: 8px; |
| | font-size: 14px; |
| | transition: var(--transition-fast); |
| | transform: translateX(400px); |
| | } |
| | |
| | .toast.show { |
| | transform: translateX(0); |
| | } |
| | |
| | .toast.error { |
| | background: rgba(239, 68, 68, 0.9); |
| | color: white; |
| | } |
| | |
| | .toast.success { |
| | background: rgba(16, 185, 129, 0.9); |
| | color: white; |
| | } |
| | |
| | |
| | .price-tag { |
| | background: linear-gradient(135deg, rgba(16, 185, 129, 0.9), rgba(5, 150, 105, 0.9)); |
| | color: white; |
| | padding: 6px 12px; |
| | border-radius: 8px; |
| | font-weight: 600; |
| | font-size: 0.875rem; |
| | } |
| | |
| | |
| | ::-webkit-scrollbar { |
| | width: 8px; |
| | height: 8px; |
| | } |
| | |
| | ::-webkit-scrollbar-track { |
| | background: rgba(0, 0, 0, 0.3); |
| | } |
| | |
| | ::-webkit-scrollbar-thumb { |
| | background: rgba(255, 255, 255, 0.2); |
| | border-radius: 4px; |
| | } |
| | |
| | ::-webkit-scrollbar-thumb:hover { |
| | background: rgba(255, 255, 255, 0.3); |
| | } |
| | |
| | |
| | .hidden { display: none !important; } |
| | .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
| | .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } |
| | |
| | |
| | :focus-visible { |
| | outline: 2px solid var(--accent-blue); |
| | outline-offset: 2px; |
| | } |
| | |
| | |
| | ::selection { |
| | background: rgba(102, 126, 234, 0.3); |
| | color: white; |
| | } |
| | |
| | |
| | @media (prefers-reduced-motion: reduce) { |
| | * { |
| | animation-duration: 0.01ms !important; |
| | transition-duration: 0.01ms !important; |
| | } |
| | } |
| | </style> |
| | </head> |
| | <body> |
| | |
| | <div class="background-overlay"></div> |
| | <div class="orb" aria-hidden="true"></div> |
| | |
| | |
| | <div id="toast" class="toast"></div> |
| | |
| | <div class="container mx-auto py-4 md:py-8 px-4"> |
| | |
| | <header class="text-center mb-8 md:mb-12"> |
| | |
| | <h1 class="header-text text-4xl md:text-6xl lg:text-8xl mb-4 md:mb-6"> |
| | Deck Doctor |
| | </h1> |
| | <div class="flex justify-center gap-5 md:gap-8 text-xs md:text-sm flex-wrap"> |
| | <span class="text-white/40"><i class="fas fa-shield-alt mr-2"></i>Card Search</span> |
| | <span class="text-white/40"><i class="fas fa-chart-line mr-2"></i>Deck Analysis</span> |
| | <span class="text-white/40"><i class="fas fa-search mr-2"></i>Smart Discovery</span> |
| | </div> |
| |
|
| | |
| | <div class="search-container mt-8"> |
| | <i class="fas fa-search search-icon"></i> |
| | <textarea |
| | id="search-input" |
| | class="search-input" |
| | placeholder="Search for cards, terms or paste a deck..." |
| | autocomplete="off" |
| | rows="1" |
| | oninput="autoResize(this)" |
| | ></textarea> |
| | <button class="search-clear" id="search-clear" aria-label="Clear"> |
| | <i class="fas fa-times"></i> |
| | </button> |
| | <div class="autocomplete-dropdown" id="autocomplete"></div> |
| | </div> |
| | </header> |
| |
|
| | |
| | <div id="main-card-section" class="responsive-grid max-w-7xl mx-auto"> |
| | |
| | <div class="flex flex-col items-center"> |
| | <div class="card-3d-container mb-4"> |
| | <div class="card-3d" id="card-3d"> |
| | <div class="card-face"> |
| | <div id="card-image" class="w-full h-full bg-gray-900 flex items-center justify-center"> |
| | <div class="text-center glass-card p-8"> |
| | <i class="fas fa-cube text-5xl text-white/30 mb-4"></i> |
| | <p class="text-white/60">Initializing...</p> |
| | </div> |
| | </div> |
| | <div id="face-indicator" class="absolute top-4 right-4 price-tag hidden">Face 1/2</div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | |
| | <div class="flex gap-2 mb-4"> |
| | <button id="random-btn" class="glass-button"> |
| | <i class="fas fa-shuffle mr-2"></i>Random |
| | </button> |
| | </div> |
| | |
| | |
| | <div id="face-nav" class="hidden glass-card px-4 py-2 flex items-center gap-4"> |
| | <button onclick="switchFace(-1)" class="text-white/60 hover:text-white"> |
| | <i class="fas fa-chevron-left"></i> |
| | </button> |
| | <span id="face-counter" class="text-white font-medium">1/2</span> |
| | <button onclick="switchFace(1)" class="text-white/60 hover:text-white"> |
| | <i class="fas fa-chevron-right"></i> |
| | </button> |
| | </div> |
| | </div> |
| | |
| | |
| | <div id="info-panel" class="space-y-4"> |
| | <div class="glass-card p-4 md:p-5"> |
| | <h2 id="card-name" class="text-2xl font-semibold text-white mb-1">No Card Selected</h2> |
| | <div id="card-type" class="text-white/60 mb-4"></div> |
| | |
| | |
| | <div id="oracle-search-section" class="mb-4"> |
| | <h3 class="info-label">Search Card Mechanics</h3> |
| | <div id="oracle-search-buttons" class="space-y-2"></div> |
| | </div> |
| | |
| | |
| | <div class="mb-4"> |
| | <h3 class="info-label mb-2">Format Legality</h3> |
| | <div id="legalities" class="flex flex-wrap gap-1"></div> |
| | </div> |
| | |
| | |
| | <div class="stat-box mb-4"> |
| | <p class="info-label">Rankings</p> |
| | <div id="rankings" class="info-value">—</div> |
| | </div> |
| | |
| | |
| | <div class="price-section"> |
| | <h3 class="info-label">Market Prices</h3> |
| | <div id="prices" class="price-grid"></div> |
| | </div> |
| | |
| | |
| | <div> |
| | <h3 class="info-label mb-2">Purchase & Resources</h3> |
| | <div id="links" class="flex flex-wrap gap-2"></div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | |
| | <div id="gallery-section" class="mt-12 hidden"> |
| | <div class="gallery-header max-w-7xl mx-auto"> |
| | <h2 id="gallery-header" class="gallery-title">Similar Cards</h2> |
| | <div class="color-filters-inline"> |
| | <span class="color-filter-label">Filter:</span> |
| | <div id="gallery-color-filters" class="flex gap-2"></div> |
| | <button id="clear-gallery-filters" class="clear-filters-btn" onclick="clearGalleryFilters()"> |
| | <i class="fas fa-times"></i> |
| | Clear |
| | </button> |
| | </div> |
| | </div> |
| | <div id="gallery" class="gallery-grid max-w-7xl mx-auto"></div> |
| | </div> |
| | |
| | |
| | <div id="search-results-section" class="hidden"> |
| | <div class="search-results-header max-w-7xl mx-auto"> |
| | <div class="search-results-title"> |
| | <i class="fas fa-search"></i> |
| | <span>Search: <span class="search-query" id="search-query-text"></span></span> |
| | </div> |
| | <div class="flex items-center gap-4"> |
| | <div class="results-count" id="results-count"></div> |
| | <div class="search-results-filters"> |
| | <div id="search-color-filters" class="flex gap-2"></div> |
| | <button id="clear-search-filters" class="clear-filters-btn" onclick="clearSearchFilters()"> |
| | <i class="fas fa-times"></i> |
| | Clear |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| | <div id="search-results" class="gallery-grid max-w-7xl mx-auto"></div> |
| | </div> |
| | |
| | |
| | <div id="loading" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center hidden z-50"> |
| | <div class="glass-card p-6 text-center"> |
| | <div class="loading-ring mx-auto mb-3"></div> |
| | <p class="text-white/60">Loading...</p> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <script> |
| | // State |
| | let currentCard = null; |
| | let currentFace = 0; |
| | let isLoading = false; |
| | let searchTimeout = null; |
| | let autocompleteResults = []; |
| | let isSearchMode = false; |
| | let lastSearchQuery = ''; |
| | let galleryColors = new Set(); |
| | let searchColors = new Set(); |
| | let currentGalleryQuery = ''; |
| | |
| | // Mana color definitions |
| | const manaColors = [ |
| | { symbol: 'W', name: 'White', color: '#FFFBD5', textColor: '#000' }, |
| | { symbol: 'U', name: 'Blue', color: '#0E68AB', textColor: '#fff' }, |
| | { symbol: 'B', name: 'Black', color: '#150B00', textColor: '#fff' }, |
| | { symbol: 'R', name: 'Red', color: '#D3202A', textColor: '#fff' }, |
| | { symbol: 'G', name: 'Green', color: '#00733E', textColor: '#fff' } |
| | ]; |
| | |
| | // DOM Elements |
| | const $ = id => document.getElementById(id); |
| | const searchInput = $('search-input'); |
| | const searchClear = $('search-clear'); |
| | const autocomplete = $('autocomplete'); |
| | const randomBtn = $('random-btn'); |
| | const loading = $('loading'); |
| | const toast = $('toast'); |
| | const mainCardSection = $('main-card-section'); |
| | const infoPanel = $('info-panel'); |
| | const gallerySection = $('gallery-section'); |
| | const searchResultsSection = $('search-results-section'); |
| | |
| | // Initialize |
| | document.addEventListener('DOMContentLoaded', () => { |
| | createColorFilterButtons('gallery-color-filters', 'gallery'); |
| | createColorFilterButtons('search-color-filters', 'search'); |
| | fetchRandomCard(); |
| | setupEventListeners(); |
| | }); |
| | |
| | // Create color filter buttons for different sections |
| | function createColorFilterButtons(containerId, filterType) { |
| | const container = $(containerId); |
| | if (!container) return; |
| | |
| | container.innerHTML = manaColors.map(color => ` |
| | <button |
| | class="color-filter-btn" |
| | data-color="${color.symbol}" |
| | data-filter-type="${filterType}" |
| | onclick="toggleColorFilter('${color.symbol}', '${filterType}')" |
| | style="color: ${color.textColor};" |
| | title="Filter by ${color.name}" |
| | > |
| | <span style=" |
| | position: absolute; |
| | inset: 0; |
| | background: ${color.color}; |
| | border-radius: 50%; |
| | z-index: -1; |
| | "></span> |
| | ${color.symbol} |
| | </button> |
| | `).join(''); |
| | } |
| | |
| | // Toggle color filter for specific section |
| | function toggleColorFilter(colorSymbol, filterType) { |
| | const colorSet = filterType === 'gallery' ? galleryColors : searchColors; |
| | const button = document.querySelector(`[data-color="${colorSymbol}"][data-filter-type="${filterType}"]`); |
| | |
| | if (colorSet.has(colorSymbol)) { |
| | colorSet.delete(colorSymbol); |
| | button.classList.remove('active'); |
| | } else { |
| | colorSet.add(colorSymbol); |
| | button.classList.add('active'); |
| | } |
| | |
| | // Update clear button visibility |
| | const clearBtn = filterType === 'gallery' ? $('clear-gallery-filters') : $('clear-search-filters'); |
| | if (clearBtn) { |
| | clearBtn.classList.toggle('visible', colorSet.size > 0); |
| | } |
| | |
| | // Apply filters based on type |
| | if (filterType === 'gallery' && currentCard) { |
| | fetchSimilarCards(currentCard); |
| | } else if (filterType === 'search' && isSearchMode) { |
| | performDeckDoctorSearch(lastSearchQuery); |
| | } |
| | } |
| | |
| | // Clear filters for gallery |
| | function clearGalleryFilters() { |
| | galleryColors.clear(); |
| | document.querySelectorAll('[data-filter-type="gallery"]').forEach(btn => { |
| | btn.classList.remove('active'); |
| | }); |
| | $('clear-gallery-filters').classList.remove('visible'); |
| | |
| | if (currentCard) { |
| | fetchSimilarCards(currentCard); |
| | } |
| | } |
| | |
| | // Clear filters for search |
| | function clearSearchFilters() { |
| | searchColors.clear(); |
| | document.querySelectorAll('[data-filter-type="search"]').forEach(btn => { |
| | btn.classList.remove('active'); |
| | }); |
| | $('clear-search-filters').classList.remove('visible'); |
| | |
| | if (isSearchMode) { |
| | performDeckDoctorSearch(lastSearchQuery); |
| | } |
| | } |
| | |
| | // Event Listeners |
| | function setupEventListeners() { |
| | searchInput.addEventListener('input', handleSearch); |
| | searchInput.addEventListener('keydown', handleSearchKey); |
| | searchClear.addEventListener('click', clearSearch); |
| | randomBtn.addEventListener('click', () => { |
| | exitSearchMode(); |
| | clearGalleryFilters(); |
| | fetchRandomCard(); |
| | }); |
| | |
| | // Close autocomplete on outside click |
| | document.addEventListener('click', e => { |
| | if (!e.target.closest('.search-container')) { |
| | hideAutocomplete(); |
| | } |
| | }); |
| | |
| | // Keyboard shortcuts |
| | document.addEventListener('keydown', e => { |
| | if (e.key === '/' && document.activeElement !== searchInput) { |
| | e.preventDefault(); |
| | searchInput.focus(); |
| | } |
| | if (e.key === 'Escape') { |
| | searchInput.blur(); |
| | hideAutocomplete(); |
| | } |
| | }); |
| | } |
| | |
| | // Auto-resize textarea |
| | function autoResize(textarea) { |
| | textarea.style.height = 'auto'; |
| | textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; |
| | } |
| | |
| | // Search Functions |
| | function handleSearch(e) { |
| | const query = e.target.value.trim(); |
| | searchClear.classList.toggle('visible', query.length > 0); |
| | |
| | clearTimeout(searchTimeout); |
| | |
| | // Check if this looks like a decklist (multiple lines) |
| | const lines = query.split('\n').filter(line => line.trim().length > 0); |
| | |
| | if (lines.length > 5) { |
| | // This is likely a decklist - don't show autocomplete |
| | hideAutocomplete(); |
| | return; |
| | } |
| | |
| | if (query.length < 2) { |
| | hideAutocomplete(); |
| | return; |
| | } |
| | |
| | searchTimeout = setTimeout(() => fetchAutocomplete(query), 300); |
| | } |
| | |
| | function handleSearchKey(e) { |
| | const query = searchInput.value.trim(); |
| | const lines = query.split('\n').filter(line => line.trim().length > 0); |
| | |
| | if (e.key === 'Enter' && query.length > 0) { |
| | // Handle decklist if more than 5 lines |
| | if (lines.length > 5) { |
| | e.preventDefault(); |
| | hideAutocomplete(); |
| | processDecklist(query); |
| | return; |
| | } |
| | |
| | // Handle single card search |
| | if (!e.shiftKey) { // Allow Shift+Enter for newlines in regular searches |
| | e.preventDefault(); |
| | hideAutocomplete(); |
| | |
| | // Check if user selected from autocomplete or is typing a custom query |
| | if (autocompleteResults.length > 0 && autocompleteResults.includes(query)) { |
| | // Exact match from autocomplete |
| | selectCard(query); |
| | } else { |
| | // Custom search query - use Deck Doctor search |
| | performDeckDoctorSearch(query); |
| | } |
| | } |
| | } |
| | } |
| | |
| | function clearSearch() { |
| | searchInput.value = ''; |
| | searchClear.classList.remove('visible'); |
| | hideAutocomplete(); |
| | clearSearchFilters(); |
| | if (isSearchMode) { |
| | exitSearchMode(); |
| | fetchRandomCard(); |
| | } |
| | } |
| | |
| | async function fetchAutocomplete(query) { |
| | try { |
| | showAutocompleteLoading(); |
| | const response = await fetch(`https://api.scryfall.com/cards/autocomplete?q=${encodeURIComponent(query)}`); |
| | const data = await response.json(); |
| | autocompleteResults = data.data || []; |
| | displayAutocomplete(autocompleteResults, query); |
| | } catch (error) { |
| | console.error('Autocomplete error:', error); |
| | hideAutocomplete(); |
| | } |
| | } |
| | |
| | function displayAutocomplete(results, query) { |
| | let html = ''; |
| | |
| | if (!results.length) { |
| | html = ''; |
| | } else { |
| | html = results.map(name => |
| | `<div class="autocomplete-item" onclick="selectCard('${name.replace(/'/g, "\\'")}')">${name}</div>` |
| | ).join(''); |
| | } |
| | |
| | // Add hint for custom search |
| | html += `<div class="autocomplete-hint"> |
| | <i class="fas fa-lightbulb"></i> |
| | <span>Press Enter to search cards for "${query}".</span> |
| | </div>`; |
| | |
| | autocomplete.innerHTML = html; |
| | showAutocomplete(); |
| | } |
| | |
| | function showAutocompleteLoading() { |
| | autocomplete.innerHTML = '<div class="autocomplete-loading"><i class="fas fa-spinner fa-spin mr-2"></i>Searching...</div>'; |
| | showAutocomplete(); |
| | } |
| | |
| | function showAutocomplete() { |
| | autocomplete.classList.add('visible'); |
| | } |
| | |
| | function hideAutocomplete() { |
| | autocomplete.classList.remove('visible'); |
| | } |
| | |
| | async function selectCard(name) { |
| | hideAutocomplete(); |
| | searchInput.value = name; |
| | searchClear.classList.add('visible'); |
| | setLoading(true); |
| | exitSearchMode(); |
| | |
| | try { |
| | const response = await fetch(`https://api.scryfall.com/cards/named?exact=${encodeURIComponent(name)}`); |
| | const card = await response.json(); |
| | displayCard(card); |
| | showToast('Card loaded', 'success'); |
| | } catch (error) { |
| | showToast('Failed to load card', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | // Deck Doctor Search with color filters |
| | async function performDeckDoctorSearch(query) { |
| | setLoading(true); |
| | lastSearchQuery = query; |
| | |
| | try { |
| | // Build URL with colors if selected |
| | let url = `https://api.deck.doctor/v1/mtg/search?q=${encodeURIComponent(query)}&topk=20&price_threshold=0`; |
| | |
| | // Add color filters for search results |
| | searchColors.forEach(color => { |
| | url += `&colors=${color}`; |
| | }); |
| | |
| | const response = await fetch(url); |
| | const data = await response.json(); |
| | |
| | if (data && data.length > 0) { |
| | const colorFilterText = searchColors.size > 0 ? ` (${Array.from(searchColors).join('')})` : ''; |
| | enterSearchMode(query + colorFilterText, data); |
| | showToast(`Found ${data.length} cards`, 'success'); |
| | } else { |
| | showToast('No cards found', 'error'); |
| | } |
| | } catch (error) { |
| | console.error('Search error:', error); |
| | showToast('Search failed', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | // Process decklist |
| | async function processDecklist(decklistText) { |
| | setLoading(true); |
| | showToast('Processing decklist...', 'success'); |
| | |
| | try { |
| | // Parse decklist - extract card names (simple parsing) |
| | const lines = decklistText.split('\n') |
| | .filter(line => line.trim().length > 0) |
| | .map(line => { |
| | // Remove quantity numbers and set codes if present |
| | return line.replace(/^\d+\s*/, '') // Remove leading numbers |
| | .replace(/\s*\(.*?\)\s*$/, '') // Remove set codes in parentheses |
| | .replace(/\s*\*.*?\*\s*$/, '') // Remove any *footnotes* |
| | .trim(); |
| | }) |
| | .filter(name => name.length > 0); |
| | |
| | if (lines.length === 0) { |
| | showToast('No valid card names found in decklist', 'error'); |
| | return; |
| | } |
| | |
| | // Search for each card in the decklist |
| | const deckCards = []; |
| | for (const cardName of lines) { |
| | try { |
| | const response = await fetch(`https://api.scryfall.com/cards/named?exact=${encodeURIComponent(cardName)}`); |
| | if (response.ok) { |
| | const card = await response.json(); |
| | deckCards.push([card, 1.0]); // Use 1.0 as similarity score for decklist items |
| | } |
| | } catch (error) { |
| | console.warn(`Could not find card: ${cardName}`); |
| | } |
| | } |
| | |
| | if (deckCards.length > 0) { |
| | enterSearchMode(`Decklist (${deckCards.length} cards)`, deckCards); |
| | showToast(`Loaded ${deckCards.length} cards from decklist`, 'success'); |
| | } else { |
| | showToast('No valid cards found in decklist', 'error'); |
| | } |
| | } catch (error) { |
| | console.error('Decklist processing error:', error); |
| | showToast('Failed to process decklist', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | // Search Mode Management |
| | function enterSearchMode(query, results) { |
| | isSearchMode = true; |
| | |
| | // Hide main card display |
| | mainCardSection.classList.add('hidden'); |
| | gallerySection.classList.add('hidden'); |
| | |
| | // Show search results |
| | searchResultsSection.classList.remove('hidden'); |
| | $('search-query-text').textContent = query; |
| | $('results-count').textContent = `${results.length} results`; |
| | |
| | // Display results |
| | displaySearchResults(results); |
| | } |
| | |
| | function exitSearchMode() { |
| | isSearchMode = false; |
| | |
| | // Show main card display |
| | mainCardSection.classList.remove('hidden'); |
| | |
| | // Hide search results |
| | searchResultsSection.classList.add('hidden'); |
| | |
| | // Clear search filters when exiting search mode |
| | clearSearchFilters(); |
| | } |
| | |
| | function displaySearchResults(results) { |
| | const searchResultsContainer = $('search-results'); |
| | |
| | searchResultsContainer.innerHTML = results.map(([card, score]) => ` |
| | <div class="gallery-item"> |
| | <img src="${card.image_uris?.normal || ''}" alt="${card.name}" class="w-full h-full object-cover"> |
| | ${card.prices?.usd ? `<div class="absolute bottom-2 left-2 price-tag text-xs">${parseFloat(card.prices.usd).toFixed(2)}</div>` : ''} |
| | <div class="gallery-overlay"> |
| | <div class="gallery-overlay-content"> |
| | <h4 class="font-semibold text-white line-clamp-2">${card.name}</h4> |
| | <p class="text-xs text-white/60 mt-1">${card.type_line}</p> |
| | ${score < 1.0 ? `<p class="text-xs text-white/40 mt-1">${Math.round(score * 100)}% match</p>` : |
| | score === 1.0 ? `<p class="text-xs text-white/40 mt-1">From decklist</p>` : ''} |
| | |
| | <div class="gallery-actions"> |
| | <button onclick="loadCardFromSearch('${card.id}')" class="gallery-btn gallery-btn-view"> |
| | <i class="fas fa-eye"></i> |
| | View Card |
| | </button> |
| | ${card.purchase_uris?.tcgplayer ? ` |
| | <a href="${card.purchase_uris.tcgplayer}" target="_blank" class="gallery-btn"> |
| | <i class="fas fa-shopping-cart"></i> |
| | Buy |
| | </a> |
| | ` : ''} |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | `).join(''); |
| | } |
| | |
| | async function loadCardFromSearch(id) { |
| | setLoading(true); |
| | try { |
| | const response = await fetch(`https://api.scryfall.com/cards/${id}`); |
| | const card = await response.json(); |
| | |
| | // Exit search mode and display the card |
| | exitSearchMode(); |
| | displayCard(card); |
| | |
| | // Clear search input |
| | searchInput.value = ''; |
| | searchClear.classList.remove('visible'); |
| | |
| | window.scrollTo({ top: 0, behavior: 'smooth' }); |
| | showToast('Card loaded successfully', 'success'); |
| | } catch (error) { |
| | showToast('Failed to load card', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | // Card Functions |
| | async function fetchRandomCard() { |
| | if (isLoading) return; |
| | setLoading(true); |
| | |
| | try { |
| | const response = await fetch('https://api.scryfall.com/cards/random?q=is%3Acommander'); |
| | const card = await response.json(); |
| | displayCard(card); |
| | } catch (error) { |
| | showToast('Failed to fetch card', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | function displayCard(card) { |
| | currentCard = card; |
| | currentFace = 0; |
| | |
| | // Update background |
| | if (card.image_uris?.art_crop) { |
| | document.body.style.setProperty('--bg-image', `url('${card.image_uris.art_crop}')`); |
| | document.body.style.backgroundImage = `url('${card.image_uris.art_crop}')`; |
| | } |
| | |
| | // Display card face |
| | displayCardFace(card, 0); |
| | |
| | // Update info |
| | updateCardInfo(card); |
| | |
| | // Fetch similar cards |
| | fetchSimilarCards(card); |
| | |
| | // Update gallery header back to "Similar Cards" |
| | $('gallery-header').textContent = 'Similar Cards'; |
| | } |
| | |
| | function displayCardFace(card, faceIndex) { |
| | const hasFaces = card.card_faces?.length > 1; |
| | const face = hasFaces ? card.card_faces[faceIndex] : card; |
| | const imageUris = face.image_uris || card.image_uris; |
| | |
| | // Update image |
| | if (imageUris?.large) { |
| | $('card-image').innerHTML = `<img src="${imageUris.large}" alt="${face.name}" class="w-full h-full object-contain">`; |
| | } |
| | |
| | // Update face navigation |
| | if (hasFaces) { |
| | $('face-nav').classList.remove('hidden'); |
| | $('face-indicator').classList.remove('hidden'); |
| | $('face-indicator').textContent = `Face ${faceIndex + 1}/${card.card_faces.length}`; |
| | $('face-counter').textContent = `${faceIndex + 1}/${card.card_faces.length}`; |
| | } else { |
| | $('face-nav').classList.add('hidden'); |
| | $('face-indicator').classList.add('hidden'); |
| | } |
| | |
| | // Update card info |
| | $('card-name').textContent = face.name || card.name; |
| | $('card-type').innerHTML = `<i class="fas fa-layer-group mr-2"></i>${face.type_line || card.type_line}`; |
| | } |
| | |
| | // Oracle text search functionality |
| | function createOracleSearchButtons(oracleText) { |
| | if (!oracleText) return ''; |
| | |
| | const cardName = currentCard.name; |
| | const shortName = cardName.includes(',') ? cardName.split(',')[0].trim() : cardName; |
| | |
| | // Split oracle text into meaningful lines |
| | const lines = oracleText.split('\n').filter(line => line.trim().length > 0); |
| | const buttons = []; |
| | |
| | lines.forEach((line) => { |
| | let cleanedLine = line.trim(); |
| | if (cleanedLine.length > 10) { |
| | // Replace card names with "this card" (case insensitive) |
| | cleanedLine = cleanedLine.replace(new RegExp(cardName, 'gi'), 'this card'); |
| | if (shortName !== cardName) { |
| | cleanedLine = cleanedLine.replace(new RegExp(shortName, 'gi'), 'this card'); |
| | } |
| | |
| | // Show full text in buttons now |
| | buttons.push(` |
| | <button class="oracle-search-btn" onclick="searchOracleText('${cleanedLine.replace(/'/g, "\\'")}')"> |
| | <i class="fas fa-search mr-2"></i> |
| | ${cleanedLine} |
| | </button> |
| | `); |
| | } |
| | }); |
| | |
| | return buttons.join(''); |
| | } |
| | |
| | async function searchOracleText(searchTerm) { |
| | searchInput.value = searchTerm; |
| | searchClear.classList.add('visible'); |
| | await performDeckDoctorSearch(searchTerm); |
| | } |
| | |
| | // Mana cost display helper |
| | function formatManaCost(manaCost) { |
| | if (!manaCost) return ''; |
| | |
| | const symbols = manaCost.match(/\{[^}]+\}/g) || []; |
| | return symbols.map(symbol => { |
| | const clean = symbol.replace(/[{}]/g, ''); |
| | let color = ''; |
| | let bgColor = ''; |
| | |
| | switch(clean) { |
| | case 'W': color = 'white'; bgColor = '#FFFBD5'; break; |
| | case 'U': color = 'blue'; bgColor = '#0E68AB'; break; |
| | case 'B': color = 'black'; bgColor = '#150B00'; break; |
| | case 'R': color = 'red'; bgColor = '#D3202A'; break; |
| | case 'G': color = 'green'; bgColor = '#00733E'; break; |
| | case 'C': color = 'colorless'; bgColor = '#A89B9A'; break; |
| | default: color = 'generic'; bgColor = '#CAC5C0'; break; |
| | } |
| | |
| | return `<span class="mana-symbol" style="background-color: ${bgColor}">${clean}</span>`; |
| | }).join(''); |
| | } |
| | |
| | function updateCardInfo(card) { |
| | // Oracle text search buttons |
| | const hasFaces = card.card_faces?.length > 1; |
| | const face = hasFaces ? card.card_faces[currentFace] : card; |
| | const oracleText = face.oracle_text || card.oracle_text || ''; |
| | |
| | const oracleSection = $('oracle-search-section'); |
| | if (oracleText) { |
| | $('oracle-search-buttons').innerHTML = createOracleSearchButtons(oracleText); |
| | oracleSection.style.display = 'block'; |
| | |
| | // Add container class for better mobile layout |
| | $('oracle-search-buttons').classList.add('space-y-2'); |
| | } else { |
| | oracleSection.style.display = 'none'; |
| | } |
| | |
| | // Legalities |
| | const formats = [ |
| | { key: 'standard', name: 'Standard' }, |
| | { key: 'modern', name: 'Modern' }, |
| | { key: 'legacy', name: 'Legacy' }, |
| | { key: 'commander', name: 'Commander' } |
| | ]; |
| | $('legalities').innerHTML = formats.map(format => { |
| | const status = card.legalities?.[format.key]; |
| | if (!status) return ''; |
| | const isLegal = status === 'legal'; |
| | return `<div class="legality-badge ${isLegal ? 'legality-legal' : status === 'banned' ? 'legality-banned' : 'legality-not-legal'}"> |
| | <i class="fas fa-${isLegal ? 'check' : 'times'}"></i> |
| | <span>${format.name}</span> |
| | </div>`; |
| | }).join(''); |
| | |
| | // Rankings |
| | $('rankings').innerHTML = card.edhrec_rank ? |
| | `#${card.edhrec_rank.toLocaleString()} <span class="text-xs text-white/60">EDH Rank</span>` : '—'; |
| | |
| | // Prices |
| | const prices = []; |
| | if (card.prices?.usd) prices.push({ |
| | label: 'USD', |
| | value: `${parseFloat(card.prices.usd).toFixed(2)}`, |
| | url: card.purchase_uris?.tcgplayer |
| | }); |
| | if (card.prices?.usd_foil) prices.push({ |
| | label: 'Foil', |
| | value: `${parseFloat(card.prices.usd_foil).toFixed(2)}`, |
| | url: card.purchase_uris?.tcgplayer |
| | }); |
| | |
| | $('prices').innerHTML = prices.length ? prices.map(p => |
| | `<a href="${p.url || '#'}" target="_blank" class="price-item" ${p.url ? '' : 'style="cursor: default;"'}> |
| | <div class="price-value">${p.value}</div> |
| | <div class="price-label">${p.label}</div> |
| | </a>` |
| | ).join('') : '<div class="text-center text-white/40">No pricing data</div>'; |
| | |
| | // Links |
| | const links = []; |
| | if (card.scryfall_uri) links.push({ name: 'Scryfall', url: card.scryfall_uri, icon: 'search' }); |
| | |
| | // Purchase links with fallbacks |
| | if (card.purchase_uris?.tcgplayer) { |
| | links.push({ name: 'TCGPlayer', url: card.purchase_uris.tcgplayer, icon: 'shopping-cart' }); |
| | } else if (card.purchase_uris?.cardmarket) { |
| | links.push({ name: 'Cardmarket', url: card.purchase_uris.cardmarket, icon: 'shopping-cart' }); |
| | } |
| | |
| | if (card.purchase_uris?.cardhoarder) { |
| | links.push({ name: 'Cardhoarder', url: card.purchase_uris.cardhoarder, icon: 'shopping-cart' }); |
| | } |
| | |
| | if (card.related_uris?.edhrec) links.push({ name: 'EDHREC', url: card.related_uris.edhrec, icon: 'chart-bar' }); |
| | |
| | $('links').innerHTML = links.map(link => |
| | `<a href="${link.url}" target="_blank" class="glass-button"> |
| | <i class="fas fa-${link.icon} mr-2"></i>${link.name} |
| | </a>` |
| | ).join(''); |
| | } |
| | |
| | // Build card query string matching Python function |
| | function buildCardQueryString(card) { |
| | // Extract card data |
| | const name = card.name || ''; |
| | const manaCost = card.mana_cost || ''; |
| | const cmc = card.cmc || 0; |
| | const colors = card.colors || []; |
| | const types = card.type_line ? card.type_line.split('—')[0].trim().split(' ') : []; |
| | const subtypes = card.type_line && card.type_line.includes('—') ? |
| | card.type_line.split('—')[1].trim().split(' ') : []; |
| | const oracleText = card.oracle_text || ''; |
| | const power = card.power || null; |
| | const toughness = card.toughness || null; |
| | |
| | // Color replacement |
| | let colorString = colors.join(' ') |
| | .replace(/W/g, 'Plains') |
| | .replace(/B/g, 'Swamp') |
| | .replace(/U/g, 'Island') |
| | .replace(/R/g, 'Mountain') |
| | .replace(/G/g, 'Forest'); |
| | |
| | // Clean color text |
| | colorString = colorString.replace(/\}\{/g, ',').replace(/\{/g, '').replace(/\}/g, ''); |
| | |
| | // Clean mana cost |
| | let cleanManaCost = manaCost.replace(/\{/g, '').replace(/\}/g, '').replace(/ /g, '').toLowerCase(); |
| | if (cmc) { |
| | cleanManaCost += ` (${parseInt(cmc)})`; |
| | } |
| | |
| | // Clean oracle text |
| | let cleanText = oracleText |
| | .replace(/•/g, '--') |
| | .replace(/\n/g, '. ') |
| | .replace(/\{/g, '') |
| | .replace(/\}/g, '') |
| | .replace(/\.\./g, '.') |
| | .replace(/ /g, ' ') |
| | .replace(/\u2014/g, '--') |
| | .replace(/ \u2022/g, ':'); |
| | |
| | // Replace name in text |
| | let cardName = name; |
| | if (name.includes(',')) { |
| | const altName = name.split(',')[0].trim(); |
| | cleanText = cleanText.replace(new RegExp(altName, 'g'), 'this card'); |
| | } |
| | |
| | const subtypeString = subtypes.join(' '); |
| | const typeString = types.join(' '); |
| | |
| | // Build card string based on type |
| | let cardStr = ''; |
| | if (types.includes('Creature')) { |
| | cardStr = `${cardName}, ${cleanManaCost}, ${subtypeString}, ${power}/${toughness}, ${cleanText}`; |
| | } else if (types.includes('Land')) { |
| | cardStr = `${cardName}, ${colorString}, ${cleanText}`; |
| | } else { |
| | cardStr = `${cardName}, ${cleanManaCost}, ${typeString}, ${cleanText}`; |
| | } |
| | |
| | // Clean up card string |
| | cardStr = cardStr.trim(); |
| | if (cardStr.endsWith(',')) { |
| | cardStr = cardStr.slice(0, -1).trim(); |
| | } |
| | |
| | // Final cleaning |
| | return cardStr.replace(/, ,/g, ','); |
| | } |
| | |
| | async function fetchSimilarCards(card) { |
| | try { |
| | // Build the query string using the same logic as Python |
| | const query = buildCardQueryString(card); |
| | currentGalleryQuery = query; |
| | console.log('Query string:', query); // Debug logging |
| | |
| | // Build URL with gallery color filters |
| | let url = `https://api.deck.doctor/v1/mtg/search?q=${encodeURIComponent(query)}&topk=12&price_threshold=0`; |
| | |
| | // Add color filters for gallery |
| | galleryColors.forEach(color => { |
| | url += `&colors=${color}`; |
| | }); |
| | |
| | const response = await fetch(url); |
| | const data = await response.json(); |
| | |
| | if (data?.length > 0) { |
| | // Filter out the current card more robustly |
| | const filteredResults = data.filter(([c]) => { |
| | // Check multiple identifiers to ensure we exclude the current card |
| | return c.id !== card.id && |
| | c.name !== card.name && |
| | (!c.scryfall_uri || c.scryfall_uri !== card.scryfall_uri); |
| | }).slice(0, 11); // Take at most 11 cards after filtering |
| | |
| | displayGallery(filteredResults); |
| | $('gallery-section').classList.remove('hidden'); |
| | |
| | // Update header if filters are active |
| | const colorFilterText = galleryColors.size > 0 ? ` (${Array.from(galleryColors).join('')})` : ''; |
| | $('gallery-header').textContent = `Similar Cards${colorFilterText}`; |
| | } |
| | } catch (error) { |
| | console.error('Gallery error:', error); |
| | } |
| | } |
| | |
| | function displayGallery(cards) { |
| | $('gallery').innerHTML = cards.map(([card, score]) => ` |
| | <div class="gallery-item"> |
| | <img src="${card.image_uris?.normal || ''}" alt="${card.name}" class="w-full h-full object-cover"> |
| | ${card.prices?.usd ? `<div class="absolute top-2 left-2 price-tag text-xs">$${parseFloat(card.prices.usd).toFixed(2)}</div>` : ''} |
| | <div class="gallery-overlay"> |
| | <div class="gallery-overlay-content"> |
| | <h4 class="font-semibold text-white line-clamp-2 mb-2">${card.name}</h4> |
| | ${card.mana_cost ? `<div class="gallery-mana-cost mb-2">${formatManaCost(card.mana_cost)}</div>` : ''} |
| | <p class="text-xs text-white/60 line-clamp-2 mb-2">${card.type_line}</p> |
| | ${card.oracle_text ? `<p class="text-xs text-white/50 line-clamp-3 mb-3">${card.oracle_text}</p>` : ''} |
| | <p class="text-xs text-white/40 mb-3">${Math.round(score * 100)}% similar</p> |
| | |
| | <div class="gallery-actions"> |
| | <button onclick="loadCard('${card.id}')" class="gallery-btn gallery-btn-view"> |
| | <i class="fas fa-eye"></i> |
| | View Card |
| | </button> |
| | ${card.purchase_uris?.tcgplayer ? ` |
| | <a href="${card.purchase_uris.tcgplayer}" target="_blank" class="gallery-btn"> |
| | <i class="fas fa-shopping-cart"></i> |
| | TCG |
| | </a> |
| | ` : card.purchase_uris?.cardmarket ? ` |
| | <a href="${card.purchase_uris.cardmarket}" target="_blank" class="gallery-btn"> |
| | <i class="fas fa-shopping-cart"></i> |
| | CM |
| | </a> |
| | ` : ''} |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | `).join(''); |
| | } |
| | |
| | async function loadCard(id) { |
| | setLoading(true); |
| | try { |
| | const response = await fetch(`https://api.scryfall.com/cards/${id}`); |
| | const card = await response.json(); |
| | displayCard(card); |
| | window.scrollTo({ top: 0, behavior: 'smooth' }); |
| | showToast('Card loaded successfully', 'success'); |
| | } catch (error) { |
| | showToast('Failed to load card', 'error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | } |
| | |
| | function switchFace(direction) { |
| | if (!currentCard?.card_faces) return; |
| | currentFace = (currentFace + direction + currentCard.card_faces.length) % currentCard.card_faces.length; |
| | displayCardFace(currentCard, currentFace); |
| | updateCardInfo(currentCard); // Refresh oracle text buttons for the new face |
| | } |
| | |
| | // Utilities |
| | function setLoading(state) { |
| | isLoading = state; |
| | loading.classList.toggle('hidden', !state); |
| | randomBtn.disabled = state; |
| | } |
| | |
| | function showToast(message, type = 'info') { |
| | toast.textContent = message; |
| | toast.className = `toast ${type} show`; |
| | setTimeout(() => toast.classList.remove('show'), 3000); |
| | } |
| | </script> |
| | </body> |
| | </html> |