Compare commits
10 Commits
feature/is
...
90b39481d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b39481d1 | |||
| e83874162f | |||
| ebae5a82db | |||
|
|
1b8d4519e6 | ||
|
|
defcf2af9c | ||
|
|
e062a3a84f | ||
|
|
73b7735074 | ||
| 57f0e7efe7 | |||
| 6de32baefb | |||
| 215c64762f |
6
.claude/settings.local.json
Normal file
6
.claude/settings.local.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"supabase"
|
||||||
|
],
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
|
}
|
||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.supabase.com/mcp?project_ref=yedghjrpyxhxesnbtbip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
PROJECT_STRUCTURE.md
Normal file
157
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Deckerr
|
||||||
|
|
||||||
|
A modern Magic: The Gathering deck builder and collection manager built with React, TypeScript, and Supabase.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Deckerr is a web application that allows Magic: The Gathering players to build and manage their decks, track their card collections, search for cards, and use a life counter during games. The application provides a clean, animated interface with user authentication and persistent storage.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Deck Management**: Create, edit, and organize your Magic: The Gathering decks
|
||||||
|
- **Collection Tracking**: Keep track of your card collection with quantity management
|
||||||
|
- **Card Search**: Search and browse Magic cards to add to your decks or collection
|
||||||
|
- **Life Counter**: Track player life totals during games
|
||||||
|
- **User Profiles**: Personalized user profiles with theme customization
|
||||||
|
- **Authentication**: Secure user authentication powered by Supabase
|
||||||
|
- **Format Support**: Support for different Magic formats (Commander, Standard, etc.)
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Frontend**: React 18 with TypeScript
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
- **Styling**: Tailwind CSS with custom animations
|
||||||
|
- **Backend/Database**: Supabase (PostgreSQL)
|
||||||
|
- **Authentication**: Supabase Auth
|
||||||
|
- **Icons**: Lucide React
|
||||||
|
- **Linting**: ESLint with TypeScript support
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
deckerr/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ │ ├── CardCarousel.tsx # Card carousel display
|
||||||
|
│ │ ├── CardSearch.tsx # Card search interface
|
||||||
|
│ │ ├── Collection.tsx # Collection management
|
||||||
|
│ │ ├── DeckBuilder.tsx # Deck building interface
|
||||||
|
│ │ ├── DeckCard.tsx # Deck card component
|
||||||
|
│ │ ├── DeckEditor.tsx # Deck editing interface
|
||||||
|
│ │ ├── DeckList.tsx # List of user decks
|
||||||
|
│ │ ├── DeckManager.tsx # Deck management
|
||||||
|
│ │ ├── LifeCounter.tsx # Game life counter
|
||||||
|
│ │ ├── LoginForm.tsx # User authentication form
|
||||||
|
│ │ ├── MagicCard.tsx # Magic card display component
|
||||||
|
│ │ ├── ManaIcons.tsx # Mana symbol icons
|
||||||
|
│ │ ├── Navigation.tsx # App navigation
|
||||||
|
│ │ └── Profile.tsx # User profile page
|
||||||
|
│ │
|
||||||
|
│ ├── contexts/ # React contexts
|
||||||
|
│ │ └── AuthContext.tsx # Authentication context
|
||||||
|
│ │
|
||||||
|
│ ├── lib/ # Library code
|
||||||
|
│ │ ├── Entities.ts # Supabase database types
|
||||||
|
│ │ └── supabase.ts # Supabase client configuration
|
||||||
|
│ │
|
||||||
|
│ ├── services/ # API and service layers
|
||||||
|
│ │ └── api.ts # API service functions
|
||||||
|
│ │
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ └── index.ts # Shared types
|
||||||
|
│ │
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ │ ├── deckValidation.ts # Deck validation logic
|
||||||
|
│ │ └── theme.ts # Theme utilities
|
||||||
|
│ │
|
||||||
|
│ ├── App.tsx # Main application component
|
||||||
|
│ ├── main.tsx # Application entry point
|
||||||
|
│ ├── main.js # JavaScript entry
|
||||||
|
│ ├── index.css # Global styles
|
||||||
|
│ └── vite-env.d.ts # Vite type definitions
|
||||||
|
│
|
||||||
|
├── public/ # Static assets (if any)
|
||||||
|
│
|
||||||
|
├── index.html # HTML entry point
|
||||||
|
├── package.json # Project dependencies
|
||||||
|
├── vite.config.ts # Vite configuration
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── tsconfig.app.json # App-specific TS config
|
||||||
|
├── tsconfig.node.json # Node-specific TS config
|
||||||
|
├── tailwind.config.js # Tailwind CSS configuration
|
||||||
|
├── postcss.config.js # PostCSS configuration
|
||||||
|
├── eslint.config.js # ESLint configuration
|
||||||
|
├── LICENSE # Project license
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application uses Supabase with the following main tables:
|
||||||
|
|
||||||
|
- **profiles**: User profiles with username and theme preferences
|
||||||
|
- **decks**: User decks with name, format, and timestamps
|
||||||
|
- **deck_cards**: Cards in decks with quantity and commander status
|
||||||
|
- **collections**: User card collections with quantities
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v18 or higher)
|
||||||
|
- npm or pnpm
|
||||||
|
- Supabase account and project
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd deckerr
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up environment variables:
|
||||||
|
Create a `.env` file with your Supabase credentials:
|
||||||
|
```
|
||||||
|
VITE_SUPABASE_URL=your_supabase_url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Open your browser to `http://localhost:5173`
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Start development server
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run preview` - Preview production build
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
|
||||||
|
## Features in Detail
|
||||||
|
|
||||||
|
### Deck Builder
|
||||||
|
Build and customize your Magic decks with support for different formats. The deck editor validates your deck composition according to format rules.
|
||||||
|
|
||||||
|
### Collection Manager
|
||||||
|
Track which cards you own and their quantities, making it easy to see what cards you have available when building decks.
|
||||||
|
|
||||||
|
### Card Search
|
||||||
|
Search through Magic cards using various filters and criteria to find exactly what you need for your deck.
|
||||||
|
|
||||||
|
### Life Counter
|
||||||
|
A built-in life counter for tracking player life totals during games, with an animated interface.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See the LICENSE file for details.
|
||||||
1
dev-dist/registerSW.js
Normal file
1
dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
110
dev-dist/sw.js
Normal file
110
dev-dist/sw.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-ca84f546'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "registerSW.js",
|
||||||
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
|
}, {
|
||||||
|
"url": "index.html",
|
||||||
|
"revision": "0.g74vi66e49"
|
||||||
|
}], {});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
allowlist: [/^\/$/]
|
||||||
|
}));
|
||||||
|
workbox.registerRoute(/^https:\/\/api\.scryfall\.com\/.*/i, new workbox.CacheFirst({
|
||||||
|
"cacheName": "scryfall-cache",
|
||||||
|
plugins: [new workbox.ExpirationPlugin({
|
||||||
|
maxEntries: 500,
|
||||||
|
maxAgeSeconds: 604800
|
||||||
|
}), new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
workbox.registerRoute(/^https:\/\/cards\.scryfall\.io\/.*/i, new workbox.CacheFirst({
|
||||||
|
"cacheName": "card-images-cache",
|
||||||
|
plugins: [new workbox.ExpirationPlugin({
|
||||||
|
maxEntries: 1000,
|
||||||
|
maxAgeSeconds: 2592000
|
||||||
|
}), new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
|
||||||
|
}));
|
||||||
4556
dev-dist/workbox-ca84f546.js
Normal file
4556
dev-dist/workbox-ca84f546.js
Normal file
File diff suppressed because it is too large
Load Diff
25
index.html
25
index.html
@@ -2,9 +2,28 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Vite + React + TS</title>
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>Deckerr - Card Deck Manager</title>
|
||||||
|
<meta name="title" content="Deckerr - Card Deck Manager" />
|
||||||
|
<meta name="description" content="Manage your trading card game decks on the go. Build, organize, and track your card collection." />
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Deckerr" />
|
||||||
|
|
||||||
|
<!-- Apple Touch Icons -->
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icon.svg" />
|
||||||
|
|
||||||
|
<!-- MS Application -->
|
||||||
|
<meta name="msapplication-TileColor" content="#0f172a" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
5887
package-lock.json
generated
5887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-react-typescript-starter",
|
"name": "deckerr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.2",
|
||||||
|
"vite-plugin-pwa": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
public/browserconfig.xml
Normal file
9
public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/icon.svg"/>
|
||||||
|
<TileColor>#0f172a</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
23
public/icon.svg
Normal file
23
public/icon.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="512" height="512" fill="#0f172a" rx="128"/>
|
||||||
|
|
||||||
|
<!-- Card stack effect - back card -->
|
||||||
|
<rect x="120" y="130" width="240" height="320" rx="16" fill="#1e293b" transform="rotate(-8 240 290)"/>
|
||||||
|
|
||||||
|
<!-- Card stack effect - middle card -->
|
||||||
|
<rect x="120" y="130" width="240" height="320" rx="16" fill="#334155" transform="rotate(-4 240 290)"/>
|
||||||
|
|
||||||
|
<!-- Front card -->
|
||||||
|
<rect x="120" y="130" width="240" height="320" rx="16" fill="#475569"/>
|
||||||
|
<rect x="135" y="145" width="210" height="290" rx="12" fill="#1e293b"/>
|
||||||
|
|
||||||
|
<!-- Card details/design -->
|
||||||
|
<circle cx="240" cy="200" r="40" fill="#3b82f6" opacity="0.6"/>
|
||||||
|
<rect x="160" y="280" width="160" height="12" rx="6" fill="#3b82f6" opacity="0.8"/>
|
||||||
|
<rect x="160" y="310" width="120" height="12" rx="6" fill="#3b82f6" opacity="0.6"/>
|
||||||
|
<rect x="160" y="340" width="140" height="12" rx="6" fill="#3b82f6" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- "D" Letter overlay -->
|
||||||
|
<text x="256" y="310" font-family="Arial, sans-serif" font-size="160" font-weight="bold" fill="#3b82f6" text-anchor="middle" opacity="0.15">D</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/mana-color/forest.png
Normal file
BIN
public/mana-color/forest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
public/mana-color/island.png
Normal file
BIN
public/mana-color/island.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
public/mana-color/moutain.png
Normal file
BIN
public/mana-color/moutain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
public/mana-color/plains.png
Normal file
BIN
public/mana-color/plains.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
BIN
public/mana-color/swamp.png
Normal file
BIN
public/mana-color/swamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
111
public/manifest.json
Normal file
111
public/manifest.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"name": "Deckerr - Card Deck Manager",
|
||||||
|
"short_name": "Deckerr",
|
||||||
|
"description": "Manage your trading card game decks on the go",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/screenshot-mobile-1.png",
|
||||||
|
"sizes": "540x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/screenshot-desktop-1.png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["games", "utilities"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "My Decks",
|
||||||
|
"short_name": "Decks",
|
||||||
|
"description": "View your deck collection",
|
||||||
|
"url": "/?page=home",
|
||||||
|
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Search Cards",
|
||||||
|
"short_name": "Search",
|
||||||
|
"description": "Search for cards",
|
||||||
|
"url": "/?page=search",
|
||||||
|
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Life Counter",
|
||||||
|
"short_name": "Life",
|
||||||
|
"description": "Track life totals",
|
||||||
|
"url": "/?page=life-counter",
|
||||||
|
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
src/App.tsx
11
src/App.tsx
@@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
|||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import CardSearch from './components/CardSearch';
|
import CardSearch from './components/CardSearch';
|
||||||
import LifeCounter from './components/LifeCounter';
|
import LifeCounter from './components/LifeCounter';
|
||||||
|
import PWAInstallPrompt from './components/PWAInstallPrompt';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
|
||||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
|
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
|
||||||
@@ -38,10 +39,13 @@ import React, { useState } from 'react';
|
|||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6 animate-fade-in">
|
<div className="bg-gray-900 text-white p-3 sm:p-6 animate-fade-in">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6 animate-slide-in-left">My Decks</h1>
|
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6 animate-slide-in-left">My Decks</h1>
|
||||||
<DeckList onDeckEdit={handleDeckEdit} />
|
<DeckList
|
||||||
|
onDeckEdit={handleDeckEdit}
|
||||||
|
onCreateDeck={() => setCurrentPage('deck')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -76,6 +80,7 @@ import React, { useState } from 'react';
|
|||||||
<div className="min-h-screen bg-gray-900">
|
<div className="min-h-screen bg-gray-900">
|
||||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
|
<PWAInstallPrompt />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { searchCards, getUserCollection, addCardToCollection } from '../services
|
|||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import MagicCard from './MagicCard';
|
import MagicCard from './MagicCard';
|
||||||
|
import { getManaIconPath } from './ManaCost';
|
||||||
|
|
||||||
const CardSearch = () => {
|
const CardSearch = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -192,9 +193,9 @@ const CardSearch = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="bg-gray-900 text-white p-3 sm:p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">Card Search</h1>
|
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6">Card Search</h1>
|
||||||
<form onSubmit={handleSearch} className="mb-8 space-y-4">
|
<form onSubmit={handleSearch} className="mb-8 space-y-4">
|
||||||
{/* Card Details */}
|
{/* Card Details */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -219,7 +220,7 @@ const CardSearch = () => {
|
|||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
placeholder="Rules Text (~ for card name)"
|
placeholder="Rules Text (~ for card name)"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={typeLine}
|
value={typeLine}
|
||||||
@@ -259,9 +260,11 @@ const CardSearch = () => {
|
|||||||
onChange={() => setColors({ ...colors, [color]: !active })}
|
onChange={() => setColors({ ...colors, [color]: !active })}
|
||||||
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '1.5em' }}>
|
{getManaIconPath(color) ? (
|
||||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
<img src={getManaIconPath(color)!} alt={color} className="w-6 h-6" />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">{color}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,9 +288,11 @@ const CardSearch = () => {
|
|||||||
onChange={() => setCommanderColors({ ...commanderColors, [color]: !active })}
|
onChange={() => setCommanderColors({ ...commanderColors, [color]: !active })}
|
||||||
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '1.5em' }}>
|
{getManaIconPath(color) ? (
|
||||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
<img src={getManaIconPath(color)!} alt={color} className="w-6 h-6" />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">{color}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -295,25 +300,32 @@ const CardSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mana Cost */}
|
{/* Mana Cost */}
|
||||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
|
||||||
{Object.entries(manaCost).map(([color, count]) => (
|
{Object.entries(manaCost).map(([color, count]) => {
|
||||||
|
const iconPath = getManaIconPath(color);
|
||||||
|
return (
|
||||||
<div key={color} className="flex items-center space-x-2">
|
<div key={color} className="flex items-center space-x-2">
|
||||||
<span style={{ fontSize: '1.5em' }}>
|
{iconPath ? (
|
||||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
<img src={iconPath} alt={color} className="w-6 h-6 md:w-8 md:h-8" />
|
||||||
|
) : (
|
||||||
|
<span className="w-6 h-6 md:w-8 md:h-8 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">
|
||||||
|
{color}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={count}
|
value={count}
|
||||||
onChange={(e) => setManaCost({ ...manaCost, [color]: parseInt(e.target.value) })}
|
onChange={(e) => setManaCost({ ...manaCost, [color]: parseInt(e.target.value) })}
|
||||||
className="w-16 px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-14 sm:w-16 px-2 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<select
|
<select
|
||||||
value={manaValueComparison}
|
value={manaValueComparison}
|
||||||
onChange={(e) => setManaValueComparison(e.target.value)}
|
onChange={(e) => setManaValueComparison(e.target.value)}
|
||||||
@@ -354,7 +366,7 @@ const CardSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Formats */}
|
{/* Formats */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<select
|
<select
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(e) => setFormat(e.target.value)}
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
@@ -396,7 +408,7 @@ const CardSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sets */}
|
{/* Sets */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={set}
|
value={set}
|
||||||
@@ -432,7 +444,7 @@ const CardSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Criteria */}
|
{/* Criteria */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={criteria}
|
value={criteria}
|
||||||
@@ -459,7 +471,7 @@ const CardSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prices */}
|
{/* Prices */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<select
|
<select
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={(e) => setCurrency(e.target.value)}
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
@@ -586,7 +598,7 @@ const CardSearch = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg"
|
className="mt-4 w-full sm:w-auto min-h-[44px] px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium text-base"
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -605,7 +617,7 @@ const CardSearch = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{searchResults && searchResults.length > 0 && (
|
{searchResults && searchResults.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||||
{searchResults.map((card) => {
|
{searchResults.map((card) => {
|
||||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
const isMultiFaced = isDoubleFaced(card);
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ export default function Collection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="bg-gray-900 text-white p-3 sm:p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6">My Collection</h1>
|
||||||
|
|
||||||
{/* Search within collection */}
|
{/* Search within collection */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -228,7 +228,7 @@ export default function Collection() {
|
|||||||
<p className="text-sm">Try a different search term</p>
|
<p className="text-sm">Try a different search term</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9 gap-2 sm:gap-3">
|
||||||
{filteredCollection.map(({ card, quantity }) => {
|
{filteredCollection.map(({ card, quantity }) => {
|
||||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
const isMultiFaced = isDoubleFaced(card);
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
@@ -252,7 +252,7 @@ export default function Collection() {
|
|||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
/>
|
/>
|
||||||
{/* Quantity badge */}
|
{/* Quantity badge */}
|
||||||
<div className="absolute top-1 right-1 bg-blue-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
|
<div className="absolute top-1 right-1 bg-blue-600 text-white text-xs sm:text-sm font-bold px-2 py-1 rounded-full shadow-lg">
|
||||||
x{quantity}
|
x{quantity}
|
||||||
</div>
|
</div>
|
||||||
{/* Flip button for double-faced cards */}
|
{/* Flip button for double-faced cards */}
|
||||||
@@ -295,7 +295,7 @@ export default function Collection() {
|
|||||||
const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text;
|
const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-1/2 right-8 transform -translate-y-1/2 z-40 pointer-events-none">
|
<div className="hidden lg:block fixed top-1/2 right-8 transform -translate-y-1/2 z-40 pointer-events-none">
|
||||||
<div className="bg-gray-800 rounded-lg shadow-2xl p-4 max-w-md">
|
<div className="bg-gray-800 rounded-lg shadow-2xl p-4 max-w-md">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
@@ -350,15 +350,17 @@ export default function Collection() {
|
|||||||
|
|
||||||
{/* Sliding Panel */}
|
{/* Sliding Panel */}
|
||||||
<div className="fixed top-0 right-0 h-full w-full md:w-96 bg-gray-800 shadow-2xl z-50 overflow-y-auto animate-slide-in-right">
|
<div className="fixed top-0 right-0 h-full w-full md:w-96 bg-gray-800 shadow-2xl z-50 overflow-y-auto animate-slide-in-right">
|
||||||
<div className="p-6">
|
{/* Close button - fixed position, stays visible when scrolling */}
|
||||||
{/* Close button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCard(null)}
|
onClick={() => setSelectedCard(null)}
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
className="fixed top-4 right-4 bg-gray-700 hover:bg-gray-600 text-white p-2 md:p-1.5 rounded-full transition-colors z-[60] shadow-lg"
|
||||||
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} className="md:w-5 md:h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
|
||||||
{/* Card Image */}
|
{/* Card Image */}
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<img
|
<img
|
||||||
@@ -385,8 +387,8 @@ export default function Collection() {
|
|||||||
{/* Card Info */}
|
{/* Card Info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">{displayName}</h2>
|
<h2 className="text-xl md:text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||||
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
<p className="text-xs sm:text-sm text-gray-400">{displayTypeLine}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayOracleText && (
|
{displayOracleText && (
|
||||||
@@ -442,7 +444,7 @@ export default function Collection() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
className="w-full mt-3 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg flex items-center justify-center gap-2 transition-colors"
|
className="w-full mt-3 min-h-[44px] px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
Remove from Collection
|
Remove from Collection
|
||||||
|
|||||||
@@ -22,42 +22,38 @@ export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg card-hover cursor-pointer animate-scale-in"
|
className="bg-gray-800 rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-all cursor-pointer group"
|
||||||
onClick={() => onEdit?.(deck.id)}
|
onClick={() => onEdit?.(deck.id)}
|
||||||
>
|
>
|
||||||
<div className="relative h-48 overflow-hidden">
|
{/* Full Card Art */}
|
||||||
|
<div className="relative aspect-[5/7] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||||
alt={commander?.name || deck.cards[0]?.card.name}
|
alt={commander?.name || deck.cards[0]?.card.name}
|
||||||
className="w-full object-cover object-top transform translate-y-[-12%]"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
{/* Overlay for text readability */}
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/60 to-transparent" />
|
||||||
|
|
||||||
<div className="p-4">
|
{/* Deck info overlay */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||||
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
<div className="flex items-start justify-between mb-1">
|
||||||
|
<h3 className="text-base sm:text-lg font-bold text-white line-clamp-2 flex-1">{deck.name}</h3>
|
||||||
{validation.isValid ? (
|
{validation.isValid ? (
|
||||||
<div className="flex items-center text-green-400">
|
<Check size={16} className="text-green-400 ml-2 flex-shrink-0" />
|
||||||
<Check size={16} className="mr-1" />
|
|
||||||
<span className="text-sm">Legal</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
<AlertTriangle size={16} className="text-yellow-400 ml-2 flex-shrink-0" title={validation.errors.join(', ')} />
|
||||||
<AlertTriangle size={16} className="mr-1" />
|
|
||||||
<span className="text-sm">Issues</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
<div className="flex items-center justify-between text-xs text-gray-300 mb-2">
|
||||||
<span className="capitalize">{deck.format}</span>
|
<span className="capitalize">{deck.format}</span>
|
||||||
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{commander && (
|
{commander && (
|
||||||
<div className="mt-2 text-sm text-gray-300">
|
<div className="text-xs text-blue-300 mb-2 truncate">
|
||||||
<span className="text-blue-400">Commander:</span> {commander.name}
|
<span className="font-semibold">Commander:</span> {commander.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -66,12 +62,13 @@ export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit?.(deck.id);
|
onEdit?.(deck.id);
|
||||||
}}
|
}}
|
||||||
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white btn-ripple transition-smooth glow-on-hover"
|
className="w-full min-h-[36px] px-3 py-2 bg-blue-600/90 hover:bg-blue-600 rounded-md flex items-center justify-center gap-2 text-white text-sm font-medium transition-colors backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<Edit size={20} />
|
<Edit size={16} />
|
||||||
Edit Deck
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { getCardById, getCardsByIds } from '../services/api';
|
|||||||
import { Deck } from '../types';
|
import { Deck } from '../types';
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import DeckCard from "./DeckCard";
|
import DeckCard from "./DeckCard";
|
||||||
|
import { PlusCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface DeckListProps {
|
interface DeckListProps {
|
||||||
onDeckEdit?: (deckId: string) => void;
|
onDeckEdit?: (deckId: string) => void;
|
||||||
|
onCreateDeck?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
const DeckList = ({ onDeckEdit, onCreateDeck }: DeckListProps) => {
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -88,10 +90,26 @@ const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
|
||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Create New Deck Card */}
|
||||||
|
<button
|
||||||
|
onClick={onCreateDeck}
|
||||||
|
className="bg-gray-800 rounded-lg overflow-hidden shadow-lg hover:shadow-xl border-2 border-dashed border-gray-600 hover:border-blue-500 transition-all duration-300 hover:scale-105 cursor-pointer group aspect-[5/7] flex flex-col items-center justify-center gap-3 p-4"
|
||||||
|
>
|
||||||
|
<PlusCircle size={48} className="text-gray-600 group-hover:text-blue-500 transition-colors" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-gray-400 group-hover:text-blue-400 transition-colors">
|
||||||
|
Create New Deck
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 hidden sm:block">
|
||||||
|
Start building
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw } from 'lucide-react';
|
import { Plus, Minus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw } from 'lucide-react';
|
||||||
import { Card, Deck } from '../types';
|
import { Card, Deck } from '../types';
|
||||||
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
|
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { validateDeck } from '../utils/deckValidation';
|
import { validateDeck } from '../utils/deckValidation';
|
||||||
import MagicCard from './MagicCard';
|
import MagicCard from './MagicCard';
|
||||||
|
import { ManaCost } from './ManaCost';
|
||||||
|
|
||||||
interface DeckManagerProps {
|
interface DeckManagerProps {
|
||||||
initialDeck?: Deck;
|
initialDeck?: Deck;
|
||||||
@@ -96,6 +97,7 @@ const suggestLandCountAndDistribution = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||||
|
const [currentDeckId, setCurrentDeckId] = useState<string | null>(initialDeck?.id || null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||||
const [selectedCards, setSelectedCards] = useState<{
|
const [selectedCards, setSelectedCards] = useState<{
|
||||||
@@ -310,8 +312,9 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const deckId = currentDeckId || crypto.randomUUID();
|
||||||
const deckToSave: Deck = {
|
const deckToSave: Deck = {
|
||||||
id: initialDeck?.id || crypto.randomUUID(),
|
id: deckId,
|
||||||
name: deckName,
|
name: deckName,
|
||||||
format: deckFormat,
|
format: deckFormat,
|
||||||
cards: selectedCards,
|
cards: selectedCards,
|
||||||
@@ -337,9 +340,14 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
|
|
||||||
if (deckError) throw deckError;
|
if (deckError) throw deckError;
|
||||||
|
|
||||||
|
// Update current deck ID if this was a new deck
|
||||||
|
if (!currentDeckId) {
|
||||||
|
setCurrentDeckId(deckId);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete existing cards if updating
|
// Delete existing cards if updating
|
||||||
if (initialDeck) {
|
if (currentDeckId) {
|
||||||
await supabase.from('deck_cards').delete().eq('deck_id', initialDeck.id);
|
await supabase.from('deck_cards').delete().eq('deck_id', currentDeckId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the deck cards
|
// Save the deck cards
|
||||||
@@ -490,40 +498,50 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="bg-gray-900 text-white p-3 sm:p-6 pt-6 pb-20 md:pt-20 md:pb-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{/* Card Search Section */}
|
{/* Card Search Section */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
{/* Mobile-First Search Bar */}
|
||||||
<div className="relative flex-1">
|
<form onSubmit={handleSearch} className="relative">
|
||||||
<Search
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full pl-10 pr-24 py-3 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 text-white"
|
||||||
placeholder="Search for cards..."
|
placeholder="Rechercher une carte..."
|
||||||
/>
|
/>
|
||||||
</div>
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
className="absolute right-14 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<XCircle size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||||
>
|
>
|
||||||
<Search size={20} />
|
<Search size={20} />
|
||||||
Search
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
{/* Vertical Card List for Mobile */}
|
||||||
|
<div className="space-y-2">
|
||||||
{searchResults.map(card => {
|
{searchResults.map(card => {
|
||||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
const isMultiFaced = isDoubleFaced(card);
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
const inCollection = userCollection.get(card.id) || 0;
|
const inCollection = userCollection.get(card.id) || 0;
|
||||||
const isAddingThisCard = addingCardId === card.id;
|
const isAddingThisCard = addingCardId === card.id;
|
||||||
|
const cardInDeck = selectedCards.find(c => c.card.id === card.id);
|
||||||
|
const quantityInDeck = cardInDeck?.quantity || 0;
|
||||||
|
|
||||||
const displayName = isMultiFaced && card.card_faces
|
const displayName = isMultiFaced && card.card_faces
|
||||||
? card.card_faces[currentFaceIndex]?.name || card.name
|
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||||
@@ -532,17 +550,18 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
className="bg-gray-800 rounded-lg p-3 flex items-center gap-3 hover:bg-gray-750 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
{/* Card Thumbnail */}
|
||||||
|
<div className="relative flex-shrink-0 w-16 h-22 rounded overflow-hidden">
|
||||||
{getCardImageUri(card, currentFaceIndex) ? (
|
{getCardImageUri(card, currentFaceIndex) ? (
|
||||||
<img
|
<img
|
||||||
src={getCardImageUri(card, currentFaceIndex)}
|
src={getCardImageUri(card, currentFaceIndex)}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
className="w-full h-auto"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MagicCard card={card} />
|
<div className="w-full h-full bg-gray-700" />
|
||||||
)}
|
)}
|
||||||
{isMultiFaced && (
|
{isMultiFaced && (
|
||||||
<button
|
<button
|
||||||
@@ -550,38 +569,69 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleCardFace(card.id, card.card_faces!.length);
|
toggleCardFace(card.id, card.card_faces!.length);
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
|
className="absolute bottom-0 right-0 bg-purple-600 text-white p-1 rounded-tl"
|
||||||
title="Flip card"
|
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
{/* Card Info */}
|
||||||
<h3 className="font-bold">{displayName}</h3>
|
<div className="flex-1 min-w-0">
|
||||||
{inCollection > 0 && (
|
<h3 className="font-medium text-sm truncate">{displayName}</h3>
|
||||||
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<CheckCircle size={12} />
|
{card.mana_cost && (
|
||||||
x{inCollection}
|
<ManaCost cost={card.mana_cost} size={14} />
|
||||||
</span>
|
)}
|
||||||
|
{card.prices?.usd && (
|
||||||
|
<div className="text-xs text-gray-400">${card.prices.usd}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{card.prices?.usd && (
|
{inCollection > 0 && (
|
||||||
<div className="text-sm text-gray-400 mb-2">${card.prices.usd}</div>
|
<div className="text-xs text-green-400 mt-1">
|
||||||
|
<CheckCircle size={12} className="inline mr-1" />
|
||||||
|
x{inCollection} in collection
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Quantity Controls */}
|
||||||
|
{quantityInDeck > 0 ? (
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (quantityInDeck === 1) {
|
||||||
|
removeCardFromDeck(card.id);
|
||||||
|
} else {
|
||||||
|
updateCardQuantity(card.id, quantityInDeck - 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-8 h-8 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-sm font-medium">{quantityInDeck}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => addCardToDeck(card)}
|
onClick={() => addCardToDeck(card)}
|
||||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => addCardToDeck(card)}
|
||||||
|
className="flex-shrink-0 w-10 h-10 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
Add to Deck
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add to Collection Button (hidden on mobile by default) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddCardToCollection(card.id, 1)}
|
onClick={() => handleAddCardToCollection(card.id, 1)}
|
||||||
disabled={isAddingThisCard}
|
disabled={isAddingThisCard}
|
||||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
className="hidden sm:flex flex-shrink-0 w-10 h-10 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-full items-center justify-center transition-colors"
|
||||||
title="Add to collection"
|
title="Add to collection"
|
||||||
>
|
>
|
||||||
{isAddingThisCard ? (
|
{isAddingThisCard ? (
|
||||||
@@ -591,8 +641,6 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -682,91 +730,25 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
<h3 className="font-bold text-xl">
|
<h3 className="font-bold text-xl">
|
||||||
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
||||||
</h3>
|
</h3>
|
||||||
{!isLoadingCollection && getMissingCards().length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-yellow-500">
|
|
||||||
<AlertCircle size={16} />
|
|
||||||
<span>{getMissingCards().length} missing</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoadingCollection && getMissingCards().length > 0 && (
|
{selectedCards.map(({ card, quantity }) => (
|
||||||
<button
|
|
||||||
onClick={handleAddAllMissingCards}
|
|
||||||
disabled={isAddingAll}
|
|
||||||
className="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 mb-3 relative"
|
|
||||||
>
|
|
||||||
{isAddingAll ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin" size={20} />
|
|
||||||
<span>Adding to collection...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PackagePlus size={20} />
|
|
||||||
<span>Add All Missing Cards to Collection</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedCards.map(({ card, quantity }) => {
|
|
||||||
const ownedQuantity = userCollection.get(card.id) || 0;
|
|
||||||
const isMissing = !isCardInCollection(card.id, quantity);
|
|
||||||
const neededQuantity = Math.max(0, quantity - ownedQuantity);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className={`flex items-center gap-4 p-2 rounded-lg ${
|
className="flex items-center gap-3 p-2 rounded-lg bg-gray-700"
|
||||||
isMissing
|
|
||||||
? 'bg-yellow-900/20 border border-yellow-700/50'
|
|
||||||
: 'bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={card.image_uris?.art_crop}
|
src={card.image_uris?.art_crop}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className="w-12 h-12 rounded"
|
className="w-10 h-10 rounded"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-medium flex items-center gap-2">
|
<h4 className="font-medium text-sm truncate">{card.name}</h4>
|
||||||
{card.name}
|
|
||||||
{isMissing && (
|
|
||||||
<span className="text-xs bg-yellow-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
<AlertCircle size={12} />
|
|
||||||
Missing {neededQuantity}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isMissing && ownedQuantity > 0 && (
|
|
||||||
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
<CheckCircle size={12} />
|
|
||||||
Owned ({ownedQuantity})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
{card.prices?.usd && (
|
{card.prices?.usd && (
|
||||||
<div className="text-sm text-gray-400">${card.prices.usd}</div>
|
<div className="text-xs text-gray-400">${card.prices.usd}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isMissing && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleAddCardToCollection(card.id, neededQuantity)}
|
|
||||||
disabled={addingCardId === card.id}
|
|
||||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded text-sm flex items-center gap-1"
|
|
||||||
title={`Add ${neededQuantity} to collection`}
|
|
||||||
>
|
|
||||||
{addingCardId === card.id ? (
|
|
||||||
<Loader2 className="animate-spin" size={16} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span className="hidden sm:inline">Add</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
@@ -774,18 +756,17 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
updateCardQuantity(card.id, parseInt(e.target.value))
|
updateCardQuantity(card.id, parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
min="1"
|
min="1"
|
||||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
className="w-14 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeCardFromDeck(card.id)}
|
onClick={() => removeCardFromDeck(card.id)}
|
||||||
className="text-red-500 hover:text-red-400"
|
className="text-red-500 hover:text-red-400"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="font-bold text-xl">
|
<div className="font-bold text-xl">
|
||||||
@@ -813,12 +794,30 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isLoadingCollection && getMissingCards().length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleAddAllMissingCards}
|
||||||
|
disabled={isAddingAll}
|
||||||
|
className="flex-1 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
||||||
|
title="Add missing cards to collection"
|
||||||
|
>
|
||||||
|
{isAddingAll ? (
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PackagePlus size={20} />
|
||||||
|
<span className="hidden sm:inline">Add Missing</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={saveDeck}
|
onClick={saveDeck}
|
||||||
disabled={
|
disabled={
|
||||||
!deckName.trim() || selectedCards.length === 0 || isSaving
|
!deckName.trim() || selectedCards.length === 0 || isSaving
|
||||||
}
|
}
|
||||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 relative"
|
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 relative"
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
@@ -836,6 +835,7 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{snackbar && (
|
{snackbar && (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
className={`fixed bottom-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
||||||
|
|||||||
71
src/components/ManaCost.tsx
Normal file
71
src/components/ManaCost.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Map mana symbols to their icon paths
|
||||||
|
const MANA_ICONS: Record<string, string> = {
|
||||||
|
W: '/mana-color/plains.png',
|
||||||
|
U: '/mana-color/island.png',
|
||||||
|
B: '/mana-color/swamp.png',
|
||||||
|
R: '/mana-color/moutain.png', // Note: filename has typo "moutain"
|
||||||
|
G: '/mana-color/forest.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ManaSymbolProps {
|
||||||
|
symbol: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a single mana symbol (either as an icon or as text for numbers/other)
|
||||||
|
export function ManaSymbol({ symbol, size = 16 }: ManaSymbolProps) {
|
||||||
|
const iconPath = MANA_ICONS[symbol];
|
||||||
|
|
||||||
|
if (iconPath) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={iconPath}
|
||||||
|
alt={symbol}
|
||||||
|
className="inline-block"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For numbers and other symbols, show as a circle with the symbol
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center bg-gray-500 text-white font-bold rounded-full"
|
||||||
|
style={{ width: size, height: size, fontSize: size * 0.6 }}
|
||||||
|
>
|
||||||
|
{symbol}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManaCostProps {
|
||||||
|
cost: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses and renders a full mana cost string like "{2}{W}{U}"
|
||||||
|
export function ManaCost({ cost, size = 16 }: ManaCostProps) {
|
||||||
|
if (!cost) return null;
|
||||||
|
|
||||||
|
// Parse mana cost string: {2}{W}{U} -> ['2', 'W', 'U']
|
||||||
|
const symbols = cost.match(/\{([^}]+)\}/g)?.map(s => s.slice(1, -1)) || [];
|
||||||
|
|
||||||
|
if (symbols.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
{symbols.map((symbol, index) => (
|
||||||
|
<ManaSymbol key={index} symbol={symbol} size={size} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get icon path for a color (for use in filters, etc.)
|
||||||
|
export function getManaIconPath(color: string): string | null {
|
||||||
|
return MANA_ICONS[color] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ManaCost;
|
||||||
@@ -51,8 +51,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'home' as const, label: 'Home', icon: Home },
|
{ id: 'home' as const, label: 'Decks', icon: Library },
|
||||||
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
|
||||||
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
||||||
{ id: 'search' as const, label: 'Search', icon: Search },
|
{ id: 'search' as const, label: 'Search', icon: Search },
|
||||||
{ id: 'life-counter' as const, label: 'Life Counter', icon: Heart },
|
{ id: 'life-counter' as const, label: 'Life Counter', icon: Heart },
|
||||||
@@ -140,60 +139,64 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Navigation - Bottom */}
|
{/* Mobile Navigation - Bottom */}
|
||||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50 animate-slide-in-right">
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50 safe-area-bottom">
|
||||||
<div className="flex justify-between items-center h-16 px-4">
|
<div className="flex justify-around items-center h-16 px-2">
|
||||||
<span className="text-2xl font-bold text-orange-500 animate-bounce-in">Deckerr</span>
|
|
||||||
<div className="relative" ref={mobileMenuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
|
||||||
className="text-gray-300 hover:text-white"
|
|
||||||
>
|
|
||||||
<Menu size={24} />
|
|
||||||
</button>
|
|
||||||
{showMobileMenu && (
|
|
||||||
<div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700 animate-scale-in glass-effect">
|
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => setCurrentPage(item.id)}
|
||||||
setCurrentPage(item.id);
|
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
||||||
setShowMobileMenu(false);
|
currentPage === item.id
|
||||||
}}
|
? 'text-blue-500'
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={16} />
|
<item.icon size={20} />
|
||||||
<span>{item.label}</span>
|
<span className="text-xs mt-1">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{user && (
|
|
||||||
<>
|
{/* Settings with dropdown */}
|
||||||
|
<div className="relative flex-1 h-full" ref={mobileMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||||
|
className={`flex flex-col items-center justify-center w-full h-full transition-colors ${
|
||||||
|
currentPage === 'profile' || showMobileMenu
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span className="text-xs mt-1">Settings</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMobileMenu && (
|
||||||
|
<div className="absolute right-0 bottom-full mb-2 w-48 bg-gray-800 rounded-lg shadow-xl py-1 border border-gray-700 animate-scale-in">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage('profile');
|
setCurrentPage('profile');
|
||||||
setShowMobileMenu(false);
|
setShowMobileMenu(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
className="flex items-center space-x-3 w-full px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={18} />
|
||||||
<span>Profile Settings</span>
|
<span>Profile</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={() => {
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
handleSignOut();
|
||||||
|
setShowMobileMenu(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-3 w-full px-4 py-3 text-sm text-red-400 hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={18} />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content Padding */}
|
|
||||||
<div className="md:pt-16 pb-16 md:pb-0" />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/components/PWAInstallPrompt.tsx
Normal file
128
src/components/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Download, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PWAInstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if already installed
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||||
|
if (isStandalone || (window.navigator as any).standalone) {
|
||||||
|
setIsInstalled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user dismissed the prompt before
|
||||||
|
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||||
|
const dismissedTime = dismissed ? parseInt(dismissed) : 0;
|
||||||
|
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
// Show prompt again after 7 days
|
||||||
|
if (daysSinceDismissed > 7) {
|
||||||
|
localStorage.removeItem('pwa-install-dismissed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const promptEvent = e as BeforeInstallPromptEvent;
|
||||||
|
setDeferredPrompt(promptEvent);
|
||||||
|
|
||||||
|
// Show prompt if not dismissed recently
|
||||||
|
if (!dismissed || daysSinceDismissed > 7) {
|
||||||
|
setShowPrompt(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handler);
|
||||||
|
|
||||||
|
// Detect if app was installed
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
setIsInstalled(true);
|
||||||
|
setShowPrompt(false);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handler);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstallClick = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
const choiceResult = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
if (choiceResult.outcome === 'accepted') {
|
||||||
|
console.log('User accepted the install prompt');
|
||||||
|
setShowPrompt(false);
|
||||||
|
} else {
|
||||||
|
console.log('User dismissed the install prompt');
|
||||||
|
handleDismiss();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during installation:', error);
|
||||||
|
} finally {
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInstalled || !showPrompt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 md:bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm z-50 animate-slide-in-bottom">
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg shadow-2xl p-4 text-white">
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="absolute top-2 right-2 text-white/80 hover:text-white transition-colors"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 pr-6">
|
||||||
|
<div className="bg-white/20 rounded-lg p-2 flex-shrink-0">
|
||||||
|
<Download size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-lg mb-1">Install Deckerr</h3>
|
||||||
|
<p className="text-sm text-white/90 mb-3">
|
||||||
|
Install our app for quick access and offline support!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleInstallClick}
|
||||||
|
className="flex-1 bg-white text-blue-600 font-semibold py-2 px-4 rounded-lg hover:bg-blue-50 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-1 bg-white/20 font-semibold py-2 px-4 rounded-lg hover:bg-white/30 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
|
Not Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,13 +88,13 @@ export default function Profile() {
|
|||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Theme Color
|
Theme Color
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||||
{THEME_COLORS.map((color) => (
|
{THEME_COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setThemeColor(color)}
|
onClick={() => setThemeColor(color)}
|
||||||
className={`h-12 rounded-lg border-2 transition-all capitalize
|
className={`h-12 sm:h-14 rounded-lg border-2 transition-all capitalize text-sm sm:text-base
|
||||||
${themeColor === color
|
${themeColor === color
|
||||||
? 'border-white scale-105'
|
? 'border-white scale-105'
|
||||||
: 'border-transparent hover:border-gray-600'
|
: 'border-transparent hover:border-gray-600'
|
||||||
@@ -110,7 +110,7 @@ export default function Profile() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
className="w-full min-h-[44px] flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-3 px-4 rounded-lg transition duration-200"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
|
|||||||
@@ -1,9 +1,98 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['icon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Deckerr - Card Deck Manager',
|
||||||
|
short_name: 'Deckerr',
|
||||||
|
description: 'Manage your trading card game decks on the go',
|
||||||
|
theme_color: '#0f172a',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'icon.svg',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'icon.svg',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'maskable'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
categories: ['games', 'utilities'],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: 'My Decks',
|
||||||
|
short_name: 'Decks',
|
||||||
|
description: 'View your deck collection',
|
||||||
|
url: '/?page=home'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search Cards',
|
||||||
|
short_name: 'Search',
|
||||||
|
description: 'Search for cards',
|
||||||
|
url: '/?page=search'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Life Counter',
|
||||||
|
short_name: 'Life',
|
||||||
|
description: 'Track life totals',
|
||||||
|
url: '/?page=life-counter'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/api\.scryfall\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'scryfall-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 500,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/cards\.scryfall\.io\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'card-images-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1000,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user