Deploy #1
							
								
								
									
										2383
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2383
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										56
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								package.json
									
									
									
									
									
								
							| @@ -11,43 +11,51 @@ | |||||||
|     "lint:fix": "next lint --fix" |     "lint:fix": "next lint --fix" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@aws-sdk/client-s3": "^3.693.0", |     "@aws-sdk/client-s3": "^3.712.0", | ||||||
|     "@heroicons/react": "^2.1.5", |     "@heroicons/react": "^2.2.0", | ||||||
|     "@mdx-js/loader": "^3.0.1", |     "@mdx-js/loader": "^3.1.0", | ||||||
|     "@mdx-js/react": "^3.0.1", |     "@mdx-js/react": "^3.1.0", | ||||||
|     "@next/bundle-analyzer": "^15.0.3", |     "@next/bundle-analyzer": "^15.1.0", | ||||||
|     "@next/mdx": "^15.0.3", |     "@next/mdx": "^15.1.0", | ||||||
|     "@tailwindcss/typography": "^0.5.15", |     "@tailwindcss/typography": "^0.5.15", | ||||||
|     "@types/better-sqlite3": "^7.6.11", |     "@tanstack/react-query": "^5.62.7", | ||||||
|  |     "@tanstack/react-virtual": "^3.11.1", | ||||||
|  |     "@trpc/client": "^11.0.0-rc.660", | ||||||
|  |     "@trpc/react-query": "^11.0.0-rc.660", | ||||||
|  |     "@trpc/server": "^11.0.0-rc.660", | ||||||
|  |     "@types/better-sqlite3": "^7.6.12", | ||||||
|     "@types/mdx": "^2.0.13", |     "@types/mdx": "^2.0.13", | ||||||
|     "@types/node": "^22.6.1", |     "@types/node": "^22.10.2", | ||||||
|     "@types/react": "^18.3.9", |     "@types/react": "^19.0.1", | ||||||
|     "@types/react-dom": "^18.3.0", |     "@types/react-dom": "^19.0.2", | ||||||
|     "@typescript-eslint/eslint-plugin": "^8.14.0", |     "@typescript-eslint/eslint-plugin": "^8.18.0", | ||||||
|     "autoprefixer": "^10.4.20", |     "autoprefixer": "^10.4.20", | ||||||
|     "babel-plugin-react-compiler": "^19.0.0-beta-a7bf2bd-20241110", |     "babel-plugin-react-compiler": "beta", | ||||||
|     "better-sqlite3": "^11.5.0", |     "better-sqlite3": "^11.7.0", | ||||||
|     "drizzle-kit": "^0.28.1", |     "client-only": "^0.0.1", | ||||||
|     "drizzle-orm": "^0.36.3", |     "drizzle-kit": "^0.30.1", | ||||||
|     "eslint": "^9.15.0", |     "drizzle-orm": "^0.38.2", | ||||||
|     "eslint-config-next": "^15.0.4-canary.15", |     "eslint": "^9.17.0", | ||||||
|  |     "eslint-config-next": "^15.1.0", | ||||||
|     "exif-reader": "^2.0.1", |     "exif-reader": "^2.0.1", | ||||||
|     "framer-motion": "^11.11.17", |     "framer-motion": "^11.14.4", | ||||||
|     "glob": "^11.0.0", |     "glob": "^11.0.0", | ||||||
|     "million": "^3.1.11", |     "million": "^3.1.11", | ||||||
|     "next": "15.0.4-canary.15", |     "next": "15.1.1-canary.5", | ||||||
|     "next-auth": "5.0.0-beta.25", |     "next-auth": "5.0.0-beta.25", | ||||||
|     "postcss": "^8.4.49", |     "postcss": "^8.4.49", | ||||||
|     "radash": "^12.1.0", |     "radash": "^12.1.0", | ||||||
|     "react": "19.0.0-rc-e1ef8c95-20241115", |     "react": "19.0.0", | ||||||
|     "react-dom": "19.0.0-rc-e1ef8c95-20241115", |     "react-dom": "19.0.0", | ||||||
|     "react-zoom-pan-pinch": "^3.6.1", |     "react-zoom-pan-pinch": "^3.6.1", | ||||||
|     "reflect-metadata": "^0.2.2", |     "reflect-metadata": "^0.2.2", | ||||||
|     "server-only": "^0.0.1", |     "server-only": "^0.0.1", | ||||||
|     "sharp": "^0.33.5", |     "sharp": "^0.33.5", | ||||||
|  |     "superjson": "^2.2.2", | ||||||
|     "tailwind-scrollbar": "^3.1.0", |     "tailwind-scrollbar": "^3.1.0", | ||||||
|     "tailwindcss": "^3.4.15", |     "tailwindcss": "^3.4.16", | ||||||
|     "typescript": "^5.6.2", |     "typescript": "^5.7.2", | ||||||
|     "yet-another-react-lightbox": "^3.21.7" |     "yet-another-react-lightbox": "^3.21.7", | ||||||
|  |     "zod": "^3.24.1" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,19 +1,16 @@ | |||||||
| import Image from "next/image"; | import Image from "next/image"; | ||||||
| import FilteredLightbox from "@/components/lightbox"; | import FilteredLightbox from "@/components/lightbox"; | ||||||
| import { type GetPhotos } from "@/app/api/photos/route"; | import { trpc } from "@/trpc/server"; | ||||||
|  | import { TRPCProvider } from "@/trpc/client"; | ||||||
| async function getImageData(): Promise<GetPhotos> { |  | ||||||
|   const res = await fetch(`http://localhost:3000/api/photos`, { next: { revalidate: false, tags: ['photos'] } }); |  | ||||||
|   return res.json() as Promise<GetPhotos>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default async function Photos(): Promise<React.JSX.Element> { | export default async function Photos(): Promise<React.JSX.Element> { | ||||||
|   const {data: imageData} = await getImageData(); |   const { data: images } = await trpc.photos.list(); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="mx-auto"> |     <div className="mx-auto"> | ||||||
|       <FilteredLightbox imageData={imageData.images}> |       <TRPCProvider> | ||||||
|         {imageData.images.map((image) => ( |         <FilteredLightbox imageData={images}> | ||||||
|  |           {images.map((image) => ( | ||||||
|             <Image |             <Image | ||||||
|               key={image.src} |               key={image.src} | ||||||
|               alt={image.src} |               alt={image.src} | ||||||
| @@ -28,6 +25,7 @@ export default async function Photos(): Promise<React.JSX.Element> { | |||||||
|             /> |             /> | ||||||
|           ))} |           ))} | ||||||
|         </FilteredLightbox> |         </FilteredLightbox> | ||||||
|  |       </TRPCProvider> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,24 +1,32 @@ | |||||||
| import { glob } from "glob"; | import { glob } from "glob"; | ||||||
| import dynamic from "next/dynamic"; | import dynamic, { LoaderComponent } from "next/dynamic"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| export const dynamicParams = false; | export const dynamicParams = false; | ||||||
|  |  | ||||||
| export async function generateStaticParams(): Promise<{slug: string[]}[]> { | export async function generateStaticParams(): Promise<{ slug: string[] }[]> { | ||||||
|   const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, { |   const posts = await glob( | ||||||
|  |     `${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, | ||||||
|  |     { | ||||||
|       nodir: true, |       nodir: true, | ||||||
|   }); |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const slugs = posts.map((post) => ({ |   const slugs = posts.map((post) => ({ | ||||||
|     slug: [post.split('/').at(-1)!.slice(0, -4)] |     slug: [post.split("/").at(-1)!.slice(0, -4)], | ||||||
|   })); |   })); | ||||||
|  |  | ||||||
|   return slugs; |   return slugs; | ||||||
| } | } | ||||||
|  |  | ||||||
| export default async function Post({params}: {params: Promise<{ slug: string[] }>}): Promise<React.JSX.Element> { | export default async function Post({ | ||||||
|   const mdxFile = await import(`../../../../markdown/posts/[...slug]/${(await params).slug.join('/')}.mdx`); |   params, | ||||||
|  | }: { | ||||||
|  |   params: Promise<{ slug: string[] }>; | ||||||
|  | }): Promise<React.JSX.Element> { | ||||||
|  |   const mdxFile = await import( | ||||||
|  |     `../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx` | ||||||
|  |   ) as LoaderComponent<unknown>; | ||||||
|   const Post = dynamic(() => mdxFile); |   const Post = dynamic(() => mdxFile); | ||||||
|   return ( |   return <Post />; | ||||||
|     <Post/> |  | ||||||
|   ); |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,27 +4,32 @@ import { unstable_cache } from "next/cache"; | |||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
|  |  | ||||||
| type postDetails = { | type postDetails = { | ||||||
|   link: string, |   link: string; | ||||||
|   metadata: { |   metadata: { | ||||||
|     title: string, |     title: string; | ||||||
|     date: string, |     date: string; | ||||||
|     coverImage: string, |     coverImage: string; | ||||||
|     blurb: string, |     blurb: string; | ||||||
|     shortBlurb: string, |     shortBlurb: string; | ||||||
|     tags: string[] |     tags: string[]; | ||||||
|   } |   }; | ||||||
| } | }; | ||||||
|  |  | ||||||
| async function loadPostDetails(): Promise<postDetails[]> { | async function loadPostDetails(): Promise<postDetails[]> { | ||||||
|   const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, { |   const posts = await glob( | ||||||
|  |     `${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, | ||||||
|  |     { | ||||||
|       nodir: true, |       nodir: true, | ||||||
|   }); |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const loadPostData = posts.map(async (post) => { |   const loadPostData = posts.map(async (post) => { | ||||||
|     const slug = [post.split('/').at(-1)!.slice(0, -4)]; |     const slug = [post.split("/").at(-1)!.slice(0, -4)]; | ||||||
|     const mdxFile = await import(`../../../../src/markdown/posts/[...slug]/${slug.join('/')}.mdx`); |     const mdxFile = await import( | ||||||
|  |       `../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx` | ||||||
|  |     ) as postDetails; | ||||||
|     return { |     return { | ||||||
|       link: getCurrentUrl() + '/posts/' + slug.join('/'), |       link: getCurrentUrl() + "/posts/" + slug.join("/"), | ||||||
|       metadata: mdxFile.metadata, |       metadata: mdxFile.metadata, | ||||||
|     }; |     }; | ||||||
|   }); |   }); | ||||||
| @@ -33,13 +38,9 @@ async function loadPostDetails(): Promise<postDetails[]> { | |||||||
|   return postData; |   return postData; | ||||||
| } | } | ||||||
|  |  | ||||||
| const getPosts = unstable_cache( | const getPosts = unstable_cache(loadPostDetails, ["posts"], { | ||||||
|   loadPostDetails, |   revalidate: false, | ||||||
|   ['posts'], | }); | ||||||
|   { |  | ||||||
|     revalidate: false |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default async function Posts(): Promise<React.JSX.Element> { | export default async function Posts(): Promise<React.JSX.Element> { | ||||||
|   const postDetails = await getPosts(); |   const postDetails = await getPosts(); | ||||||
| @@ -56,14 +57,14 @@ export default async function Posts(): Promise<React.JSX.Element> { | |||||||
|                 {post.metadata.tags.map((tag) => { |                 {post.metadata.tags.map((tag) => { | ||||||
|                   return ( |                   return ( | ||||||
|                     <div key={`${post.link}_${tag}`}> |                     <div key={`${post.link}_${tag}`}> | ||||||
|                       <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span> |                       <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink"> | ||||||
|  |                         {tag} | ||||||
|  |                       </span> | ||||||
|                     </div> |                     </div> | ||||||
|                   ); |                   ); | ||||||
|                 })} |                 })} | ||||||
|               </div> |               </div> | ||||||
|               <p> |               <p>{post.metadata.blurb}</p> | ||||||
|                 {post.metadata.blurb} |  | ||||||
|               </p> |  | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         ); |         ); | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								src/app/api/trpc/[trpc]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/api/trpc/[trpc]/route.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; | ||||||
|  | import { createTRPCContext } from '@/trpc/init'; | ||||||
|  | import { appRouter } from '@/trpc/routers/_app'; | ||||||
|  | const handler = (req: Request): Promise<Response> => | ||||||
|  |   fetchRequestHandler({ | ||||||
|  |     endpoint: '/api/trpc', | ||||||
|  |     req, | ||||||
|  |     router: appRouter, | ||||||
|  |     createContext: createTRPCContext, | ||||||
|  |   }); | ||||||
|  | export { handler as GET, handler as POST }; | ||||||
| @@ -15,9 +15,22 @@ import "yet-another-react-lightbox/styles.css"; | |||||||
| import "yet-another-react-lightbox/plugins/thumbnails.css"; | import "yet-another-react-lightbox/plugins/thumbnails.css"; | ||||||
| import "yet-another-react-lightbox/plugins/captions.css"; | import "yet-another-react-lightbox/plugins/captions.css"; | ||||||
|  |  | ||||||
| import { type ImageData } from "@/app/api/photos/route"; | import type { RouterOutput } from "@/trpc/routers/_app"; | ||||||
|  | import { trpc } from "@/trpc/client"; | ||||||
|  |  | ||||||
| function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: ImageData, offset: number, rect: {width: number, height: number}, unoptimized: boolean}): React.JSX.Element { | type ImageData = RouterOutput["photos"]["list"]["data"][number]; | ||||||
|  |  | ||||||
|  | function NextJsImage({ | ||||||
|  |   slide, | ||||||
|  |   offset, | ||||||
|  |   rect, | ||||||
|  |   unoptimized = false, | ||||||
|  | }: { | ||||||
|  |   slide: ImageData; | ||||||
|  |   offset: number; | ||||||
|  |   rect: { width: number; height: number }; | ||||||
|  |   unoptimized: boolean; | ||||||
|  | }): React.JSX.Element { | ||||||
|   const { |   const { | ||||||
|     on: { click }, |     on: { click }, | ||||||
|     carousel: { imageFit }, |     carousel: { imageFit }, | ||||||
| @@ -29,13 +42,13 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image | |||||||
|  |  | ||||||
|   const width = !cover |   const width = !cover | ||||||
|     ? Math.round( |     ? Math.round( | ||||||
|       Math.min(rect.width, (rect.height / slide.height) * slide.width), |       Math.min(rect.width, (rect.height / slide.height) * slide.width) | ||||||
|     ) |     ) | ||||||
|     : rect.width; |     : rect.width; | ||||||
|  |  | ||||||
|   const height = !cover |   const height = !cover | ||||||
|     ? Math.round( |     ? Math.round( | ||||||
|       Math.min(rect.height, (rect.width / slide.width) * slide.height), |       Math.min(rect.height, (rect.width / slide.width) * slide.height) | ||||||
|     ) |     ) | ||||||
|     : rect.height; |     : rect.height; | ||||||
|  |  | ||||||
| @@ -56,14 +69,22 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image | |||||||
|         }} |         }} | ||||||
|         sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`} |         sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`} | ||||||
|         onClick={ |         onClick={ | ||||||
|           offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined |           offset === 0 | ||||||
|  |             ? (): void => click?.({ index: currentIndex }) | ||||||
|  |             : undefined | ||||||
|         } |         } | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function Lightbox({imageData, children}: {imageData: ImageData[], children: React.JSX.Element[]}): React.JSX.Element { | export function Lightbox({ | ||||||
|  |   imageData, | ||||||
|  |   children, | ||||||
|  | }: { | ||||||
|  |   imageData: ImageData[]; | ||||||
|  |   children: React.JSX.Element[]; | ||||||
|  | }): React.JSX.Element { | ||||||
|   const [active, setActive] = useState<number | null>(null); |   const [active, setActive] = useState<number | null>(null); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -71,49 +92,98 @@ export function Lightbox({imageData, children}: {imageData: ImageData[], childre | |||||||
|       <div className="flex flex-row flex-wrap justify-center"> |       <div className="flex flex-row flex-wrap justify-center"> | ||||||
|         {children.map((image, index) => { |         {children.map((image, index) => { | ||||||
|           return ( |           return ( | ||||||
|             <button key={`lightbox_img_${index}`} onClick={(() => { |             <button | ||||||
|  |               key={`lightbox_img_${index}`} | ||||||
|  |               onClick={() => { | ||||||
|                 setActive(index); |                 setActive(index); | ||||||
|             })}> |               }} | ||||||
|               <div className="relative"> |             > | ||||||
|                 {image} |               <div className="relative">{image}</div> | ||||||
|               </div> |  | ||||||
|             </button> |             </button> | ||||||
|           ); } |           ); | ||||||
|         )} |         })} | ||||||
|       </div> |       </div> | ||||||
|       <YARL |       <YARL | ||||||
|         open={typeof active === 'number'} |         open={typeof active === "number"} | ||||||
|         close={() => setActive(null)} |         close={() => setActive(null)} | ||||||
|         index={active ?? undefined} |         index={active ?? undefined} | ||||||
|         slides={imageData} |         slides={imageData} | ||||||
|  |         render={{ | ||||||
|  |           // @ts-expect-error - Todo | ||||||
|  |           slide: (args) => NextJsImage({ ...args, unoptimized: true }), | ||||||
|           // @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type |           // @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type | ||||||
|         render={{ slide: (args) => NextJsImage({...args, unoptimized: true }), thumbnail: NextJsImage }} |           thumbnail: NextJsImage, | ||||||
|  |         }} | ||||||
|         plugins={[Thumbnails, Zoom, Captions]} |         plugins={[Thumbnails, Zoom, Captions]} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| interface FormElements extends HTMLFormControlsCollection { | interface FormElements extends HTMLFormControlsCollection { | ||||||
|   src: HTMLInputElement |   src: HTMLInputElement; | ||||||
| } | } | ||||||
| interface UsernameFormElement extends HTMLFormElement { | interface UsernameFormElement extends HTMLFormElement { | ||||||
|   readonly elements: FormElements |   readonly elements: FormElements; | ||||||
| } | } | ||||||
|  |  | ||||||
| export default function FilteredLightbox(props: {imageData: ImageData[], children: React.JSX.Element[]}): React.JSX.Element { | export default function FilteredLightbox(props: { | ||||||
|   const [imageData, setImageData] = useState(props.imageData); |   imageData: ImageData[]; | ||||||
|  |   children: React.JSX.Element[]; | ||||||
|  | }): React.JSX.Element { | ||||||
|  |   //const [imageData, setImageData] = useState(props.imageData); | ||||||
|  |   const [imageData] = useState(props.imageData); | ||||||
|  |   const photoQuery = trpc.photos.list.useInfiniteQuery( | ||||||
|  |     { | ||||||
|  |       limit: 1, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       initialData: { | ||||||
|  |         pages: [ | ||||||
|  |           { | ||||||
|  |             data: props.imageData, | ||||||
|  |             next: props.imageData.length, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |         pageParams: [0], | ||||||
|  |       }, | ||||||
|  |       getNextPageParam: (lastPage) => lastPage.next, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const refreshQuery = trpc.photos.update.useQuery(undefined, { | ||||||
|  |     enabled: false, | ||||||
|  |     retry: false, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   function handleSubmit(event: React.FormEvent<UsernameFormElement>): void { |   function handleSubmit(event: React.FormEvent<UsernameFormElement>): void { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     const imageData = props.imageData; |     // const imageData = props.imageData; | ||||||
|     setImageData(imageData.filter((data) => data.src === event.currentTarget.elements.src.value)); |     // setImageData( | ||||||
|  |     //   imageData.filter( | ||||||
|  |     //     (data) => data.src === event.currentTarget.elements.src.value | ||||||
|  |     //   ) | ||||||
|  |     // ); | ||||||
|  |     void photoQuery.fetchNextPage(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const children = imageData.map((data) => props.children.find((child) => { |   const children = photoQuery.data.pages | ||||||
|     return data.src === child.key ? child : null; |     .flatMap((data) => data.data) | ||||||
|   })).filter(((data) => !!data)); |     .map((data) => ( | ||||||
|  |       <Image | ||||||
|  |         key={data.src} | ||||||
|  |         alt={data.src} | ||||||
|  |         src={data.src} | ||||||
|  |         className="object-contain h-60 w-80" | ||||||
|  |         sizes="100vw" | ||||||
|  |         loading="lazy" | ||||||
|  |         width={data.width} | ||||||
|  |         height={data.height} | ||||||
|  |         blurDataURL={data.blur} | ||||||
|  |         placeholder="blur" | ||||||
|  |       /> | ||||||
|  |     )) | ||||||
|  |     .filter((data) => !!data); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
| @@ -124,9 +194,16 @@ export default function FilteredLightbox(props: {imageData: ImageData[], childre | |||||||
|         </div> |         </div> | ||||||
|         <button type="submit">Submit</button> |         <button type="submit">Submit</button> | ||||||
|       </form> |       </form> | ||||||
|       <Lightbox imageData={imageData}> |       <button | ||||||
|         {...children} |         onClick={() => { | ||||||
|       </Lightbox> |           void refreshQuery.refetch(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         Refresh | ||||||
|  |       </button> | ||||||
|  |       {refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"} | ||||||
|  |       {refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"} | ||||||
|  |       <Lightbox imageData={imageData}>{...children}</Lightbox> | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| @@ -1,21 +1,33 @@ | |||||||
| 'use client'; | "use client"; | ||||||
| import { useMemo, useState } from 'react'; | import { useMemo, useState } from "react"; | ||||||
| import Link from 'next/link'; | import Link from "next/link"; | ||||||
| import { HomeModernIcon, Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; | import { | ||||||
| import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion"; |   HomeModernIcon, | ||||||
| import { usePathname } from 'next/navigation'; |   Bars3Icon, | ||||||
| import ThemeSwitcher from './theme-switcher'; |   XMarkIcon, | ||||||
|  | } from "@heroicons/react/24/outline"; | ||||||
|  | import { | ||||||
|  |   AnimatePresence, | ||||||
|  |   motion, | ||||||
|  |   LazyMotion, | ||||||
|  |   domAnimation, | ||||||
|  | } from "framer-motion"; | ||||||
|  | import { usePathname } from "next/navigation"; | ||||||
|  | import ThemeSwitcher from "./theme-switcher"; | ||||||
|  |  | ||||||
| type NavBarClientProps = { | type NavBarClientProps = { | ||||||
|   LogIn: React.JSX.Element, |   LogIn: React.JSX.Element; | ||||||
|   navigation: { |   navigation: { | ||||||
|     name: string; |     name: string; | ||||||
|     href: string; |     href: string; | ||||||
|     current: boolean; |     current: boolean; | ||||||
|   }[] |   }[]; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export default function NavBarClient({LogIn, navigation}: NavBarClientProps): React.JSX.Element { | export default function NavBarClient({ | ||||||
|  |   LogIn, | ||||||
|  |   navigation, | ||||||
|  | }: NavBarClientProps): React.JSX.Element { | ||||||
|   const [open, setOpen] = useState(false); |   const [open, setOpen] = useState(false); | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|  |  | ||||||
| @@ -35,61 +47,67 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re | |||||||
|         <div className="mx-auto max-w-7xl px-4"> |         <div className="mx-auto max-w-7xl px-4"> | ||||||
|           <div className="relative flex h-16 items-center justify-between"> |           <div className="relative flex h-16 items-center justify-between"> | ||||||
|             <div className="flex"> |             <div className="flex"> | ||||||
|               <button className='sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1' onClick={() => setOpen(!open)}> |               <button | ||||||
|  |                 className="sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1" | ||||||
|  |                 onClick={() => setOpen(!open)} | ||||||
|  |               > | ||||||
|                 {open ? ( |                 {open ? ( | ||||||
|                   <XMarkIcon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/> |                   <XMarkIcon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" /> | ||||||
|                 ) : ( |                 ) : ( | ||||||
|                   <Bars3Icon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/> |                   <Bars3Icon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" /> | ||||||
|                 )} |                 )} | ||||||
|               </button> |               </button> | ||||||
|               <Link className='hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors' href='/'> |               <Link | ||||||
|                 <HomeModernIcon className='dark:stroke-dracula-cyan rounded-sm h-8 w-auto'/> |                 className="hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors" | ||||||
|  |                 href="/" | ||||||
|  |               > | ||||||
|  |                 <HomeModernIcon className="dark:stroke-dracula-cyan rounded-sm h-8 w-auto" /> | ||||||
|               </Link> |               </Link> | ||||||
|               <div className='space-x-5 hidden sm:flex ml-10'> |               <div className="space-x-5 hidden sm:flex ml-10"> | ||||||
|                 {activeNavigation.map((item) => ( |                 {activeNavigation.map((item) => ( | ||||||
|                   <Link |                   <Link | ||||||
|                     key={item.name} |                     key={item.name} | ||||||
|                     href={item.href} |                     href={item.href} | ||||||
|                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${ |                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${ | ||||||
|                       item.current ? 'dark:border-b-dracula-pink' : '' |                       item.current ? "dark:border-b-dracula-pink" : "" | ||||||
|                     }`} |                     }`} | ||||||
|                     aria-current={item.current ? 'page' : undefined} |                     aria-current={item.current ? "page" : undefined} | ||||||
|                   > |                   > | ||||||
|                     {item.name} |                     {item.name} | ||||||
|                   </Link> |                   </Link> | ||||||
|                 ))} |                 ))} | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|             <div className='space-x-4 flex'> |             <div className="space-x-4 flex"> | ||||||
|               <ThemeSwitcher/> |               <ThemeSwitcher /> | ||||||
|               {LogIn} |               {LogIn} | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <AnimatePresence> |         <AnimatePresence> | ||||||
|           { open ? ( |           {open ? ( | ||||||
|             <m.div |             <motion.div | ||||||
|               initial={{ height: 0 }} |               initial={{ height: 0 }} | ||||||
|               animate={{ height: "auto" }} |               animate={{ height: "auto" }} | ||||||
|               transition={{ duration: 0.15, ease: 'linear' }} |               transition={{ duration: 0.15, ease: "linear" }} | ||||||
|               exit={{ height: 0 }} |               exit={{ height: 0 }} | ||||||
|               className='sm:hidden overflow-hidden' |               className="sm:hidden overflow-hidden" | ||||||
|             > |             > | ||||||
|               <div className='flex flex-col space-y-1 py-1'> |               <div className="flex flex-col space-y-1 py-1"> | ||||||
|                 {activeNavigation.map((item) => ( |                 {activeNavigation.map((item) => ( | ||||||
|                   <Link |                   <Link | ||||||
|                     key={item.name} |                     key={item.name} | ||||||
|                     href={item.href} |                     href={item.href} | ||||||
|                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${ |                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${ | ||||||
|                       item.current ? 'dark:border-l-dracula-pink' : '' |                       item.current ? "dark:border-l-dracula-pink" : "" | ||||||
|                     }`} |                     }`} | ||||||
|                     aria-current={item.current ? 'page' : undefined} |                     aria-current={item.current ? "page" : undefined} | ||||||
|                   > |                   > | ||||||
|                     {item.name} |                     {item.name} | ||||||
|                   </Link> |                   </Link> | ||||||
|                 ))} |                 ))} | ||||||
|               </div> |               </div> | ||||||
|             </m.div> |             </motion.div> | ||||||
|           ) : null} |           ) : null} | ||||||
|         </AnimatePresence> |         </AnimatePresence> | ||||||
|       </LazyMotion> |       </LazyMotion> | ||||||
|   | |||||||
							
								
								
									
										60
									
								
								src/trpc/client.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/trpc/client.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | "use client"; | ||||||
|  |  | ||||||
|  | import React, { useState } from "react"; | ||||||
|  | import superjson from "superjson"; | ||||||
|  | import { httpBatchLink } from "@trpc/client"; | ||||||
|  | import { createTRPCReact } from "@trpc/react-query"; | ||||||
|  | import { getCurrentUrl } from "@/lib/current-url"; | ||||||
|  | import { makeQueryClient } from "./query-client"; | ||||||
|  |  | ||||||
|  | import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; | ||||||
|  | import type { appRouter } from "./routers/_app"; | ||||||
|  |  | ||||||
|  | export const trpc = createTRPCReact<typeof appRouter>(); | ||||||
|  |  | ||||||
|  | let clientQueryClientSingleton: QueryClient; | ||||||
|  |  | ||||||
|  | function getQueryClient(): QueryClient { | ||||||
|  |   if (typeof window === "undefined") { | ||||||
|  |     // Server: always make a new query client | ||||||
|  |     return makeQueryClient(); | ||||||
|  |   } | ||||||
|  |   // Browser: use singleton pattern to keep the same query client | ||||||
|  |   return (clientQueryClientSingleton ??= makeQueryClient()); | ||||||
|  | } | ||||||
|  | function getUrl(): string { | ||||||
|  |   const base = ((): string => { | ||||||
|  |     if (typeof window !== "undefined") return ""; | ||||||
|  |     return getCurrentUrl(); | ||||||
|  |   })(); | ||||||
|  |   return `${base}/api/trpc`; | ||||||
|  | } | ||||||
|  | export function TRPCProvider( | ||||||
|  |   props: Readonly<{ | ||||||
|  |     children: React.ReactNode; | ||||||
|  |   }> | ||||||
|  | ): React.JSX.Element { | ||||||
|  |   // NOTE: Avoid useState when initializing the query client if you don't | ||||||
|  |   //       have a suspense boundary between this and the code that may | ||||||
|  |   //       suspend because React will throw away the client on the initial | ||||||
|  |   //       render if it suspends and there is no boundary | ||||||
|  |   const queryClient = getQueryClient(); | ||||||
|  |   const [trpcClient] = useState(() => | ||||||
|  |     trpc.createClient({ | ||||||
|  |       links: [ | ||||||
|  |         httpBatchLink({ | ||||||
|  |           transformer: superjson, | ||||||
|  |           url: getUrl(), | ||||||
|  |         }), | ||||||
|  |       ] | ||||||
|  |     }) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <trpc.Provider client={trpcClient} queryClient={queryClient}> | ||||||
|  |       <QueryClientProvider client={queryClient}> | ||||||
|  |         {props.children} | ||||||
|  |       </QueryClientProvider> | ||||||
|  |     </trpc.Provider> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								src/trpc/init.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/trpc/init.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | import { cache } from 'react'; | ||||||
|  | import superjson from 'superjson'; | ||||||
|  | import { initTRPC, TRPCError } from '@trpc/server'; | ||||||
|  | import { auth } from '@/lib/auth'; | ||||||
|  |  | ||||||
|  | interface Context { | ||||||
|  |   user?: { | ||||||
|  |     id?: string | ||||||
|  |     name?: string | null | ||||||
|  |     email?: string | null | ||||||
|  |     image?: string | null | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const createTRPCContext = cache(async (): Promise<Context> => { | ||||||
|  |   /** | ||||||
|  |    * @see: https://trpc.io/docs/server/context | ||||||
|  |    */ | ||||||
|  |   const session = await auth(); | ||||||
|  |   return { user: session?.user }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Avoid exporting the entire t-object | ||||||
|  | // since it's not very descriptive. | ||||||
|  | // For instance, the use of a t variable | ||||||
|  | // is common in i18n libraries. | ||||||
|  | const t = initTRPC.context<Context>().create({ | ||||||
|  |   /** | ||||||
|  |    * @see https://trpc.io/docs/server/data-transformers | ||||||
|  |    */ | ||||||
|  |   transformer: superjson, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const authMiddleware = t.middleware(({ ctx, next }) => { | ||||||
|  |   if (ctx.user?.name !== 'Joe') { | ||||||
|  |     throw new TRPCError({ code: 'UNAUTHORIZED' }); | ||||||
|  |   } | ||||||
|  |   return next({ | ||||||
|  |     ctx: { | ||||||
|  |       user: ctx.user, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Base router and procedure helpers | ||||||
|  | export const createTRPCRouter = t.router; | ||||||
|  | export const createCallerFactory = t.createCallerFactory; | ||||||
|  | export const publicProcedure = t.procedure; | ||||||
|  | export const privateProcedure = t.procedure.use(authMiddleware); | ||||||
							
								
								
									
										27
									
								
								src/trpc/query-client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/trpc/query-client.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | import { | ||||||
|  |   defaultShouldDehydrateQuery, | ||||||
|  |   QueryClient, | ||||||
|  | } from '@tanstack/react-query'; | ||||||
|  | import { serialize, deserialize } from 'superjson'; | ||||||
|  |  | ||||||
|  | export function makeQueryClient(): QueryClient { | ||||||
|  |   return new QueryClient({ | ||||||
|  |     defaultOptions: { | ||||||
|  |       queries: { | ||||||
|  |         refetchOnMount: false, | ||||||
|  |         refetchOnWindowFocus: false, | ||||||
|  |         refetchOnReconnect: false, | ||||||
|  |         staleTime: 30 * 1000, | ||||||
|  |       }, | ||||||
|  |       dehydrate: { | ||||||
|  |         serializeData: serialize, | ||||||
|  |         shouldDehydrateQuery: (query) => | ||||||
|  |           defaultShouldDehydrateQuery(query) || | ||||||
|  |           query.state.status === 'pending', | ||||||
|  |       }, | ||||||
|  |       hydrate: { | ||||||
|  |         deserializeData: deserialize, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								src/trpc/routers/_app.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/trpc/routers/_app.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | // eslint-disable-next-line import/named | ||||||
|  | import { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; | ||||||
|  | import { createTRPCRouter } from '../init'; | ||||||
|  | import { photosRouter } from './photos'; | ||||||
|  |  | ||||||
|  | export const appRouter = createTRPCRouter({ | ||||||
|  |   photos: photosRouter, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export type RouterInput = inferRouterInputs<typeof appRouter>; | ||||||
|  | export type RouterOutput = inferRouterOutputs<typeof appRouter>; | ||||||
							
								
								
									
										35
									
								
								src/trpc/routers/photos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/trpc/routers/photos.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | import { z } from 'zod'; | ||||||
|  | import { createTRPCRouter, privateProcedure, publicProcedure } from '../init'; | ||||||
|  |  | ||||||
|  | import { list } from './photos/list'; | ||||||
|  | import { update } from './photos/update'; | ||||||
|  |  | ||||||
|  | export const photosRouter = createTRPCRouter({ | ||||||
|  |   list: publicProcedure | ||||||
|  |     .input( | ||||||
|  |       z.object({ | ||||||
|  |         limit: z.number().nonnegative().default(1), | ||||||
|  |         cursor: z.number().nonnegative().default(0), | ||||||
|  |       }) | ||||||
|  |         .optional() | ||||||
|  |         .default({}), | ||||||
|  |     ) | ||||||
|  |     .query(async ({ input }) => { | ||||||
|  |       const ret = await list({ | ||||||
|  |         limit: input.limit + 1, | ||||||
|  |         cursor: input.cursor, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       let next; | ||||||
|  |       if (ret.length > input.limit) { | ||||||
|  |         next = input.limit; | ||||||
|  |         ret.pop(); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         data: ret, | ||||||
|  |         next | ||||||
|  |       }; | ||||||
|  |     }), | ||||||
|  |   update: privateProcedure.query(update) | ||||||
|  | }); | ||||||
| @@ -1,4 +1,3 @@ | |||||||
| import { NextResponse } from "next/server"; |  | ||||||
| import { shake } from "radash"; | import { shake } from "radash"; | ||||||
| import db from "@/db/db"; | import db from "@/db/db"; | ||||||
| import { photosTable } from "@/db/schema/photo"; | import { photosTable } from "@/db/schema/photo"; | ||||||
| @@ -21,15 +20,16 @@ export type ImageData = { | |||||||
|   description?: string |   description?: string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export type GetPhotos = { | export type ListOptions = { | ||||||
|   status: number, |   cursor: number, | ||||||
|   data: { |   limit: number | ||||||
|     images: ImageData[] |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function GET(): Promise<Response> { | export async function list(options: ListOptions): Promise<ImageData[]> { | ||||||
|   const currentSources = await db.select().from(photosTable); |   const currentSources = await db.select().from(photosTable) | ||||||
|  |     .limit(options.limit) | ||||||
|  |     .offset(options.cursor); | ||||||
|  | 
 | ||||||
|   const images = currentSources.map((photo) => { |   const images = currentSources.map((photo) => { | ||||||
|     return { |     return { | ||||||
|       width: photo.width, |       width: photo.width, | ||||||
| @@ -50,5 +50,5 @@ export async function GET(): Promise<Response> { | |||||||
|     }; |     }; | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   return NextResponse.json<GetPhotos>({ status: 200, data: { images } }); |   return images; | ||||||
| } | } | ||||||
| @@ -1,22 +1,13 @@ | |||||||
| import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; | import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; | ||||||
| import exifReader from "exif-reader"; | import exifReader from "exif-reader"; | ||||||
| import { NextResponse } from "next/server"; |  | ||||||
| import { diff, sift } from "radash"; | import { diff, sift } from "radash"; | ||||||
| import sharp from "sharp"; | import sharp from "sharp"; | ||||||
| 
 | 
 | ||||||
| import db from "@/db/db"; | import db from "@/db/db"; | ||||||
| import { photosTable } from "@/db/schema/photo"; | import { photosTable } from "@/db/schema/photo"; | ||||||
| import { auth } from "@/lib/auth"; | import { TRPCError } from "@trpc/server"; | ||||||
| 
 | 
 | ||||||
| export type GetPhotosUpdate = { | export async function update(): Promise<string[]> { | ||||||
|   status: number, |  | ||||||
|   s3Photos: string[] |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const GET = auth(async function GET(req): Promise<Response> { |  | ||||||
|   if (!req.auth) { |  | ||||||
|     return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); |  | ||||||
|   } |  | ||||||
|   const photos = await db.select().from(photosTable); |   const photos = await db.select().from(photosTable); | ||||||
|   const currentSources = photos.map((photo) => photo.src); |   const currentSources = photos.map((photo) => photo.src); | ||||||
| 
 | 
 | ||||||
| @@ -32,7 +23,10 @@ export const GET = auth(async function GET(req): Promise<Response> { | |||||||
|   const s3Res = await s3Client.send(listObjCmd); |   const s3Res = await s3Client.send(listObjCmd); | ||||||
| 
 | 
 | ||||||
|   if (!s3Res.Contents) { |   if (!s3Res.Contents) { | ||||||
|     return NextResponse.json({ status: 500 }); |     throw new TRPCError({ | ||||||
|  |       code: "GATEWAY_TIMEOUT", | ||||||
|  |       message: "Could not get contents from Tigris" | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|   const s3Photos = sift(s3Res.Contents.map((obj) => { |   const s3Photos = sift(s3Res.Contents.map((obj) => { | ||||||
|     if (!obj.Key?.endsWith('/')) { |     if (!obj.Key?.endsWith('/')) { | ||||||
| @@ -45,7 +39,7 @@ export const GET = auth(async function GET(req): Promise<Response> { | |||||||
|   const newPhotos = diff(s3Photos, currentSources); |   const newPhotos = diff(s3Photos, currentSources); | ||||||
| 
 | 
 | ||||||
|   if (newPhotos.length === 0) { |   if (newPhotos.length === 0) { | ||||||
|     return NextResponse.json<GetPhotosUpdate>({ status: 200, s3Photos: newPhotos }); |     return []; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const imageData = newPhotos.map(async (fileName: string) => { |   const imageData = newPhotos.map(async (fileName: string) => { | ||||||
| @@ -84,5 +78,5 @@ export const GET = auth(async function GET(req): Promise<Response> { | |||||||
| 
 | 
 | ||||||
|   await db.insert(photosTable).values(images); |   await db.insert(photosTable).values(images); | ||||||
| 
 | 
 | ||||||
|   return NextResponse.json<GetPhotosUpdate>({ status: 200, s3Photos: newPhotos }); |   return newPhotos; | ||||||
| }); | }; | ||||||
							
								
								
									
										14
									
								
								src/trpc/server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/trpc/server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | import 'server-only'; // <-- ensure this file cannot be imported from the client | ||||||
|  | import { createHydrationHelpers } from '@trpc/react-query/rsc'; | ||||||
|  | import { cache } from 'react'; | ||||||
|  | import { createCallerFactory, createTRPCContext } from './init'; | ||||||
|  | import { makeQueryClient } from './query-client'; | ||||||
|  | import { appRouter } from './routers/_app'; | ||||||
|  | // IMPORTANT: Create a stable getter for the query client that | ||||||
|  | //            will return the same client during the same request. | ||||||
|  | export const getQueryClient = cache(makeQueryClient); | ||||||
|  | const caller = createCallerFactory(appRouter)(createTRPCContext); | ||||||
|  | export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>( | ||||||
|  |   caller, | ||||||
|  |   getQueryClient, | ||||||
|  | ); | ||||||
		Reference in New Issue
	
	Block a user