diff --git a/.modified b/.modified new file mode 100644 index 0000000..e69de29 diff --git a/docs/blueprint.md b/docs/blueprint.md new file mode 100644 index 0000000..86feb2e --- /dev/null +++ b/docs/blueprint.md @@ -0,0 +1,19 @@ +# **App Name**: Cardex + +## Core Features: + +- User Authentication: Secure user authentication using email and Google login. +- Card Scanning: Utilize Gemini Vision to scan and identify Pokémon cards based on images. It will use tools to try to provide the most accurate name, set, and rarity, even with partial information. The user will have the option to correct the identification. +- Card Display: Display identified card information including name, set, rarity, and an image from the Gemini Vision. Allow the user to accept, edit or reject. +- Card Storage: Store identified and user-confirmed card data, with user id. +- Data Management: Allow the user to create, update and delete his data stored into Firestore. + +## Style Guidelines: + +- Primary color: #3498db (RGB) - A clean, professional blue to evoke trust and security. +- Background color: #ecf0f1 (RGB) - A light, neutral gray to provide a clean backdrop. +- Accent color: #e74c3c (RGB) - A vibrant red for errors or highlighting important actions (like deleting a card). +- Body and headline font: 'Inter' - a grotesque-style sans-serif with a modern, machined, objective, neutral look; suitable for both headlines and body text +- Use a consistent set of outline-style icons throughout the application. +- Employ a grid-based layout to ensure consistency and readability across different screen sizes. +- Subtle animations during card scanning and data saving to provide feedback to the user. \ No newline at end of file diff --git a/src/ai/dev.ts b/src/ai/dev.ts index 51e556a..4e309ce 100644 --- a/src/ai/dev.ts +++ b/src/ai/dev.ts @@ -1 +1,5 @@ -// Flows will be imported for their side effects in this file. +import { config } from 'dotenv'; +config(); + +import '@/ai/flows/summarize-card-information.ts'; +import '@/ai/flows/scan-pokemon-card.ts'; \ No newline at end of file diff --git a/src/ai/flows/scan-pokemon-card.ts b/src/ai/flows/scan-pokemon-card.ts new file mode 100644 index 0000000..06f27f9 --- /dev/null +++ b/src/ai/flows/scan-pokemon-card.ts @@ -0,0 +1,75 @@ +// This file is automatically generated - edits will be lost! +'use server'; + +/** + * @fileOverview Uses Gemini Vision to scan and identify a Pokemon card's name, set, and rarity. + * + * - scanPokemonCard - A function that handles the card scanning process. + * - ScanPokemonCardInput - The input type for the scanPokemonCard function. + * - ScanPokemonCardOutput - The return type for the scanPokemonCard function. + */ + +import {ai} from '@/ai/genkit'; +import {z} from 'genkit'; + +const ScanPokemonCardInputSchema = z.object({ + photoDataUri: z + .string() + .describe( + "A photo of a Pokemon card, as a data URI that must include a MIME type and use Base64 encoding. Expected format: 'data:;base64,'." + ), +}); +export type ScanPokemonCardInput = z.infer; + +const ScanPokemonCardOutputSchema = z.object({ + cardDetails: z.object({ + name: z.string().describe("The name of the Pokemon card.").optional(), + set: z.string().describe("The set the Pokemon card belongs to.").optional(), + rarity: z.string().describe("The rarity of the Pokemon card.").optional(), + }).describe("Details about the Pokemon card.").optional(), + error: z.string().describe("Error message if the card could not be identified.").optional() +}); + +export type ScanPokemonCardOutput = z.infer; + +export async function scanPokemonCard(input: ScanPokemonCardInput): Promise { + return scanPokemonCardFlow(input); +} + +const scanPokemonCardPrompt = ai.definePrompt({ + name: 'scanPokemonCardPrompt', + input: {schema: ScanPokemonCardInputSchema}, + output: {schema: ScanPokemonCardOutputSchema}, + prompt: `You are an expert Pokemon card appraiser. Use the following image to identify the card's name, set, and rarity. If you are unable to determine any of these values, leave them blank.\n + Photo: {{media url=photoDataUri}} + \n + Respond using the following format: + { + "cardDetails": { + "name": "Pokemon card name", + "set": "Pokemon card set", + "rarity": "Pokemon card rarity" + } + } + \nIf you cannot identify the Pokemon card or if the image does not contain a Pokemon card, respond with the following format: + { + "error": "Error message describing why the card could not be identified" + }`, +}); + +const scanPokemonCardFlow = ai.defineFlow( + { + name: 'scanPokemonCardFlow', + inputSchema: ScanPokemonCardInputSchema, + outputSchema: ScanPokemonCardOutputSchema, + }, + async input => { + try { + const {output} = await scanPokemonCardPrompt(input); + return output!; + } catch (error: any) { + console.error('Error during card scanning:', error); + return {error: 'Failed to scan Pokemon card.'}; + } + } +); diff --git a/src/ai/flows/summarize-card-information.ts b/src/ai/flows/summarize-card-information.ts new file mode 100644 index 0000000..d619e9d --- /dev/null +++ b/src/ai/flows/summarize-card-information.ts @@ -0,0 +1,50 @@ +// Summarize Card Information Flow +'use server'; + +/** + * @fileOverview Summarizes the key information of a Pokemon card. + * + * - summarizeCardInformation - A function that summarizes the card information. + * - SummarizeCardInformationInput - The input type for the summarizeCardInformation function. + * - SummarizeCardInformationOutput - The return type for the summarizeCardInformation function. + */ + +import {ai} from '@/ai/genkit'; +import {z} from 'genkit'; + +const SummarizeCardInformationInputSchema = z.object({ + name: z.string().describe('The name of the Pokemon card.'), + set: z.string().describe('The set the card belongs to.'), + rarity: z.string().describe('The rarity of the card.'), +}); +export type SummarizeCardInformationInput = z.infer; + +const SummarizeCardInformationOutputSchema = z.object({ + summary: z.string().describe('A summary of the card information.'), +}); +export type SummarizeCardInformationOutput = z.infer; + +export async function summarizeCardInformation( + input: SummarizeCardInformationInput +): Promise { + return summarizeCardInformationFlow(input); +} + +const summarizeCardInformationPrompt = ai.definePrompt({ + name: 'summarizeCardInformationPrompt', + input: {schema: SummarizeCardInformationInputSchema}, + output: {schema: SummarizeCardInformationOutputSchema}, + prompt: `Summarize the key information of the following Pokemon card, including its name, set, and rarity:\n\nName: {{{name}}}\nSet: {{{set}}}\nRarity: {{{rarity}}}`, +}); + +const summarizeCardInformationFlow = ai.defineFlow( + { + name: 'summarizeCardInformationFlow', + inputSchema: SummarizeCardInformationInputSchema, + outputSchema: SummarizeCardInformationOutputSchema, + }, + async input => { + const {output} = await summarizeCardInformationPrompt(input); + return output!; + } +); diff --git a/src/app/dashboard/collection/[cardId]/edit/page.tsx b/src/app/dashboard/collection/[cardId]/edit/page.tsx new file mode 100644 index 0000000..b0d63e8 --- /dev/null +++ b/src/app/dashboard/collection/[cardId]/edit/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; +import { getCardById, updateCardInCollection } from '@/lib/firestore'; +import type { PokemonCard } from '@/types'; +import CardForm, { type CardFormInputs } from '@/components/cards/CardForm'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, AlertTriangle } from 'lucide-react'; + +export default function EditCardPage() { + const { user } = useAuth(); + const router = useRouter(); + const params = useParams(); + const cardId = params.cardId as string; + + const [card, setCard] = useState(null); + const [loading, setLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + if (user && cardId) { + setLoading(true); + setError(null); + getCardById(user.uid, cardId) + .then(fetchedCard => { + if (fetchedCard) { + setCard(fetchedCard); + } else { + setError('Card not found or you do not have permission to edit it.'); + toast({ title: "Error", description: "Card not found.", variant: "destructive" }); + } + }) + .catch(err => { + console.error("Error fetching card for edit:", err); + setError((err as Error).message || 'Failed to load card details.'); + toast({ title: "Error", description: "Failed to load card details.", variant: "destructive" }); + }) + .finally(() => setLoading(false)); + } else if (!user && !loading) { // if auth is loaded and no user + router.replace('/login'); + } + }, [user, cardId, router, toast, loading]); + + const handleUpdateCard = async (data: CardFormInputs) => { + if (!user || !card) return; + + setIsSubmitting(true); + try { + const updatedData: Partial = { + name: data.name, + set: data.set, + rarity: data.rarity, + // imageDataUrl is part of CardFormInputs but might not change if not re-uploaded + // For simplicity, we assume imageDataUrl is handled if it's part of the form + // and if image upload was part of the edit form (which it isn't currently for simplicity) + // For this schema, imageDataUrl is part of CardFormInputs, so it will be included. + // If image editing is not part of this form, ensure data.imageDataUrl is the original one. + imageDataUrl: data.imageDataUrl, + }; + await updateCardInCollection(user.uid, card.id, updatedData); + toast({ title: 'Card Updated', description: `${data.name} has been updated.` }); + router.push('/dashboard/collection'); + } catch (e) { + console.error('Error updating card:', e); + const errorMessage = (e instanceof Error) ? e.message : 'An unknown error occurred while updating.'; + toast({ title: 'Update Error', description: errorMessage, variant: 'destructive' }); + } finally { + setIsSubmitting(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Error Loading Card

+

{error}

+
+ ); + } + + if (!card) { + // This case should ideally be covered by error state, but as a fallback: + return
Card not found.
; + } + + return ( +
+

Edit Card: {card.name}

+ router.push('/dashboard/collection')} + /> +
+ ); +} diff --git a/src/app/dashboard/collection/page.tsx b/src/app/dashboard/collection/page.tsx new file mode 100644 index 0000000..ad96cf9 --- /dev/null +++ b/src/app/dashboard/collection/page.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useAuth } from '@/hooks/useAuth'; +import { getUserCards, deleteCardFromCollection } from '@/lib/firestore'; +import type { PokemonCard } from '@/types'; +import CardItem from '@/components/cards/CardItem'; +import { Button } from '@/components/ui/button'; +import { Loader2, PlusCircle, AlertTriangle } from 'lucide-react'; +import Link from 'next/link'; +import { useToast } from '@/hooks/use-toast'; + +export default function CollectionPage() { + const { user } = useAuth(); + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deletingCardId, setDeletingCardId] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + if (user) { + fetchCards(); + } else { + setLoading(false); + } + }, [user]); + + const fetchCards = async () => { + if (!user) return; + setLoading(true); + setError(null); + try { + const userCards = await getUserCards(user.uid); + setCards(userCards); + } catch (e) { + console.error("Error fetching cards:", e); + setError((e as Error).message || 'Failed to load your collection. Please try again.'); + toast({ title: "Error", description: "Failed to load collection.", variant: "destructive" }); + } finally { + setLoading(false); + } + }; + + const handleDeleteCard = async (cardId: string) => { + if (!user) return; + setDeletingCardId(cardId); + try { + await deleteCardFromCollection(user.uid, cardId); + setCards(prevCards => prevCards.filter(card => card.id !== cardId)); + toast({ title: "Card Deleted", description: "The card has been removed from your collection." }); + } catch (e) { + console.error("Error deleting card:", e); + toast({ title: "Error", description: "Failed to delete card.", variant: "destructive" }); + } finally { + setDeletingCardId(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Error Loading Collection

+

{error}

+ +
+ ); + } + + return ( +
+
+

My Card Collection

+ +
+ + {cards.length === 0 ? ( +
+ +

Your collection is empty.

+

Start by scanning your first Pokémon card!

+ +
+ ) : ( +
+ {cards.map(card => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..fe4993a --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,6 @@ +import AuthGuard from '@/components/auth/AuthGuard'; +import type { ReactNode } from 'react'; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..5675d02 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,21 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function DashboardPage() { + const router = useRouter(); + useEffect(() => { + // For now, redirect to scan page as the primary action + router.replace('/dashboard/scan'); + }, [router]); + + return ( +
+

Welcome to your Cardex Dashboard

+

+ Manage your Pokémon card collection with ease. +

+

Redirecting to card scanner...

+
+ ); +} diff --git a/src/app/dashboard/scan/page.tsx b/src/app/dashboard/scan/page.tsx new file mode 100644 index 0000000..ceacf98 --- /dev/null +++ b/src/app/dashboard/scan/page.tsx @@ -0,0 +1,9 @@ +import CardScanner from '@/components/cards/CardScanner'; + +export default function ScanCardPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..352db3d --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { sendPasswordResetEmail } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import Link from 'next/link'; +import { Loader2, Mail } from 'lucide-react'; + +const forgotPasswordSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), +}); + +type ForgotPasswordInputs = z.infer; + +export default function ForgotPasswordPage() { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(forgotPasswordSchema), + }); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + try { + await sendPasswordResetEmail(auth, data.email); + toast({ + title: 'Password reset email sent', + description: 'Please check your inbox for further instructions.', + }); + } catch (error) { + console.error('Password reset error:', error); + toast({ + title: 'Error sending reset email', + description: (error as Error).message, + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Forgot Your Password? +

+

+ No worries! Enter your email and we'll send you a reset link. +

+
+ +
+ + + Reset Password + Enter your email address below. + + +
+
+ +
+ + +
+ {errors.email &&

{errors.email.message}

} +
+ + +
+
+ +

+ Remembered your password?{' '} + + + Sign in + + +

+
+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a8144b6..f35aa59 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,79 +2,75 @@ @tailwind components; @tailwind utilities; -body { - font-family: Arial, Helvetica, sans-serif; -} - @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; + --background: 200 17% 94%; /* #ecf0f1 */ + --foreground: 210 20% 20%; /* Custom dark gray for text */ --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card-foreground: 210 20% 20%; --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; + --popover-foreground: 210 20% 20%; + --primary: 207 70% 53%; /* #3498db */ + --primary-foreground: 0 0% 100%; /* White for contrast on primary */ + --secondary: 200 17% 85%; /* Slightly darker than background for secondary elements */ + --secondary-foreground: 210 20% 20%; + --muted: 200 17% 90%; + --muted-foreground: 210 20% 40%; + --accent: 207 70% 63%; /* Lighter shade of primary for accents */ + --accent-foreground: 0 0% 100%; + --destructive: 6 78% 57%; /* #e74c3c */ + --destructive-foreground: 0 0% 100%; + --border: 200 17% 80%; + --input: 200 17% 88%; + --ring: 207 70% 53%; /* Primary color for rings */ --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; + + /* Sidebar specific colors, can be adjusted if sidebar is used extensively */ + --sidebar-background: 200 17% 92%; + --sidebar-foreground: 210 20% 20%; + --sidebar-primary: 207 70% 53%; --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --sidebar-accent: 207 70% 63%; + --sidebar-accent-foreground: 0 0% 100%; + --sidebar-border: 200 17% 80%; + --sidebar-ring: 207 70% 53%; + } + + .dark { + /* Define dark theme colors if needed, for now, focusing on light theme */ + --background: 210 20% 10%; + --foreground: 200 17% 94%; + --card: 210 20% 12%; + --card-foreground: 200 17% 94%; + --popover: 210 20% 12%; + --popover-foreground: 200 17% 94%; + --primary: 207 70% 53%; + --primary-foreground: 0 0% 100%; + --secondary: 210 20% 20%; + --secondary-foreground: 200 17% 94%; + --muted: 210 20% 20%; + --muted-foreground: 200 17% 70%; + --accent: 207 70% 63%; + --accent-foreground: 0 0% 100%; + --destructive: 6 78% 57%; + --destructive-foreground: 0 0% 100%; + --border: 210 20% 25%; + --input: 210 20% 25%; + --ring: 207 70% 53%; + + --sidebar-background: 210 20% 12%; + --sidebar-foreground: 200 17% 94%; + --sidebar-primary: 207 70% 53%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 207 70% 63%; + --sidebar-accent-foreground: 0 0% 100%; + --sidebar-border: 210 20% 25%; + --sidebar-ring: 207 70% 53%; } } @@ -83,6 +79,6 @@ body { @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-body; } -} +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c81ce2d..fe56585 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,17 @@ -import type {Metadata} from 'next'; +import type { Metadata } from 'next'; import './globals.css'; +import { Inter } from 'next/font/google'; +import { FirebaseProvider } from '@/components/providers/FirebaseProvider'; +import { AuthProvider } from '@/components/providers/AuthProvider'; +import { Toaster } from '@/components/ui/toaster'; +import Navbar from '@/components/layout/Navbar'; + +// If you have specific font weights and styles, configure them here +const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export const metadata: Metadata = { - title: 'Firebase Studio App', - description: 'Generated by Firebase Studio', + title: 'Cardex - Pokémon Card Scanner', + description: 'Scan, identify, and manage your Pokémon card collection with Cardex.', }; export default function RootLayout({ @@ -12,13 +20,24 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - - + {/* Google Fonts link is kept as per instructions, next/font is also used for Inter for better optimization */} + + + - {children} + + + + +
+ {children} +
+ +
+
+ ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..9d76dc8 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; +import LoginForm from '@/components/auth/LoginForm'; +import GoogleSignInButton from '@/components/auth/GoogleSignInButton'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import Link from 'next/link'; +import { Loader2 } from 'lucide-react'; + +export default function LoginPage() { + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!authLoading && user) { + router.replace('/dashboard'); + } + }, [user, authLoading, router]); + + if (authLoading || (!authLoading && user)) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Sign in to Cardex +

+

+ Access your Pokémon card collection. +

+
+ +
+ + + Welcome back + Enter your credentials to continue. + + + +
+
+
+ +
+
+ + Or continue with + +
+
+
+ +
+
+
+ +

+ Don't have an account?{' '} + + + Sign up + + +

+
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6ff5373..45e7d13 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,27 @@ -export default function Home() { - return <>; +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; +import { Loader2 } from 'lucide-react'; + +export default function HomePage() { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading) { + if (user) { + router.replace('/dashboard'); + } else { + router.replace('/login'); + } + } + }, [user, loading, router]); + + return ( +
+ +
+ ); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..13948aa --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; +import SignupForm from '@/components/auth/SignupForm'; +import GoogleSignInButton from '@/components/auth/GoogleSignInButton'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import Link from 'next/link'; +import { Loader2 } from 'lucide-react'; + +export default function SignupPage() { + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!authLoading && user) { + router.replace('/dashboard'); + } + }, [user, authLoading, router]); + + if (authLoading || (!authLoading && user)) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Create your Cardex account +

+

+ Start managing your Pokémon card collection today. +

+
+ +
+ + + Get Started + It's quick and easy. + + + +
+
+
+ +
+
+ + Or sign up with + +
+
+
+ +
+
+
+ +

+ Already have an account?{' '} + + + Sign in + + +

+
+
+
+
+ ); +} diff --git a/src/components/auth/AuthGuard.tsx b/src/components/auth/AuthGuard.tsx new file mode 100644 index 0000000..8a7bb10 --- /dev/null +++ b/src/components/auth/AuthGuard.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; +import { Loader2 } from 'lucide-react'; + +interface AuthGuardProps { + children: ReactNode; +} + +export default function AuthGuard({ children }: AuthGuardProps) { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + router.replace('/login'); + } + }, [user, loading, router]); + + if (loading || !user) { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/src/components/auth/GoogleSignInButton.tsx b/src/components/auth/GoogleSignInButton.tsx new file mode 100644 index 0000000..1456d59 --- /dev/null +++ b/src/components/auth/GoogleSignInButton.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState } from 'react'; +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2 } from 'lucide-react'; + +// Simple inline SVG for Google icon +const GoogleIcon = () => ( + + + + + + + +); + + +export default function GoogleSignInButton() { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + + const handleGoogleSignIn = async () => { + setLoading(true); + const provider = new GoogleAuthProvider(); + try { + await signInWithPopup(auth, provider); + toast({ title: 'Google Sign-In successful', description: 'Redirecting to dashboard...' }); + // Redirect handled by AuthProvider or HomePage + } catch (error) { + console.error('Google Sign-In error:', error); + toast({ + title: 'Google Sign-In failed', + description: (error as Error).message, + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..1a9e9ba --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { signInWithEmailAndPassword } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, Mail, Lock } from 'lucide-react'; +import Link from 'next/link'; + +const loginSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), + password: z.string().min(6, { message: 'Password must be at least 6 characters' }), +}); + +type LoginFormInputs = z.infer; + +export default function LoginForm() { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + try { + await signInWithEmailAndPassword(auth, data.email, data.password); + toast({ title: 'Login successful', description: 'Redirecting to dashboard...' }); + // Redirect handled by AuthProvider or HomePage + } catch (error) { + console.error('Login error:', error); + toast({ + title: 'Login failed', + description: (error as Error).message, // More specific error from Firebase + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ + +
+ {errors.email &&

{errors.email.message}

} +
+ +
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ + + + +
+ ); +} diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx new file mode 100644 index 0000000..d25276f --- /dev/null +++ b/src/components/auth/SignupForm.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState } from 'react'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, Mail, Lock, User } from 'lucide-react'; + +const signupSchema = z.object({ + displayName: z.string().min(2, { message: 'Display name must be at least 2 characters' }).max(50), + email: z.string().email({ message: 'Invalid email address' }), + password: z.string().min(6, { message: 'Password must be at least 6 characters' }), +}); + +type SignupFormInputs = z.infer; + +export default function SignupForm() { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + }); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + try { + const userCredential = await createUserWithEmailAndPassword(auth, data.email, data.password); + if (userCredential.user) { + await updateProfile(userCredential.user, { displayName: data.displayName }); + } + toast({ title: 'Signup successful', description: 'Redirecting to dashboard...' }); + // Redirect handled by AuthProvider or HomePage + } catch (error) { + console.error('Signup error:', error); + toast({ + title: 'Signup failed', + description: (error as Error).message, + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ + +
+ {errors.displayName &&

{errors.displayName.message}

} +
+ +
+ +
+ + +
+ {errors.email &&

{errors.email.message}

} +
+ +
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ + +
+ ); +} diff --git a/src/components/cards/CardForm.tsx b/src/components/cards/CardForm.tsx new file mode 100644 index 0000000..4eefebf --- /dev/null +++ b/src/components/cards/CardForm.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Loader2 } from 'lucide-react'; +import Image from 'next/image'; +import type { ScannedCardData, PokemonCard } from '@/types'; + +const cardSchema = z.object({ + name: z.string().min(1, 'Name is required'), + set: z.string().min(1, 'Set is required'), + rarity: z.string().min(1, 'Rarity is required'), + imageDataUrl: z.string().refine(val => val.startsWith('data:image/'), { + message: 'Image data URL is required and must be valid', + }), +}); + +export type CardFormInputs = z.infer; + +interface CardFormProps { + initialData?: Partial | (ScannedCardData & { imageDataUrl?: string }); + imageDataUrlFromScan?: string; // Specifically for new cards from scan + onSubmit: (data: CardFormInputs) => Promise; + isSubmitting: boolean; + submitButtonText?: string; + formTitle?: string; + formDescription?: string; + onCancel?: () => void; +} + +export default function CardForm({ + initialData, + imageDataUrlFromScan, + onSubmit: handleFormSubmit, + isSubmitting, + submitButtonText = 'Save Card', + formTitle = 'Card Details', + formDescription = 'Fill in the details of the Pokémon card.', + onCancel, +}: CardFormProps) { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(cardSchema), + defaultValues: { + name: initialData?.name || '', + set: initialData?.set || '', + rarity: initialData?.rarity || '', + imageDataUrl: imageDataUrlFromScan || (initialData as PokemonCard)?.imageDataUrl || '', + }, + }); + + const currentImageDataUrl = watch('imageDataUrl'); + + const onSubmit: SubmitHandler = async (data) => { + await handleFormSubmit(data); + }; + + return ( + + + {formTitle} + {formDescription} + +
+ + {currentImageDataUrl && ( +
+ {watch('name') +
+ )} + {/* Hidden input for imageDataUrl, as it's typically set by scanner or pre-loaded */} + + {errors.imageDataUrl &&

{errors.imageDataUrl.message}

} + +
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + + {errors.set &&

{errors.set.message}

} +
+ +
+ + + {errors.rarity &&

{errors.rarity.message}

} +
+
+ + {onCancel && ( + + )} + + +
+
+ ); +} diff --git a/src/components/cards/CardItem.tsx b/src/components/cards/CardItem.tsx new file mode 100644 index 0000000..ebe6325 --- /dev/null +++ b/src/components/cards/CardItem.tsx @@ -0,0 +1,82 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Pencil, Trash2 } from 'lucide-react'; +import type { PokemonCard } from '@/types'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +interface CardItemProps { + card: PokemonCard; + onDelete: (cardId: string) => void; + isDeleting: boolean; +} + +export default function CardItem({ card, onDelete, isDeleting }: CardItemProps) { + return ( + + + {card.name} + + Set: {card.set} | Rarity: {card.rarity} + + + + {card.imageDataUrl ? ( + {card.name} + ) : ( +
+ No Image +
+ )} +
+ + + + + + + + + Are you sure? + + This action cannot be undone. This will permanently delete the card "{card.name}" from your collection. + + + + Cancel + onDelete(card.id)} className="bg-destructive hover:bg-destructive/90"> + Delete + + + + + +
+ ); +} diff --git a/src/components/cards/CardScanner.tsx b/src/components/cards/CardScanner.tsx new file mode 100644 index 0000000..d351fe7 --- /dev/null +++ b/src/components/cards/CardScanner.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useState, type ChangeEvent, useRef } from 'react'; +import { scanPokemonCard, type ScanPokemonCardOutput } from '@/ai/flows/scan-pokemon-card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, Upload, AlertCircle, CheckCircle } from 'lucide-react'; +import Image from 'next/image'; +import CardForm, { type CardFormInputs } from './CardForm'; +import { useAuth } from '@/hooks/useAuth'; +import { addCardToCollection } from '@/lib/firestore'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'; + +export default function CardScanner() { + const [isScanning, setIsScanning] = useState(false); + const [scanResult, setScanResult] = useState(null); + const [error, setError] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [imageDataUrl, setImageDataUrl] = useState(null); + const [showForm, setShowForm] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { toast } = useToast(); + const { user } = useAuth(); + const router = useRouter(); + const fileInputRef = useRef(null); + + const handleImageChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Reset previous scan state + setScanResult(null); + setError(null); + setShowForm(false); + setImageDataUrl(null); + + const reader = new FileReader(); + reader.onloadend = () => { + const dataUrl = reader.result as string; + setImagePreview(dataUrl); // For preview + setImageDataUrl(dataUrl); // For sending to AI + }; + reader.readAsDataURL(file); + } + }; + + const handleScan = async () => { + if (!imageDataUrl) { + toast({ title: 'No image selected', description: 'Please select an image to scan.', variant: 'destructive' }); + return; + } + + setIsScanning(true); + setError(null); + setScanResult(null); + setShowForm(false); + + try { + const result = await scanPokemonCard({ photoDataUri: imageDataUrl }); + if (result.error) { + setError(result.error); + toast({ title: 'Scan Failed', description: result.error, variant: 'destructive' }); + } else if (result.cardDetails) { + setScanResult(result); + setShowForm(true); + toast({ title: 'Scan Successful', description: 'Card details identified. Please review and save.', variant: 'default' }); + } else { + setError('Unexpected response from scanner.'); + toast({ title: 'Scan Error', description: 'Unexpected response from scanner.', variant: 'destructive' }); + } + } catch (e) { + console.error('Scan error:', e); + const errorMessage = (e instanceof Error) ? e.message : 'An unknown error occurred during scanning.'; + setError(errorMessage); + toast({ title: 'Scan Error', description: errorMessage, variant: 'destructive' }); + } finally { + setIsScanning(false); + } + }; + + const handleSaveCard = async (data: CardFormInputs) => { + if (!user) { + toast({ title: 'Authentication Error', description: 'You must be logged in to save cards.', variant: 'destructive' }); + return; + } + if (!imageDataUrl) { // Ensure image data is still available + toast({ title: 'Image Error', description: 'Image data is missing. Please re-upload.', variant: 'destructive'}); + return; + } + + setIsSubmitting(true); + try { + const cardToSave = { + name: data.name, + set: data.set, + rarity: data.rarity, + imageDataUrl: data.imageDataUrl, // Use image from form state, which should be original scan + }; + await addCardToCollection(user.uid, cardToSave); + toast({ title: 'Card Saved!', description: `${data.name} has been added to your collection.` }); + // Reset state after successful save + setImagePreview(null); + setImageDataUrl(null); + setScanResult(null); + setShowForm(false); + setError(null); + if(fileInputRef.current) fileInputRef.current.value = ""; // Clear file input + router.push('/dashboard/collection'); + } catch (e) { + console.error('Error saving card:', e); + const errorMessage = (e instanceof Error) ? e.message : 'An unknown error occurred while saving.'; + toast({ title: 'Save Error', description: errorMessage, variant: 'destructive' }); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancelForm = () => { + setShowForm(false); + // Optionally clear scanResult or keep it if user might want to re-open form + }; + + + return ( +
+ + + Scan Pokémon Card + Upload an image of your Pokémon card to identify its details. + + +
+ + +
+ + {imagePreview && ( +
+

Image Preview

+ Card preview +
+ )} + + {imageDataUrl && ( + + )} +
+
+ + {error && !showForm && ( + + + + Scan Failed + + +

{error}

+
+
+ )} + + {showForm && scanResult?.cardDetails && imageDataUrl && ( + + )} +
+ ); +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..0b825dd --- /dev/null +++ b/src/components/layout/Navbar.tsx @@ -0,0 +1,111 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { LogIn, LogOut, UserCircle, ScanLine, BookOpen } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { Button } from '@/components/ui/button'; +import { signOut } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { useToast } from '@/hooks/use-toast'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +export default function Navbar() { + const { user, loading } = useAuth(); + const router = useRouter(); + const { toast } = useToast(); + + const handleSignOut = async () => { + try { + await signOut(auth); + toast({ title: 'Signed out successfully' }); + router.push('/login'); + } catch (error) { + console.error('Sign out error:', error); + toast({ title: 'Error signing out', description: (error as Error).message, variant: 'destructive' }); + } + }; + + const getInitials = (name?: string | null) => { + if (!name) return 'U'; + const names = name.split(' '); + if (names.length > 1) { + return names[0][0] + names[names.length - 1][0]; + } + return names[0].substring(0, 2); + }; + + return ( + + ); +} diff --git a/src/components/providers/AuthProvider.tsx b/src/components/providers/AuthProvider.tsx new file mode 100644 index 0000000..f3be7b9 --- /dev/null +++ b/src/components/providers/AuthProvider.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React, { createContext, useState, useEffect, type ReactNode } from 'react'; +import { onAuthStateChanged, type User } from 'firebase/auth'; +import { auth } from '@/lib/firebase'; +import { Loader2 } from 'lucide-react'; + +export interface AuthContextType { + user: User | null; + loading: boolean; +} + +export const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (currentUser) => { + setUser(currentUser); + setLoading(false); + }); + return () => unsubscribe(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/src/components/providers/FirebaseProvider.tsx b/src/components/providers/FirebaseProvider.tsx new file mode 100644 index 0000000..0a9e31d --- /dev/null +++ b/src/components/providers/FirebaseProvider.tsx @@ -0,0 +1,23 @@ +'use client'; + +import React, { type ReactNode, useEffect, useState } from 'react'; +import { app } from '@/lib/firebase'; // This initializes Firebase + +export function FirebaseProvider({ children }: { children: ReactNode }) { + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + // The 'app' import initializes Firebase. + // This effect ensures that we acknowledge initialization. + if (app) { + setInitialized(true); + } + }, []); + + if (!initialized) { + // You can return a loader here if needed + return null; + } + + return <>{children}; +} diff --git a/src/config/firebase.ts b/src/config/firebase.ts new file mode 100644 index 0000000..840ef3b --- /dev/null +++ b/src/config/firebase.ts @@ -0,0 +1,16 @@ +// TODO: Add your Firebase project configuration here +// See https://firebase.google.com/docs/web/setup#available-libraries + +// It's recommended to use environment variables for Firebase config +// For example, process.env.NEXT_PUBLIC_FIREBASE_API_KEY + +const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +export default firebaseConfig; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..a60d707 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useContext } from 'react'; +import { AuthContext, type AuthContextType } from '@/components/providers/AuthProvider'; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts new file mode 100644 index 0000000..e50b258 --- /dev/null +++ b/src/lib/firebase.ts @@ -0,0 +1,11 @@ +import { initializeApp, getApps, getApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; +import { getFirestore } from 'firebase/firestore'; +import firebaseConfig from '@/config/firebase'; + +// Initialize Firebase +const app = !getApps().length ? initializeApp(firebaseConfig) : getApp(); +const auth = getAuth(app); +const db = getFirestore(app); + +export { app, auth, db }; diff --git a/src/lib/firestore.ts b/src/lib/firestore.ts new file mode 100644 index 0000000..4cc5f6e --- /dev/null +++ b/src/lib/firestore.ts @@ -0,0 +1,101 @@ +import { + collection, + addDoc, + getDocs, + doc, + updateDoc, + deleteDoc, + query, + where, + serverTimestamp, + Timestamp, + getDoc, + orderBy, +} from 'firebase/firestore'; +import { db } from './firebase'; // Main Firebase config +import type { PokemonCard, ScannedCardData } from '@/types'; + +const CARDS_COLLECTION = 'users'; // Top-level collection for users + +// Path: users/{userId}/pokemon_cards/{pokemonCardId} +const getPokemonCardsCollectionRef = (userId: string) => { + return collection(db, CARDS_COLLECTION, userId, 'pokemon_cards'); +}; + +export const addCardToCollection = async ( + userId: string, + cardData: Omit +): Promise => { + if (!userId) throw new Error('User ID is required to add a card.'); + try { + const docRef = await addDoc(getPokemonCardsCollectionRef(userId), { + ...cardData, + userId, // Ensure userId is part of the document data as well for easier querying if needed + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }); + return docRef.id; + } catch (error) { + console.error('Error adding card to Firestore: ', error); + throw new Error('Failed to add card to collection.'); + } +}; + +export const getUserCards = async (userId: string): Promise => { + if (!userId) return []; + try { + const q = query(getPokemonCardsCollectionRef(userId), orderBy('updatedAt', 'desc')); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map(doc => ({ + id: doc.id, + ...(doc.data() as Omit), + })); + } catch (error) { + console.error('Error fetching user cards: ', error); + throw new Error('Failed to fetch card collection.'); + } +}; + +export const getCardById = async (userId: string, cardId: string): Promise => { + if (!userId || !cardId) return null; + try { + const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId); + const cardSnap = await getDoc(cardDocRef); + if (cardSnap.exists()) { + return { id: cardSnap.id, ...cardSnap.data() } as PokemonCard; + } + return null; + } catch (error) { + console.error('Error fetching card by ID: ', error); + throw new Error('Failed to fetch card details.'); + } +}; + +export const updateCardInCollection = async ( + userId: string, + cardId: string, + cardData: Partial> +): Promise => { + if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.'); + try { + const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId); + await updateDoc(cardDocRef, { + ...cardData, + updatedAt: serverTimestamp(), + }); + } catch (error) { + console.error('Error updating card: ', error); + throw new Error('Failed to update card.'); + } +}; + +export const deleteCardFromCollection = async (userId: string, cardId: string): Promise => { + if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.'); + try { + const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId); + await deleteDoc(cardDocRef); + } catch (error) { + console.error('Error deleting card: ', error); + throw new Error('Failed to delete card.'); + } +}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..146a472 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,19 @@ +import type { Timestamp } from 'firebase/firestore'; + +export interface PokemonCard { + id: string; // Firestore document ID + userId: string; + name: string; + set: string; + rarity: string; + imageDataUrl: string; // data URI of the card image + createdAt: Timestamp; + updatedAt: Timestamp; +} + +// For AI Scan result, before saving to Firestore +export interface ScannedCardData { + name?: string; + set?: string; + rarity?: string; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 4d4a68f..60d6915 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -8,10 +8,17 @@ export default { './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, extend: { fontFamily: { - body: ['Inter', 'sans-serif'], - headline: ['Inter', 'sans-serif'], + body: ['Inter', 'var(--font-inter)', 'sans-serif'], + headline: ['Inter', 'var(--font-inter)', 'sans-serif'], code: ['monospace'], }, colors: {