Add PWA support and update app metadata for Deckerr
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
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.tpsi5p961us"
|
||||
}], {});
|
||||
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">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- 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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4485
package-lock.json
generated
4485
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,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -29,6 +29,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"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 |
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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
||||
import Profile from './components/Profile';
|
||||
import CardSearch from './components/CardSearch';
|
||||
import LifeCounter from './components/LifeCounter';
|
||||
import PWAInstallPrompt from './components/PWAInstallPrompt';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
|
||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
|
||||
@@ -38,9 +39,9 @@ import React, { useState } from 'react';
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6 animate-fade-in">
|
||||
<div className="min-h-screen bg-gray-900 text-white p-3 sm:p-6 md:pt-16 pb-16 md:pb-0 animate-fade-in">
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +77,7 @@ import React, { useState } from 'react';
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||
{renderPage()}
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,9 +192,9 @@ const CardSearch = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="min-h-screen bg-gray-900 text-white p-3 sm:p-6 md:pt-16 pb-16 md:pb-0">
|
||||
<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">
|
||||
{/* Card Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -295,17 +295,17 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* 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]) => (
|
||||
<div key={color} className="flex items-center space-x-2">
|
||||
<span style={{ fontSize: '1.5em' }}>
|
||||
<span style={{ fontSize: '1.2em' }} className="md:text-[1.5em]">
|
||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={count}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -586,7 +586,7 @@ const CardSearch = () => {
|
||||
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -605,7 +605,7 @@ const CardSearch = () => {
|
||||
)}
|
||||
|
||||
{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) => {
|
||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||
const isMultiFaced = isDoubleFaced(card);
|
||||
|
||||
@@ -189,9 +189,9 @@ export default function Collection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="min-h-screen bg-gray-900 text-white p-3 sm:p-6 md:pt-16 pb-16 md:pb-0">
|
||||
<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 */}
|
||||
<div className="mb-8">
|
||||
@@ -228,7 +228,7 @@ export default function Collection() {
|
||||
<p className="text-sm">Try a different search term</p>
|
||||
</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 }) => {
|
||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||
const isMultiFaced = isDoubleFaced(card);
|
||||
@@ -252,7 +252,7 @@ export default function Collection() {
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* 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}
|
||||
</div>
|
||||
{/* Flip button for double-faced cards */}
|
||||
@@ -295,7 +295,7 @@ export default function Collection() {
|
||||
const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text;
|
||||
|
||||
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="relative">
|
||||
<img
|
||||
@@ -350,15 +350,17 @@ export default function Collection() {
|
||||
|
||||
{/* 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="p-6">
|
||||
{/* Close button */}
|
||||
{/* Close button - fixed position, stays visible when scrolling */}
|
||||
<button
|
||||
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>
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
|
||||
{/* Card Image */}
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
@@ -385,8 +387,8 @@ export default function Collection() {
|
||||
{/* Card Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-400">{displayTypeLine}</p>
|
||||
</div>
|
||||
|
||||
{displayOracleText && (
|
||||
@@ -442,7 +444,7 @@ export default function Collection() {
|
||||
});
|
||||
}}
|
||||
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} />
|
||||
Remove from Collection
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg card-hover cursor-pointer animate-scale-in"
|
||||
onClick={() => onEdit?.(deck.id)}
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<div className="relative h-32 sm:h-40 md:h-48 overflow-hidden">
|
||||
<img
|
||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||
alt={commander?.name || deck.cards[0]?.card.name}
|
||||
@@ -66,7 +66,7 @@ export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||
e.stopPropagation();
|
||||
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="mt-4 w-full min-h-[44px] px-4 py-3 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"
|
||||
>
|
||||
<Edit size={20} />
|
||||
Edit Deck
|
||||
|
||||
@@ -490,9 +490,9 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="min-h-screen bg-gray-900 text-white p-3 sm:p-6 md:pt-16 pb-16 md:pb-0">
|
||||
<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 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
@@ -511,10 +511,10 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||
className="min-h-[44px] px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Search size={20} />
|
||||
Search
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
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">
|
||||
Theme Color
|
||||
</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) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
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
|
||||
? 'border-white scale-105'
|
||||
: 'border-transparent hover:border-gray-600'
|
||||
@@ -110,7 +110,7 @@ export default function Profile() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<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 react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
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: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user