I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user).

A > before the line number in the error source usually indicates the line of interest:

> <!DOCTYPE html><html id="__next_error__"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_49a6ea35._.js"/><script src="/_next/static/chunks/node_modules_next_dist_compiled_2ce9398a._.js" async=""></script><script src="/_next/static/chunks/node_modules_next_dist_client_43e3ffb8._.js" async=""></script><script src="/_next/static/chunks/node_modules_next_dist_3bfaed20._.js" async=""></script><script src="/_next/static/chunks/node_modules_%40swc_helpers_cjs_00636ac3._.js" async=""></script><script src="/_next/static/chunks/_e69f0d32._.js" async=""></script><script src="/_next/static/chunks/_be317ff2._.js" async=""></script><script src="/_next/static/chunks/node_modules_next_dist_1a6ee436._.js" async=""></script><script src="/_next/static/chunks/src_app_favicon_ico_mjs_79b6a596._.js" async=""></script><meta name="robots" content="noindex"/><meta name="next-size-adjust" content=""/><meta name="next-error" content="not-found"/><!--$--><!--/$--><script src="/_next/static/chunks/node_modules_next_dist_build_polyfills_polyfill-nomodule.js" noModule=""></script></head><body><template data-next-error-message="Firebase: Error (auth/invalid-api-key)." data-next-error-digest="2253673657" data-next-error-stack="FirebaseError: Firebase: Error (auth/invalid-api-key).
>     at createErrorInternal (file:///home/user/studio/.next/server/chunks/ssr/d9ef2_@firebase_auth_dist_node-esm_dec10075._.js:538:40)
>     at _assert (file:///home/user/studio/.next/server/chunks/ssr/d9ef2_@firebase_auth_dist_node-esm_dec10075._.js:542:15)
>     at Component.eval [as instanceFactory] (file:///home/user/studio/.next/server/chunks/ssr/d9ef2_@firebase_auth_dist_node-esm_dec10075._.js:6842:9)
>     at Provider.getOrInitializeService (file:///home/user/studio/.next/server/chunks/ssr/node_modules_594ee151._.js:2456:39)
>     at Provider.initialize (file:///home/user/studio/.next/server/chunks/ssr/node_modules_594ee151._.js:2403:31)
>     at initializeAuth (file:///home/user/studio/.next/server/chunks/ssr/d9ef2_@firebase_auth_dist_node-esm_dec10075._.js:3404:27)
>     at getAuth (file:///home/user/studio/.next/server/chunks/ssr/d9ef2_@firebase_auth_dist_node-esm_dec10075._.js:6899:18)
>     at [project]/src/lib/firebase.ts [app-ssr] (ecmascript) (file:///home/user/studio/.next/server/chunks/ssr/%5Broot%20of%20the%20server%5D__4c51450b._.js:176:263)
>     at instantiateModule (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:593:23)
>     at getOrInstantiateModuleFromParent (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:652:12)
>     at esmImport (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:132:20)
>     at [project]/src/components/providers/FirebaseProvider.tsx [app-ssr] (ecmascript) (file:///home/user/studio/.next/server/chunks/ssr/%5Broot%20of%20the%20server%5D__4c51450b._.js:190:148)
>     at instantiateModule (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:593:23)
>     at getOrInstantiateModuleFromParent (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:652:12)
>     at commonJsRequire (/home/user/studio/.next/server/chunks/ssr/[turbopack]_runtime.js:147:20)
>     at require (/home/user/studio/node_modules/next/dist/compiled/next-server/app-page.runtime.dev.js:443:15880)
>     at requireModule (/home/user/studio/node_modules/next/dist/compiled/next-server/app-page.runtime.dev.js:206:1036)
>     at initializeModuleChunk (/home/user/studio/node_modules/next/dist/compiled/next-server/app-page.runtime.dev.js:206:25809)
>     at resolveModuleChunk (/home/user/studio/node_modules/next/dist/compiled/next-server/app-page.runtime.dev.js:206:24944)
>     at /home/user/studio/node_modules/next/dist/compiled/next-server/app-page.runtime.dev.js:206:54326"></template><script src="/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_49a6ea35._.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"3:\"$Sreact.fragment\"\n5:I[\"[project]/node_modules/next/dist/client/components/layout-router.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"default\"]\n6:I[\"[project]/node_modules/next/dist/client/components/render-from-template-context.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"default\"]\n8:I[\"[project]/src/components/providers/FirebaseProvider.tsx [app-client] (ecmascript)\",[\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"static/chunks/d9ef2_@firebase_auth_dist_esm2017_bdfab715._.js\",\"static/chunks/node_modules_@firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"static/chunks/node_modules_46e819fb._.js\",\"static/chunks/src_d3f13007._.js\",\"static/chunks/src_app_layout_tsx_f0e4c1a2._.js\"],\"FirebaseProvider\"]\n9:I[\"[project]/src/components/providers/AuthProvider.tsx [app-client] (ecmascript)\",[\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"static/chunks/d9ef2_@firebase_auth_dist_esm2017_bdfab715._.js\",\"static/chunks/node_modules_@firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"static/chunks/node_modules_46e819fb._.js\",\"static/chunks/src_d3f13007._.js\",\"static/chunks/src_app_layout_tsx_f0e4c1a2._.js\"],\"AuthProvider\"]\na:I[\"[project]/src/components/layout/Navbar.tsx [app-client] (ecmascript)\",[\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"static/chunks/d9ef2_@firebase_auth_dist_esm2017_bdfab715._.js\",\"static/chunks/node_modules_@firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"static/chunks/node_modules_46e819fb._.js\",\"static/chunks/src_d3f13007._.js\",\"static/chunks/src_app_layout_tsx_f0e4c1a2._.js\"],\"default\"]\ne:I[\"[project]/src/components/ui/toaster.tsx [app-client] (ecmascript)\",[\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"static/chunks/d9ef2_@firebase_auth_dist_esm2017_bdfab715._.js\",\"static/c"])</script><script>self.__next_f.push([1,"hunks/node_modules_@firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"static/chunks/node_modules_46e819fb._.js\",\"static/chunks/src_d3f13007._.js\",\"static/chunks/src_app_layout_tsx_f0e4c1a2._.js\"],\"Toaster\"]\nf:I[\"[project]/node_modules/next/dist/client/components/client-page.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"ClientPageRoot\"]\n10:I[\"[project]/src/app/page.tsx [app-client] (ecmascript)\",[\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"static/chunks/d9ef2_@firebase_auth_dist_esm2017_bdfab715._.js\",\"static/chunks/node_modules_@firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"static/chunks/node_modules_46e819fb._.js\",\"static/chunks/src_d3f13007._.js\",\"static/chunks/src_app_layout_tsx_f0e4c1a2._.js\",\"static/chunks/src_app_page_tsx_b025fed5._.js\",\"static/chunks/src_app_page_tsx_54ce92c9._.js\"],\"default\"]\n14:I[\"[project]/node_modules/next/dist/client/components/metadata/metadata-boundary.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"MetadataBoundary\"]\n17:I[\"[project]/node_modules/next/dist/client/components/metadata/metadata-boundary.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"OutletBoundary\"]\n1e:I[\"[project]/node_modules/next/dist/client/components/metadata/async-metadata.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"AsyncMetadataOutlet\"]\n21:I[\"[project]/node_modules/next/dist/client/components/client-segment.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"ClientSegmentRoot\"]\n22:I[\"[project]/node_modules/next/dist/client/components/error-boundary.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dis"])</script><script>self.__next_f.push([1,"t_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"default\"]\n23:I[\"[project]/node_modules/next/dist/client/components/http-access-fallback/error-boundary.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"HTTPAccessFallbackBoundary\"]\n24:I[\"[project]/node_modules/next/dist/client/components/metadata/metadata-boundary.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"ViewportBoundary\"]\n25:\"$SkResourceStore\"\n2e:\"$Sreact.suspense\"\n2f:I[\"[project]/node_modules/next/dist/client/components/metadata/async-metadata.js [app-client] (ecmascript)\",[\"static/chunks/node_modules_next_dist_1a6ee436._.js\",\"static/chunks/src_app_favicon_ico_mjs_79b6a596._.js\"],\"AsyncMetadata\"]\n:HL[\"/_next/static/chunks/%5Broot%20of%20the%20server%5D__f9b1a2bf._.css\",\"style\"]\n:HL[\"/_next/static/media/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw-s.p.0faac26c.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n2:{\"name\":\"Preloads\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{\"preloadCallbacks\":[\"$E(()=\u003e{ctx.componentMod.preloadStyle(fullHref,ctx.renderOpts.crossOrigin,ctx.nonce)})\",\"$E(()=\u003e{ctx.componentMod.preloadFont(href,type,ctx.renderOpts.crossOrigin,ctx.nonce)})\"]}}\n1:D\"$2\"\n1:null\n7:{\"name\":\"RootLayout\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{\"children\":[\"$\",\"$L5\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$3\",null,{\"children\":[\"$\",\"$L6\",null,{},null,[],1]},null,[],0],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$Y\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"},null,[],1],\"params\":\"$Y\"}}\n4:D\"$7\"\nc:{\"name\":\"NotFound\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{}}\nb:D\"$c\"\nd:{\"name\":\"HTTPAccessErrorFallback\",\"env\":\"Server\",\"key\":null,\"owner\":\"$c\",\"stack\":[],\"props\":{\"status\":404,\"me"])</script><script>self.__next_f.push([1,"ssage\":\"This page could not be found.\"}}\nb:D\"$d\"\nb:[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"},\"$d\",[],1],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}},\"$d\",[],1],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404},\"$d\",[],1],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"},\"$d\",[],1]},\"$d\",[],1]]},\"$d\",[],1]},\"$d\",[],1]]\n"])</script><script>self.__next_f.push([1,"4:[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"inter_9e72d27f-module__JKMi0a__variable\",\"children\":[[\"$\",\"head\",null,{\"children\":[[\"$\",\"link\",null,{\"rel\":\"preconnect\",\"href\":\"https://fonts.googleapis.com\"},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",227,272]],1],[\"$\",\"link\",null,{\"rel\":\"preconnect\",\"href\":\"https://fonts.gstatic.com\",\"crossOrigin\":\"anonymous\"},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",235,272]],1],[\"$\",\"link\",null,{\"href\":\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700\u0026display=swap\",\"rel\":\"stylesheet\"},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",244,272]],1]]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",225,264]],1],[\"$\",\"body\",null,{\"className\":\"font-body antialiased min-h-screen flex flex-col\",\"children\":[\"$\",\"$L8\",null,{\"children\":[\"$\",\"$L9\",null,{\"children\":[[\"$\",\"$La\",null,{},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",263,280]],1],[\"$\",\"main\",null,{\"className\":\"flex-grow container mx-auto px-4 py-8\",\"children\":[\"$\",\"$L5\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L6\",null,{},null,[],1],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[\"$b\",[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"},null,[],1]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",268,280]],1],[\"$\",\"$Le\",null,{},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",276,280]],1]]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",261,282]],1]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",260,278]],1]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",258,264]],1]]},\"$7\",[[\"RootLayout\",\"/home/user/studio/.next/server/chunks/ssr/[root of the server]__57a4c55f._.js\",221,263]],1]\n"])</script><script>self.__next_f.push([1,"12:{\"name\":\"\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{}}\n11:D\"$12\"\n13:{\"name\":\"MetadataTree\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{}}\n11:D\"$13\"\n16:{\"name\":\"__next_metadata_boundary__\",\"env\":\"Server\",\"key\":null,\"owner\":\"$13\",\"stack\":[],\"props\":{}}\n15:D\"$16\"\n11:[\"$\",\"$L14\",null,{\"children\":\"$L15\"},\"$13\",[],1]\n19:{\"name\":\"__next_outlet_boundary__\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{\"ready\":\"$E(async function getViewportReady() {\\n        await viewport();\\n        return undefined;\\n    })\"}}\n18:D\"$19\"\n1b:{\"name\":\"__next_outlet_boundary__\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{\"ready\":\"$E(async function getMetadataReady() {\\n        // Only warm up metadata() call when it's blocking metadata,\\n        // otherwise it will be fully managed by AsyncMetadata component.\\n        if (!serveStreamingMetadata) {\\n            await metadata();\\n        }\\n        return undefined;\\n    })\"}}\n1a:D\"$1b\"\n1d:{\"name\":\"StreamingMetadataOutlet\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{}}\n1c:D\"$1d\"\n1c:[\"$\",\"$L1e\",null,{\"promise\":\"$@1f\"},\"$1d\",[],1]\n"])</script><script>self.__next_f.push([1,"26:{\"name\":\"NonIndex\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{\"ctx\":{\"componentMod\":{\"ClientPageRoot\":\"$f\",\"ClientSegmentRoot\":\"$21\",\"GlobalError\":\"$22\",\"HTTPAccessFallbackBoundary\":\"$23\",\"LayoutRouter\":\"$5\",\"MetadataBoundary\":\"$14\",\"OutletBoundary\":\"$17\",\"Postpone\":\"$E(function Postpone({ reason, route }) {\\n    const prerenderStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    const dynamicTracking = prerenderStore \u0026\u0026 prerenderStore.type === 'prerender-ppr' ? prerenderStore.dynamicTracking : null;\\n    postponeWithTracking(route, reason, dynamicTracking);\\n})\",\"RenderFromTemplateContext\":\"$6\",\"ViewportBoundary\":\"$24\",\"__next_app__\":{\"require\":\"$E(function () { [native code] })\",\"loadChunk\":\"$E(function () { [native code] })\"},\"actionAsyncStorage\":{\"kResourceStore\":\"$25\",\"enabled\":false},\"collectSegmentData\":\"$E(async function collectSegmentData(shouldAssumePartialData, fullPageDataBuffer, staleTime, clientModules, serverConsumerManifest, fallbackRouteParams) {\\n    // Traverse the router tree and generate a prefetch response for each segment.\\n    // A mutable map to collect the results as we traverse the route tree.\\n    const resultMap = new Map();\\n    // Before we start, warm up the module cache by decoding the page data once.\\n    // Then we can assume that any remaining async tasks that occur the next time\\n    // are due to hanging promises caused by dynamic data access. Note we only\\n    // have to do this once per page, not per individual segment.\\n    //\\n    try {\\n        await (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2d$server$2d$dom$2d$turbopack$2f$client$2e$edge$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"createFromReadableStream\\\"])((0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$stream$2d$utils$2f$node$2d$web$2d$streams$2d$helper$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"streamFromBuffer\\\"])(fullPageDataBuffer), {\\n            serverConsumerManifest\\n        });\\n        await (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$lib$2f$scheduler$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"waitAtLeastOneReactRenderTask\\\"])();\\n    } catch  {}\\n    // Create an abort controller that we'll use to stop the stream.\\n    const abortController = new AbortController();\\n    const onCompletedProcessingRouteTree = async ()=\u003e{\\n        // Since all we're doing is decoding and re-encoding a cached prerender, if\\n        // serializing the stream takes longer than a microtask, it must because of\\n        // hanging promises caused by dynamic data.\\n        await (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$lib$2f$scheduler$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"waitAtLeastOneReactRenderTask\\\"])();\\n        abortController.abort();\\n    };\\n    // Generate a stream for the route tree prefetch. While we're walking the\\n    // tree, we'll also spawn additional tasks to generate the segment prefetches.\\n    // The promises for these tasks are pushed to a mutable array that we will\\n    // await once the route tree is fully rendered.\\n    const segmentTasks = [];\\n    const { prelude: treeStream } = await (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$server$2d$dom$2d$turbopack$2d$static$2d$edge$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"unstable_prerender\\\"])(// we need to use a component so that when we decode the original stream\\n    // inside of it, the side effects are transferred to the new stream.\\n    // @ts-expect-error\\n    /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(PrefetchTreeData, {\\n        shouldAssumePartialData: shouldAssumePartialData,\\n        fullPageDataBuffer: fullPageDataBuffer,\\n        fallbackRouteParams: fallbackRouteParams,\\n        serverConsumerManifest: serverConsumerManifest,\\n        clientModules: clientModules,\\n        staleTime: staleTime,\\n        segmentTasks: segmentTasks,\\n        onCompletedProcessingRouteTree: onCompletedProcessingRouteTree\\n    }), clientModules, {\\n        signal: abortController.signal,\\n        onError: onSegmentPrerenderError\\n    });\\n    // Write the route tree to a special `/_tree` segment.\\n    const treeBuffer = await (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$stream$2d$utils$2f$node$2d$web$2d$streams$2d$helper$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"streamToBuffer\\\"])(treeStream);\\n    resultMap.set('/_tree', treeBuffer);\\n    // Now that we've finished rendering the route tree, all the segment tasks\\n    // should have been spawned. Await them in parallel and write the segment\\n    // prefetches to the result map.\\n    for (const [segmentPath, buffer] of (await Promise.all(segmentTasks))){\\n        resultMap.set(segmentPath, buffer);\\n    }\\n    return resultMap;\\n})\",\"createMetadataComponents\":\"$E(function createMetadataComponents({ tree, searchParams, metadataContext, getDynamicParamFromSegment, appUsingSizeAdjustment, errorType, createServerParamsForMetadata, workStore, MetadataBoundary, ViewportBoundary, serveStreamingMetadata }) {\\n    function ViewportTree() {\\n        return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsxs\\\"])(__TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"Fragment\\\"], {\\n            children: [\\n                /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(ViewportBoundary, {\\n                    children: /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(Viewport, {})\\n                }),\\n                appUsingSizeAdjustment ? /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(\\\"meta\\\", {\\n                    name: \\\"next-size-adjust\\\",\\n                    content: \\\"\\\"\\n                }) : null\\n            ]\\n        });\\n    }\\n    function MetadataTree() {\\n        return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(MetadataBoundary, {\\n            children: /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(Metadata, {})\\n        });\\n    }\\n    function viewport() {\\n        return getResolvedViewport(tree, searchParams, getDynamicParamFromSegment, createServerParamsForMetadata, workStore, errorType);\\n    }\\n    async function Viewport() {\\n        try {\\n            return await viewport();\\n        } catch (error) {\\n            if (!errorType \u0026\u0026 (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$client$2f$components$2f$http$2d$access$2d$fallback$2f$http$2d$access$2d$fallback$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"isHTTPAccessFallbackError\\\"])(error)) {\\n                try {\\n                    return await getNotFoundViewport(tree, searchParams, getDynamicParamFromSegment, createServerParamsForMetadata, workStore);\\n                } catch  {}\\n            }\\n            // We don't actually want to error in this component. We will\\n            // also error in the MetadataOutlet which causes the error to\\n            // bubble from the right position in the page to be caught by the\\n            // appropriate boundaries\\n            return null;\\n        }\\n    }\\n    Viewport.displayName = __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$lib$2f$metadata$2f$metadata$2d$constants$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"VIEWPORT_BOUNDARY_NAME\\\"];\\n    function metadata() {\\n        return getResolvedMetadata(tree, searchParams, getDynamicParamFromSegment, metadataContext, createServerParamsForMetadata, workStore, errorType);\\n    }\\n    async function resolveFinalMetadata() {\\n        let result;\\n        let error = null;\\n        try {\\n            result = await metadata();\\n            return {\\n                metadata: result,\\n                error: null,\\n                digest: undefined\\n            };\\n        } catch (metadataErr) {\\n            error = metadataErr;\\n            if (!errorType \u0026\u0026 (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$client$2f$components$2f$http$2d$access$2d$fallback$2f$http$2d$access$2d$fallback$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"isHTTPAccessFallbackError\\\"])(metadataErr)) {\\n                try {\\n                    result = await getNotFoundMetadata(tree, searchParams, getDynamicParamFromSegment, metadataContext, createServerParamsForMetadata, workStore);\\n                    return {\\n                        metadata: result,\\n                        error,\\n                        digest: error == null ? void 0 : error.digest\\n                    };\\n                } catch (notFoundMetadataErr) {\\n                    error = notFoundMetadataErr;\\n                    // In PPR rendering we still need to throw the postpone error.\\n                    // If metadata is postponed, React needs to be aware of the location of error.\\n                    if (serveStreamingMetadata \u0026\u0026 (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$lib$2f$router$2d$utils$2f$is$2d$postpone$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"isPostpone\\\"])(notFoundMetadataErr)) {\\n                        throw notFoundMetadataErr;\\n                    }\\n                }\\n            }\\n            // In PPR rendering we still need to throw the postpone error.\\n            // If metadata is postponed, React needs to be aware of the location of error.\\n            if (serveStreamingMetadata \u0026\u0026 (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$lib$2f$router$2d$utils$2f$is$2d$postpone$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"isPostpone\\\"])(metadataErr)) {\\n                throw metadataErr;\\n            }\\n            // We don't actually want to error in this component. We will\\n            // also error in the MetadataOutlet which causes the error to\\n            // bubble from the right position in the page to be caught by the\\n            // appropriate boundaries\\n            return {\\n                metadata: result,\\n                error,\\n                digest: error == null ? void 0 : error.digest\\n            };\\n        }\\n    }\\n    async function Metadata() {\\n        const promise = resolveFinalMetadata();\\n        if (serveStreamingMetadata) {\\n            return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(__TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"Suspense\\\"], {\\n                fallback: null,\\n                children: /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(__TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$client$2f$components$2f$metadata$2f$async$2d$metadata$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"AsyncMetadata\\\"], {\\n                    promise: promise\\n                })\\n            });\\n        }\\n        const metadataState = await promise;\\n        return metadataState.metadata;\\n    }\\n    Metadata.displayName = __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$lib$2f$metadata$2f$metadata$2d$constants$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"METADATA_BOUNDARY_NAME\\\"];\\n    async function getMetadataReady() {\\n        // Only warm up metadata() call when it's blocking metadata,\\n        // otherwise it will be fully managed by AsyncMetadata component.\\n        if (!serveStreamingMetadata) {\\n            await metadata();\\n        }\\n        return undefined;\\n    }\\n    async function getViewportReady() {\\n        await viewport();\\n        return undefined;\\n    }\\n    function StreamingMetadataOutlet() {\\n        if (serveStreamingMetadata) {\\n            return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$jsx$2d$runtime$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"jsx\\\"])(__TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$client$2f$components$2f$metadata$2f$async$2d$metadata$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"AsyncMetadataOutlet\\\"], {\\n                promise: resolveFinalMetadata()\\n            });\\n        }\\n        return null;\\n    }\\n    return {\\n        ViewportTree,\\n        MetadataTree,\\n        getViewportReady,\\n        getMetadataReady,\\n        StreamingMetadataOutlet\\n    };\\n})\",\"createPrerenderParamsForClientSegment\":\"$E(function createPrerenderParamsForClientSegment(underlyingParams, workStore) {\\n    const prerenderStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (prerenderStore \u0026\u0026 prerenderStore.type === 'prerender') {\\n        const fallbackParams = workStore.fallbackRouteParams;\\n        if (fallbackParams) {\\n            for(let key in underlyingParams){\\n                if (fallbackParams.has(key)) {\\n                    // This params object has one of more fallback params so we need to consider\\n                    // the awaiting of this params object \\\"dynamic\\\". Since we are in dynamicIO mode\\n                    // we encode this as a promise that never resolves\\n                    return (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$dynamic$2d$rendering$2d$utils$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"makeHangingPromise\\\"])(prerenderStore.renderSignal, '`params`');\\n                }\\n            }\\n        }\\n    }\\n    // We're prerendering in a mode that does not abort. We resolve the promise without\\n    // any tracking because we're just transporting a value from server to client where the tracking\\n    // will be applied.\\n    return Promise.resolve(underlyingParams);\\n})\",\"createPrerenderSearchParamsForClientPage\":\"$E(function createPrerenderSearchParamsForClientPage(workStore) {\\n    if (workStore.forceStatic) {\\n        // When using forceStatic we override all other logic and always just return an empty\\n        // dictionary object.\\n        return Promise.resolve({});\\n    }\\n    const prerenderStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (prerenderStore \u0026\u0026 prerenderStore.type === 'prerender') {\\n        // dynamicIO Prerender\\n        // We're prerendering in a mode that aborts (dynamicIO) and should stall\\n        // the promise to ensure the RSC side is considered dynamic\\n        return (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$dynamic$2d$rendering$2d$utils$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"makeHangingPromise\\\"])(prerenderStore.renderSignal, '`searchParams`');\\n    }\\n    // We're prerendering in a mode that does not aborts. We resolve the promise without\\n    // any tracking because we're just transporting a value from server to client where the tracking\\n    // will be applied.\\n    return Promise.resolve({});\\n})\",\"createServerParamsForMetadata\":\"$E(function createServerParamsForServerSegment(underlyingParams, workStore) {\\n    const workUnitStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (workUnitStore) {\\n        switch(workUnitStore.type){\\n            case 'prerender':\\n            case 'prerender-ppr':\\n            case 'prerender-legacy':\\n                return createPrerenderParams(underlyingParams, workStore, workUnitStore);\\n            default:\\n        }\\n    }\\n    return createRenderParams(underlyingParams, workStore);\\n})\",\"createServerParamsForServerSegment\":\"$E(function createServerParamsForServerSegment(underlyingParams, workStore) {\\n    const workUnitStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (workUnitStore) {\\n        switch(workUnitStore.type){\\n            case 'prerender':\\n            case 'prerender-ppr':\\n            case 'prerender-legacy':\\n                return createPrerenderParams(underlyingParams, workStore, workUnitStore);\\n            default:\\n        }\\n    }\\n    return createRenderParams(underlyingParams, workStore);\\n})\",\"createServerSearchParamsForMetadata\":\"$E(function createServerSearchParamsForServerPage(underlyingSearchParams, workStore) {\\n    const workUnitStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (workUnitStore) {\\n        switch(workUnitStore.type){\\n            case 'prerender':\\n            case 'prerender-ppr':\\n            case 'prerender-legacy':\\n                return createPrerenderSearchParams(workStore, workUnitStore);\\n            default:\\n        }\\n    }\\n    return createRenderSearchParams(underlyingSearchParams, workStore);\\n})\",\"createServerSearchParamsForServerPage\":\"$E(function createServerSearchParamsForServerPage(underlyingSearchParams, workStore) {\\n    const workUnitStore = __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"].getStore();\\n    if (workUnitStore) {\\n        switch(workUnitStore.type){\\n            case 'prerender':\\n            case 'prerender-ppr':\\n            case 'prerender-legacy':\\n                return createPrerenderSearchParams(workStore, workUnitStore);\\n            default:\\n        }\\n    }\\n    return createRenderSearchParams(underlyingSearchParams, workStore);\\n})\",\"createTemporaryReferenceSet\":\"$E(function(){return new WeakMap})\",\"decodeAction\":\"$E(function(body,serverManifest){var formData=new FormData,action=null;return body.forEach(function(value1,key){key.startsWith(\\\"$ACTION_\\\")?key.startsWith(\\\"$ACTION_REF_\\\")?(value1=decodeBoundActionMetaData(body,serverManifest,value1=\\\"$ACTION_\\\"+key.slice(12)+\\\":\\\"),action=loadServerReference(serverManifest,value1.id,value1.bound)):key.startsWith(\\\"$ACTION_ID_\\\")\u0026\u0026(action=loadServerReference(serverManifest,value1=key.slice(11),null)):formData.append(key,value1)}),null===action?null:action.then(function(fn){return fn.bind(null,formData)})})\",\"decodeFormState\":\"$E(function(actionResult,body,serverManifest){var keyPath=body.get(\\\"$ACTION_KEY\\\");if(\\\"string\\\"!=typeof keyPath)return Promise.resolve(null);var metaData=null;if(body.forEach(function(value1,key){key.startsWith(\\\"$ACTION_REF_\\\")\u0026\u0026(metaData=decodeBoundActionMetaData(body,serverManifest,\\\"$ACTION_\\\"+key.slice(12)+\\\":\\\"))}),null===metaData)return Promise.resolve(null);var referenceId=metaData.id;return Promise.resolve(metaData.bound).then(function(bound){return null===bound?null:[actionResult,keyPath,referenceId,bound.length-1]})})\",\"decodeReply\":\"$E(function(body,turbopackMap,options){if(\\\"string\\\"==typeof body){var form=new FormData;form.append(\\\"0\\\",body),body=form}return turbopackMap=getChunk(body=createResponse(turbopackMap,\\\"\\\",options?options.temporaryReferences:void 0,body),0),close(body),turbopackMap})\",\"pages\":[\"[project]/src/app/page.tsx\"],\"patchFetch\":\"$E(function patchFetch() {\\n    return (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$lib$2f$patch$2d$fetch$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"patchFetch\\\"])({\\n        workAsyncStorage: __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workAsyncStorage\\\"],\\n        workUnitAsyncStorage: __TURBOPACK__imported__module__$5b$externals$5d2f$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js__$5b$external$5d$__$28$next$2f$dist$2f$server$2f$app$2d$render$2f$work$2d$unit$2d$async$2d$storage$2e$external$2e$js$2c$__cjs$29$__[\\\"workUnitAsyncStorage\\\"]\\n    });\\n})\",\"preconnect\":\"$E(function preconnect(href, crossOrigin, nonce) {\\n    const opts = {};\\n    if (typeof crossOrigin === 'string') {\\n        opts.crossOrigin = crossOrigin;\\n    }\\n    if (typeof nonce === 'string') {\\n        opts.nonce = nonce;\\n    }\\n    ;\\n    __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$dom$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"default\\\"].preconnect(href, opts);\\n})\",\"preloadFont\":\"$E(function preloadFont(href, type, crossOrigin, nonce) {\\n    const opts = {\\n        as: 'font',\\n        type\\n    };\\n    if (typeof crossOrigin === 'string') {\\n        opts.crossOrigin = crossOrigin;\\n    }\\n    if (typeof nonce === 'string') {\\n        opts.nonce = nonce;\\n    }\\n    __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$dom$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"default\\\"].preload(href, opts);\\n})\",\"preloadStyle\":\"$E(function preloadStyle(href, crossOrigin, nonce) {\\n    const opts = {\\n        as: 'style'\\n    };\\n    if (typeof crossOrigin === 'string') {\\n        opts.crossOrigin = crossOrigin;\\n    }\\n    if (typeof nonce === 'string') {\\n        opts.nonce = nonce;\\n    }\\n    __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$rsc$2f$react$2d$dom$2e$js__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__[\\\"default\\\"].preload(href, opts);\\n})\",\"prerender\":\"$E(function(model,turbopackMap,options){return new Promise(function(resolve,reject){var request=new RequestInstance(PRERENDER,model,turbopackMap,options?options.onError:void 0,options?options.identifierPrefix:void 0,options?options.onPostpone:void 0,options?options.temporaryReferences:void 0,options?options.environmentName:void 0,options?options.filterStackFrame:void 0,function(){resolve({prelude:new ReadableStream({type:\\\"bytes\\\",start:function(){startWork(request)},pull:function(controller){startFlowing(request,controller)},cancel:function(reason){request.destination=null,abort(request,reason)}},{highWaterMark:0})})},reject);if(options\u0026\u0026options.signal){var signal=options.signal;if(signal.aborted)abort(request,signal.reason);else{var listener=function(){abort(request,signal.reason),signal.removeEventListener(\\\"abort\\\",listener)};signal.addEventListener(\\\"abort\\\",listener)}}startWork(request)})})\",\"renderToReadableStream\":\"$E(function(model,turbopackMap,options){var request=new RequestInstance(20,model,turbopackMap,options?options.onError:void 0,options?options.identifierPrefix:void 0,options?options.onPostpone:void 0,options?options.temporaryReferences:void 0,options?options.environmentName:void 0,options?options.filterStackFrame:void 0,noop,noop);if(options\u0026\u0026options.signal){var signal=options.signal;if(signal.aborted)abort(request,signal.reason);else{var listener=function(){abort(request,signal.reason),signal.removeEventListener(\\\"abort\\\",listener)};signal.addEventListener(\\\"abort\\\",listener)}}return new ReadableStream({type:\\\"bytes\\\",start:function(){startWork(request)},pull:function(controller){startFlowing(request,controller)},cancel:function(reason){request.destination=null,abort(request,reason)}},{highWaterMark:0})})\",\"routeModule\":{\"userland\":{\"loaderTree\":\"$Y\"},\"definition\":\"$Y\"},\"serverHooks\":\"$Y\",\"taintObjectReference\":\"$E(function notImplemented() {\\n    throw Object.defineProperty(new Error('Taint can only be used with the taint flag.'), \\\"__NEXT_ERROR_CODE\\\", {\\n        value: \\\"E354\\\",\\n        enumerable: false,\\n        configurable: true\\n    });\\n})\",\"tree\":\"$Y\",\"workAsyncStorage\":\"$Y\",\"workUnitAsyncStorage\":\"$Y\"},\"url\":\"$Y\",\"renderOpts\":\"$Y\",\"workStore\":\"$Y\",\"parsedRequestHeaders\":\"$Y\",\"getDynamicParamFromSegment\":\"$E(function(segment){let segmentParam=getSegmentParam(segment);if(!segmentParam)return null;let key=segmentParam.param,value1=params[key];if(fallbackRouteParams\u0026\u0026fallbackRouteParams.has(segmentParam.param)?value1=fallbackRouteParams.get(segmentParam.param):Array.isArray(value1)?value1=value1.map(i=\u003eencodeURIComponent(i)):\\\"string\\\"==typeof value1\u0026\u0026(value1=encodeURIComponent(value1)),!value1){let isCatchall=\\\"catchall\\\"===segmentParam.type,isOptionalCatchall=\\\"optional-catchall\\\"===segmentParam.type;if(isCatchall||isOptionalCatchall){let dynamicParamType=dynamicParamTypes[segmentParam.type];return isOptionalCatchall?{param:key,value:null,type:dynamicParamType,treeSegment:[key,\\\"\\\",dynamicParamType]}:{param:key,value:value1=pagePath.split(\\\"/\\\").slice(1).flatMap(pathSegment=\u003e{let param=function(param){let match=param.match(PARAMETER_PATTERN);return match?parseMatchedParameter(match[2]):parseMatchedParameter(param)}(pathSegment);return params[param.key]??param.key}),type:dynamicParamType,treeSegment:[key,value1.join(\\\"/\\\"),dynamicParamType]}}}let type=function(type){let short=dynamicParamTypes[type];if(!short)throw Object.defineProperty(Error(\\\"Unknown dynamic param type\\\"),\\\"__NEXT_ERROR_CODE\\\",{value:\\\"E378\\\",enumerable:!1,configurable:!0});return short}(segmentParam.type);return{param:key,value:value1,treeSegment:[key,Array.isArray(value1)?value1.join(\\\"/\\\"):value1,type],type:type}})\",\"query\":\"$0:f:0:1:2:children:1:props:children:0:props:searchParams\",\"isPrefetch\":false,\"isAction\":false,\"requestTimestamp\":1749033108372,\"appUsingSizeAdjustment\":true,\"flightRouterState\":\"$undefined\",\"requestId\":\"NjUgkiy7OGko37blY3Dyf\",\"pagePath\":\"/\",\"clientReferenceManifest\":\"$Y\",\"assetPrefix\":\"\",\"isNotFoundPath\":false,\"nonce\":\"$undefined\",\"res\":\"$Y\",\"sharedContext\":\"$Y\"}}}\n"])</script><script>self.__next_f.push([1,"20:D\"$26\"\n20:null\n28:{\"name\":\"ViewportTree\",\"env\":\"Server\",\"key\":\"NjUgkiy7OGko37blY3Dyf\",\"owner\":null,\"stack\":[],\"props\":{}}\n27:D\"$28\"\n2a:{\"name\":\"__next_viewport_boundary__\",\"env\":\"Server\",\"key\":null,\"owner\":\"$28\",\"stack\":[],\"props\":{}}\n29:D\"$2a\"\n27:[\"$\",\"$3\",\"NjUgkiy7OGko37blY3Dyf\",{\"children\":[[\"$\",\"$L24\",null,{\"children\":\"$L29\"},\"$28\",[],1],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"},\"$28\",[],1]]},null,null,0]\n2c:{\"name\":\"\",\"env\":\"Server\",\"key\":null,\"owner\":null,\"stack\":[],\"props\":{}}\n2b:D\"$2c\"\n2b:null\n2d:[]\n0:{\"P\":\"$1\",\"b\":\"development\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$3\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/chunks/%5Broot%20of%20the%20server%5D__f9b1a2bf._.css\",\"precedence\":\"next_static/chunks/[root of the server]__f9b1a2bf._.css\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-0\",{\"src\":\"/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_39ae8672._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-1\",{\"src\":\"/_next/static/chunks/d9ef2_%40firebase_auth_dist_esm2017_bdfab715._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-2\",{\"src\":\"/_next/static/chunks/node_modules_%40firebase_firestore_dist_index_esm2017_c2fcaa2e.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-3\",{\"src\":\"/_next/static/chunks/node_modules_46e819fb._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-4\",{\"src\":\"/_next/static/chunks/src_d3f13007._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-5\",{\"src\":\"/_next/static/chunks/src_app_layout_tsx_f0e4c1a2._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0]],\"$4\"]},null,[],0],{\"children\":[\"__PAGE__\",[\"$\",\"$3\",\"c\",{\"children\":[[\"$\",\"$Lf\",null,{\"Component\":\"$10\",\"searchParams\":{},\"params\":{}},null,[],1],\"$11\",[[\"$\",\"script\",\"script-0\",{\"src\":\"/_next/static/chunks/src_app_page_tsx_b025fed5._.js\",\"as"])</script><script>self.__next_f.push([1,"ync\":true,\"nonce\":\"$undefined\"},null,[],0],[\"$\",\"script\",\"script-1\",{\"src\":\"/_next/static/chunks/src_app_page_tsx_54ce92c9._.js\",\"async\":true,\"nonce\":\"$undefined\"},null,[],0]],[\"$\",\"$L17\",null,{\"children\":[\"$L18\",\"$L1a\",\"$1c\"]},null,[],1]]},null,[],0],{},null,false]},null,false],[\"$\",\"$3\",\"h\",{\"children\":[\"$20\",\"$27\",\"$2b\"]},null,[],0],false]],\"m\":\"$W2d\",\"G\":[\"$22\",\"$undefined\"],\"s\":false,\"S\":false}\n15:[\"$\",\"$2e\",null,{\"fallback\":null,\"children\":[\"$\",\"$L2f\",null,{\"promise\":\"$@30\"},\"$16\",[],1]},\"$16\",[],1]\n1a:null\n"])</script><script>self.__next_f.push([1,"30:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Cardex - Pokémon Card Scanner\"},\"$16\",[],0],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Scan, identify, and manage your Pokémon card collection with Cardex.\"},\"$16\",[],0],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico?favicon.56766c03.ico\",\"sizes\":\"48x48\",\"type\":\"image/x-icon\"},\"$16\",[],0]],\"error\":null,\"digest\":\"$undefined\"}\n1f:{\"metadata\":\"$30:metadata\",\"error\":null,\"digest\":\"$undefined\"}\n29:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"},\"$19\",[],0],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"},\"$19\",[],0]]\n18:null\n"])</script></body></html>
This commit is contained in:
Xavier Portilla Edo
2025-06-04 10:32:19 +00:00
parent 87f51161ee
commit 3e9fbd9b35
32 changed files with 1840 additions and 77 deletions
View File
+19
View File
@@ -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.
+5 -1
View File
@@ -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';
+75
View File
@@ -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:<mimetype>;base64,<encoded_data>'."
),
});
export type ScanPokemonCardInput = z.infer<typeof ScanPokemonCardInputSchema>;
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<typeof ScanPokemonCardOutputSchema>;
export async function scanPokemonCard(input: ScanPokemonCardInput): Promise<ScanPokemonCardOutput> {
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.'};
}
}
);
@@ -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<typeof SummarizeCardInformationInputSchema>;
const SummarizeCardInformationOutputSchema = z.object({
summary: z.string().describe('A summary of the card information.'),
});
export type SummarizeCardInformationOutput = z.infer<typeof SummarizeCardInformationOutputSchema>;
export async function summarizeCardInformation(
input: SummarizeCardInformationInput
): Promise<SummarizeCardInformationOutput> {
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!;
}
);
@@ -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<PokemonCard | null>(null);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<PokemonCard> = {
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 (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-destructive">
<AlertTriangle className="mx-auto h-12 w-12 mb-4" />
<h2 className="text-xl font-semibold mb-2">Error Loading Card</h2>
<p>{error}</p>
</div>
);
}
if (!card) {
// This case should ideally be covered by error state, but as a fallback:
return <div className="text-center py-10">Card not found.</div>;
}
return (
<div>
<h1 className="text-3xl font-bold mb-8 text-center font-headline">Edit Card: {card.name}</h1>
<CardForm
initialData={card}
onSubmit={handleUpdateCard}
isSubmitting={isSubmitting}
submitButtonText="Save Changes"
formTitle="Update Card Information"
formDescription="Modify the details of your Pokémon card."
onCancel={() => router.push('/dashboard/collection')}
/>
</div>
);
}
+115
View File
@@ -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<PokemonCard[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingCardId, setDeletingCardId] = useState<string | null>(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 (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-destructive">
<AlertTriangle className="mx-auto h-12 w-12 mb-4" />
<h2 className="text-xl font-semibold mb-2">Error Loading Collection</h2>
<p className="mb-4">{error}</p>
<Button onClick={fetchCards}>Try Again</Button>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<h1 className="text-3xl font-bold font-headline">My Card Collection</h1>
<Button asChild>
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Add New Card
</Link>
</Button>
</div>
{cards.length === 0 ? (
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-archive-restore mx-auto mb-4 text-muted-foreground"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-V8"/><path d="M9.5 12.5 12 15l2.5-2.5"/><path d="M12 15V9"/></svg>
<h2 className="text-xl font-semibold text-muted-foreground">Your collection is empty.</h2>
<p className="text-muted-foreground mt-2">Start by scanning your first Pokémon card!</p>
<Button asChild className="mt-6">
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
</Link>
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{cards.map(card => (
<CardItem
key={card.id}
card={card}
onDelete={handleDeleteCard}
isDeleting={deletingCardId === card.id}
/>
))}
</div>
)}
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import AuthGuard from '@/components/auth/AuthGuard';
import type { ReactNode } from 'react';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return <AuthGuard>{children}</AuthGuard>;
}
+21
View File
@@ -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 (
<div className="text-center">
<h1 className="text-3xl font-bold font-headline">Welcome to your Cardex Dashboard</h1>
<p className="mt-4 text-lg text-muted-foreground">
Manage your Pokémon card collection with ease.
</p>
<p className="mt-2 text-md text-muted-foreground">Redirecting to card scanner...</p>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import CardScanner from '@/components/cards/CardScanner';
export default function ScanCardPage() {
return (
<div>
<CardScanner />
</div>
);
}
+109
View File
@@ -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<typeof forgotPasswordSchema>;
export default function ForgotPasswordPage() {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordInputs>({
resolver: zodResolver(forgotPasswordSchema),
});
const onSubmit: SubmitHandler<ForgotPasswordInputs> = 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 (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
Forgot Your Password?
</h1>
<p className="mt-2 text-center text-sm text-muted-foreground">
No worries! Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<Card className="shadow-xl">
<CardHeader>
<CardTitle className="text-xl">Reset Password</CardTitle>
<CardDescription>Enter your email address below.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<Label htmlFor="email">Email</Label>
<div className="relative mt-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
autoComplete="email"
{...register('email')}
className="pl-10"
placeholder="you@example.com"
/>
</div>
{errors.email && <p className="mt-1 text-sm text-destructive">{errors.email.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Send Reset Link
</Button>
</form>
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Remembered your password?{' '}
<Link href="/login" legacyBehavior>
<a className="font-medium text-primary hover:text-primary/80">
Sign in
</a>
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}
+59 -63
View File
@@ -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;
}
}
+26 -7
View File
@@ -1,9 +1,17 @@
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 (
<html lang="en">
<html lang="en" className={inter.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></link>
{/* Google Fonts link is kept as per instructions, next/font is also used for Inter for better optimization */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body className="font-body antialiased">{children}</body>
<body className="font-body antialiased min-h-screen flex flex-col">
<FirebaseProvider>
<AuthProvider>
<Navbar />
<main className="flex-grow container mx-auto px-4 py-8">
{children}
</main>
<Toaster />
</AuthProvider>
</FirebaseProvider>
</body>
</html>
);
}
+80
View File
@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
Sign in to Cardex
</h1>
<p className="mt-2 text-center text-sm text-muted-foreground">
Access your Pokémon card collection.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<Card className="shadow-xl">
<CardHeader>
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Enter your credentials to continue.</CardDescription>
</CardHeader>
<CardContent>
<LoginForm />
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-card px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="mt-6">
<GoogleSignInButton />
</div>
</div>
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/signup" legacyBehavior>
<a className="font-medium text-primary hover:text-primary/80">
Sign up
</a>
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}
+26 -2
View File
@@ -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 (
<div className="flex items-center justify-center min-h-[calc(100vh-4rem-var(--header-height,0px))]">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
+80
View File
@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
Create your Cardex account
</h1>
<p className="mt-2 text-center text-sm text-muted-foreground">
Start managing your Pokémon card collection today.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<Card className="shadow-xl">
<CardHeader>
<CardTitle className="text-xl">Get Started</CardTitle>
<CardDescription>It&apos;s quick and easy.</CardDescription>
</CardHeader>
<CardContent>
<SignupForm />
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-card px-2 text-muted-foreground">
Or sign up with
</span>
</div>
</div>
<div className="mt-6">
<GoogleSignInButton />
</div>
</div>
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" legacyBehavior>
<a className="font-medium text-primary hover:text-primary/80">
Sign in
</a>
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}
+31
View File
@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
return <>{children}</>;
}
@@ -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 = () => (
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
<path d="M1 1h22v22H1z" fill="none"/>
</svg>
);
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 (
<Button variant="outline" type="button" className="w-full" onClick={handleGoogleSignIn} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <GoogleIcon />}
Sign in with Google
</Button>
);
}
+100
View File
@@ -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<typeof loginSchema>;
export default function LoginForm() {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormInputs>({
resolver: zodResolver(loginSchema),
});
const onSubmit: SubmitHandler<LoginFormInputs> = 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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<Label htmlFor="email">Email</Label>
<div className="relative mt-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
autoComplete="email"
{...register('email')}
className="pl-10"
placeholder="you@example.com"
/>
</div>
{errors.email && <p className="mt-1 text-sm text-destructive">{errors.email.message}</p>}
</div>
<div>
<Label htmlFor="password">Password</Label>
<div className="relative mt-1">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
autoComplete="current-password"
{...register('password')}
className="pl-10"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password.message}</p>}
</div>
<div className="text-sm">
<Link href="/forgot-password" legacyBehavior>
<a className="font-medium text-primary hover:text-primary/80">
Forgot your password?
</a>
</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Sign In
</Button>
</form>
);
}
+111
View File
@@ -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<typeof signupSchema>;
export default function SignupForm() {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupFormInputs>({
resolver: zodResolver(signupSchema),
});
const onSubmit: SubmitHandler<SignupFormInputs> = 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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<Label htmlFor="displayName">Display Name</Label>
<div className="relative mt-1">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="displayName"
type="text"
autoComplete="name"
{...register('displayName')}
className="pl-10"
placeholder="Ash Ketchum"
/>
</div>
{errors.displayName && <p className="mt-1 text-sm text-destructive">{errors.displayName.message}</p>}
</div>
<div>
<Label htmlFor="email">Email</Label>
<div className="relative mt-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
autoComplete="email"
{...register('email')}
className="pl-10"
placeholder="you@example.com"
/>
</div>
{errors.email && <p className="mt-1 text-sm text-destructive">{errors.email.message}</p>}
</div>
<div>
<Label htmlFor="password">Password</Label>
<div className="relative mt-1">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
autoComplete="new-password"
{...register('password')}
className="pl-10"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Create Account
</Button>
</form>
);
}
+124
View File
@@ -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<typeof cardSchema>;
interface CardFormProps {
initialData?: Partial<PokemonCard> | (ScannedCardData & { imageDataUrl?: string });
imageDataUrlFromScan?: string; // Specifically for new cards from scan
onSubmit: (data: CardFormInputs) => Promise<void>;
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<CardFormInputs>({
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<CardFormInputs> = async (data) => {
await handleFormSubmit(data);
};
return (
<Card className="w-full max-w-lg mx-auto shadow-lg">
<CardHeader>
<CardTitle className="font-headline">{formTitle}</CardTitle>
<CardDescription>{formDescription}</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{currentImageDataUrl && (
<div className="mb-4 rounded-md overflow-hidden border border-border aspect-[63/88] max-w-xs mx-auto bg-muted">
<Image
src={currentImageDataUrl}
alt={watch('name') || 'Pokémon Card'}
width={300}
height={420}
className="object-contain w-full h-full"
data-ai-hint="pokemon card"
/>
</div>
)}
{/* Hidden input for imageDataUrl, as it's typically set by scanner or pre-loaded */}
<input type="hidden" {...register('imageDataUrl')} />
{errors.imageDataUrl && <p className="text-sm text-destructive">{errors.imageDataUrl.message}</p>}
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register('name')} placeholder="e.g., Charizard" />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div>
<Label htmlFor="set">Set</Label>
<Input id="set" {...register('set')} placeholder="e.g., Base Set" />
{errors.set && <p className="text-sm text-destructive">{errors.set.message}</p>}
</div>
<div>
<Label htmlFor="rarity">Rarity</Label>
<Input id="rarity" {...register('rarity')} placeholder="e.g., Holo Rare" />
{errors.rarity && <p className="text-sm text-destructive">{errors.rarity.message}</p>}
</div>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{submitButtonText}
</Button>
</CardFooter>
</form>
</Card>
);
}
+82
View File
@@ -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 className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold truncate font-headline" title={card.name}>{card.name}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Set: {card.set} | Rarity: {card.rarity}
</CardDescription>
</CardHeader>
<CardContent className="p-4 flex-grow flex justify-center items-center bg-muted/30">
{card.imageDataUrl ? (
<Image
src={card.imageDataUrl}
alt={card.name}
width={200}
height={280}
className="object-contain rounded-md aspect-[63/88] max-w-[150px]"
data-ai-hint="pokemon card"
/>
) : (
<div className="w-[200px] h-[280px] bg-gray-200 flex items-center justify-center rounded-md text-muted-foreground aspect-[63/88]">
No Image
</div>
)}
</CardContent>
<CardFooter className="p-4 bg-card border-t flex justify-end space-x-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/collection/${card.id}/edit`}>
<Pencil className="h-3 w-3 mr-1" /> Edit
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isDeleting}>
<Trash2 className="h-3 w-3 mr-1" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the card &quot;{card.name}&quot; from your collection.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(card.id)} className="bg-destructive hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
);
}
+204
View File
@@ -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<ScanPokemonCardOutput | null>(null);
const [error, setError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const { user } = useAuth();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-8 max-w-2xl mx-auto">
<Card className="shadow-xl">
<CardHeader>
<CardTitle className="text-2xl font-headline">Scan Pokémon Card</CardTitle>
<CardDescription>Upload an image of your Pokémon card to identify its details.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<Label htmlFor="card-image" className="text-base">Upload Card Image</Label>
<Input
id="card-image"
type="file"
accept="image/*"
onChange={handleImageChange}
ref={fileInputRef}
className="mt-2 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
/>
</div>
{imagePreview && (
<div className="mt-4 p-4 border border-dashed border-border rounded-lg bg-muted/50">
<h3 className="text-lg font-medium mb-2 text-center">Image Preview</h3>
<Image
src={imagePreview}
alt="Card preview"
width={300}
height={420}
className="rounded-md object-contain mx-auto shadow-md aspect-[63/88] max-w-xs"
data-ai-hint="pokemon card"
/>
</div>
)}
{imageDataUrl && (
<Button onClick={handleScan} disabled={isScanning || !imageDataUrl} className="w-full mt-4 text-lg py-6">
{isScanning ? (
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
) : (
<Upload className="mr-2 h-6 w-6" />
)}
{isScanning ? 'Scanning...' : 'Scan Card'}
</Button>
)}
</CardContent>
</Card>
{error && !showForm && (
<Card className="border-destructive bg-destructive/10 shadow-lg">
<CardHeader className="flex flex-row items-center space-x-3">
<AlertCircle className="h-6 w-6 text-destructive" />
<CardTitle className="text-destructive">Scan Failed</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive-foreground">{error}</p>
</CardContent>
</Card>
)}
{showForm && scanResult?.cardDetails && imageDataUrl && (
<CardForm
initialData={{
name: scanResult.cardDetails.name || '',
set: scanResult.cardDetails.set || '',
rarity: scanResult.cardDetails.rarity || '',
}}
imageDataUrlFromScan={imageDataUrl}
onSubmit={handleSaveCard}
isSubmitting={isSubmitting}
submitButtonText="Add to Collection"
formTitle="Confirm Card Details"
formDescription="Review the scanned details and save the card to your collection."
onCancel={handleCancelForm}
/>
)}
</div>
);
}
+111
View File
@@ -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 (
<nav className="bg-card border-b border-border shadow-sm">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<Link href={user ? "/dashboard" : "/"} className="text-2xl font-headline font-bold text-primary hover:opacity-80 transition-opacity">
Cardex
</Link>
<div className="flex items-center space-x-4">
{!loading && user && (
<>
<Button variant="ghost" asChild>
<Link href="/dashboard/scan">
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
</Link>
</Button>
<Button variant="ghost" asChild>
<Link href="/dashboard/collection">
<BookOpen className="mr-2 h-4 w-4" /> My Collection
</Link>
</Button>
</>
)}
{!loading && (
user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.photoURL || undefined} alt={user.displayName || user.email || 'User'} />
<AvatarFallback>{getInitials(user.displayName || user.email)}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.displayName || 'User'}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button asChild>
<Link href="/login">
<LogIn className="mr-2 h-4 w-4" /> Login
</Link>
</Button>
)
)}
</div>
</div>
</div>
</nav>
);
}
+40
View File
@@ -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<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
setLoading(false);
});
return () => unsubscribe();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
}
@@ -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}</>;
}
+16
View File
@@ -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;
+12
View File
@@ -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;
};
+11
View File
@@ -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 };
+101
View File
@@ -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<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
): Promise<string> => {
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<PokemonCard[]> => {
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<PokemonCard, 'id'>),
}));
} 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<PokemonCard | null> => {
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<Omit<PokemonCard, 'id' | 'userId' | 'createdAt'>>
): Promise<void> => {
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<void> => {
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.');
}
};
+19
View File
@@ -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;
}
+9 -2
View File
@@ -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: {