From ec6ba2591877549c76e0d8f210bc0baa4b2934dd Mon Sep 17 00:00:00 2001 From: Graham Vasquez Date: Tue, 19 Aug 2025 23:59:15 -0400 Subject: [PATCH 1/2] fix: missing title when forking in seed script --- api/cmd/seed/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/cmd/seed/main.go b/api/cmd/seed/main.go index e763140..7daf73e 100644 --- a/api/cmd/seed/main.go +++ b/api/cmd/seed/main.go @@ -239,6 +239,7 @@ func main() { revisions := make([]RevisionWithIngredients, 0) if forkdFrom.revision.ID.Valid { initialRevision, err := qtx.SeedRevision(ctx, db.SeedRevisionParams{ + Title: forkdFrom.revision.Title, RecipeID: forkdFrom.revision.RecipeID, RecipeDescription: forkdFrom.revision.RecipeDescription, ChangeComment: forkdFrom.revision.ChangeComment, From b2093eccc2a92a668ef02a69a47b56ea69f496eb Mon Sep 17 00:00:00 2001 From: Graham Vasquez Date: Fri, 22 Aug 2025 18:17:52 -0400 Subject: [PATCH 2/2] feat: add infinite scroll pagination for recipe list --- .graphqlrc.json | 3 +- api/services/recipe/recipe.go | 5 +++ web/app/.server/loaders/recipes.ts | 22 ++++++++++ web/app/components/recipeList/RecipeList.tsx | 44 ++++++++++++++++--- web/app/gql/forkd.g.ts | 13 ++++-- ...UserSignup.gql => CheckUserSignup.graphql} | 0 .../{ListRecipes.gql => ListRecipes.graphql} | 7 ++- .../operations/{Login.gql => Login.graphql} | 0 .../operations/{Logout.gql => Logout.graphql} | 0 ...MagicLink.gql => RequestMagicLink.graphql} | 0 .../operations/{Signup.gql => Signup.graphql} | 0 .../{queryUser.gql => queryUser.graphql} | 0 ...{recipeBySlug.gql => recipeBySlug.graphql} | 0 web/app/routes/_app._index/route.tsx | 23 ++-------- .../api.recipes.paginate.$cursor/route.ts | 3 ++ web/codegen.ts | 2 +- 16 files changed, 89 insertions(+), 33 deletions(-) create mode 100644 web/app/.server/loaders/recipes.ts rename web/app/gql/operations/{CheckUserSignup.gql => CheckUserSignup.graphql} (100%) rename web/app/gql/operations/{ListRecipes.gql => ListRecipes.graphql} (67%) rename web/app/gql/operations/{Login.gql => Login.graphql} (100%) rename web/app/gql/operations/{Logout.gql => Logout.graphql} (100%) rename web/app/gql/operations/{RequestMagicLink.gql => RequestMagicLink.graphql} (100%) rename web/app/gql/operations/{Signup.gql => Signup.graphql} (100%) rename web/app/gql/operations/{queryUser.gql => queryUser.graphql} (100%) rename web/app/gql/operations/{recipeBySlug.gql => recipeBySlug.graphql} (100%) create mode 100644 web/app/routes/api.recipes.paginate.$cursor/route.ts diff --git a/.graphqlrc.json b/.graphqlrc.json index 55ab635..fa233a1 100644 --- a/.graphqlrc.json +++ b/.graphqlrc.json @@ -1,4 +1,5 @@ { "$schema": "https://raw.githubusercontent.com/kamilkisiela/graphql-config/master/config-schema.json", - "schema": ["./api/graph/schema/*.graphql"] + "schema": ["./api/graph/schema/*.graphql"], + "documents": ["./web/app/gql/operations/*.graphql"] } diff --git a/api/services/recipe/recipe.go b/api/services/recipe/recipe.go index 051ce05..eb39c86 100644 --- a/api/services/recipe/recipe.go +++ b/api/services/recipe/recipe.go @@ -556,6 +556,8 @@ func (r recipeService) ListRecipeRevisions(ctx context.Context, input *model.Lis var NextCursor *string = nil if len(revisions) == int(params.Limit) { + // We have to do this to keep the cursor size from ballooning, since we don't care about this field anyway + input.NextCursor = nil cursor := ListRevisionsCursor{ ListRevisionsInput: *input, } @@ -708,6 +710,9 @@ func (r recipeService) ListRecipes(ctx context.Context, input *model.ListRecipeI listInput = *input } + // We have to do this to keep the cursor size from ballooning, since we don't care about this field anyway + listInput.NextCursor = nil + cursor := ListRecipesCursor{ ListRecipeInput: listInput, } diff --git a/web/app/.server/loaders/recipes.ts b/web/app/.server/loaders/recipes.ts new file mode 100644 index 0000000..38d9307 --- /dev/null +++ b/web/app/.server/loaders/recipes.ts @@ -0,0 +1,22 @@ +import { ClientError } from "graphql-request" +import { environment } from "~/.server/env" +import { getSessionOrThrow } from "~/.server/session" +import { getSDK } from "~/gql/client" +import { LoaderFunctionArgs } from "react-router" + +export async function recipesLoader(args: T) { + const session = await getSessionOrThrow(args, false) + const auth = session.get("sessionToken") + const sdk = getSDK(`${environment.BACKEND_URL}`, auth) + try { + const data = await sdk.ListRecipes({ + input: { nextCursor: args.params.cursor }, + }) + return data?.recipe?.list ?? null + } catch (err) { + if (err instanceof ClientError && err.message === "missing auth") { + return null + } + throw err + } +} diff --git a/web/app/components/recipeList/RecipeList.tsx b/web/app/components/recipeList/RecipeList.tsx index eedec30..5da8437 100644 --- a/web/app/components/recipeList/RecipeList.tsx +++ b/web/app/components/recipeList/RecipeList.tsx @@ -1,29 +1,61 @@ -import { SimpleGrid } from "@mantine/core" -import { ReactNode } from "react" +import { Center, Loader, Paper, SimpleGrid } from "@mantine/core" +import { ReactNode, useEffect, useState } from "react" import { ListRecipesQuery } from "~/gql/forkd.g" import { RecipeCard } from "../recipeCard/recipeCard" +import { useFetch, useIntersection } from "@mantine/hooks" type Props = { recipes: Exclude["list"] } export default function RecipeList({ recipes }: Props): ReactNode { + const [recipeList, setRecipeList] = useState(recipes.items) + const [cursor, setCursor] = useState(recipes.pagination.nextCursor) + const { refetch, data, loading, abort } = useFetch( + `/api/recipes/paginate/${cursor ?? ""}`, + { autoInvoke: false } + ) + useEffect(() => { + return () => { + abort() + } + }, [abort]) + useEffect(() => { + if (data) { + setRecipeList((r) => r.concat(data.items)) + setCursor(data.pagination.nextCursor) + } + }, [data]) + const { ref, entry } = useIntersection({ + rootMargin: "1200px", + }) + useEffect(() => { + if (entry?.isIntersecting && !loading && cursor) { + refetch() + } + }, [entry?.isIntersecting, loading, refetch, cursor]) return ( - <> - {/* recipe component */} + - {recipes?.items.map((recipe) => ( + {recipeList.map((recipe) => (
))}
- + {loading ? ( +
+ {" "} +
+ ) : ( +
+ )} +
) } diff --git a/web/app/gql/forkd.g.ts b/web/app/gql/forkd.g.ts index 721ac93..a385b57 100644 --- a/web/app/gql/forkd.g.ts +++ b/web/app/gql/forkd.g.ts @@ -335,10 +335,12 @@ export type CheckUserSignupQueryVariables = Exact<{ export type CheckUserSignupQuery = { __typename?: 'Query', user?: { __typename?: 'UserQuery', byEmail?: { __typename?: 'User', email: string } | null, byDisplayName?: { __typename?: 'User', displayName: string } | null } | null }; -export type ListRecipesQueryVariables = Exact<{ [key: string]: never; }>; +export type ListRecipesQueryVariables = Exact<{ + input?: InputMaybe; +}>; -export type ListRecipesQuery = { __typename?: 'Query', recipe?: { __typename?: 'RecipeQuery', list: { __typename?: 'PaginatedRecipes', items: Array<{ __typename?: 'Recipe', slug: string, id: any, author: { __typename?: 'User', displayName: string }, featuredRevision?: { __typename?: 'RecipeRevision', recipeDescription?: string | null, publishDate: any, rating?: number | null, title: string } | null }> } } | null }; +export type ListRecipesQuery = { __typename?: 'Query', recipe?: { __typename?: 'RecipeQuery', list: { __typename?: 'PaginatedRecipes', items: Array<{ __typename?: 'Recipe', slug: string, id: any, author: { __typename?: 'User', displayName: string }, featuredRevision?: { __typename?: 'RecipeRevision', recipeDescription?: string | null, publishDate: any, rating?: number | null, title: string } | null }>, pagination: { __typename?: 'PaginationInfo', nextCursor?: string | null } } } | null }; export type LoginMutationVariables = Exact<{ token: Scalars['String']['input']; @@ -395,9 +397,9 @@ export const CheckUserSignupDocument = gql` } `; export const ListRecipesDocument = gql` - query ListRecipes { + query ListRecipes($input: ListRecipeInput) { recipe { - list { + list(input: $input) { items { slug id @@ -411,6 +413,9 @@ export const ListRecipesDocument = gql` title } } + pagination { + nextCursor + } } } } diff --git a/web/app/gql/operations/CheckUserSignup.gql b/web/app/gql/operations/CheckUserSignup.graphql similarity index 100% rename from web/app/gql/operations/CheckUserSignup.gql rename to web/app/gql/operations/CheckUserSignup.graphql diff --git a/web/app/gql/operations/ListRecipes.gql b/web/app/gql/operations/ListRecipes.graphql similarity index 67% rename from web/app/gql/operations/ListRecipes.gql rename to web/app/gql/operations/ListRecipes.graphql index b50617b..57baeae 100644 --- a/web/app/gql/operations/ListRecipes.gql +++ b/web/app/gql/operations/ListRecipes.graphql @@ -1,6 +1,6 @@ -query ListRecipes { +query ListRecipes($input: ListRecipeInput) { recipe { - list { + list(input: $input) { items { slug id @@ -14,6 +14,9 @@ query ListRecipes { title } } + pagination { + nextCursor + } } } } diff --git a/web/app/gql/operations/Login.gql b/web/app/gql/operations/Login.graphql similarity index 100% rename from web/app/gql/operations/Login.gql rename to web/app/gql/operations/Login.graphql diff --git a/web/app/gql/operations/Logout.gql b/web/app/gql/operations/Logout.graphql similarity index 100% rename from web/app/gql/operations/Logout.gql rename to web/app/gql/operations/Logout.graphql diff --git a/web/app/gql/operations/RequestMagicLink.gql b/web/app/gql/operations/RequestMagicLink.graphql similarity index 100% rename from web/app/gql/operations/RequestMagicLink.gql rename to web/app/gql/operations/RequestMagicLink.graphql diff --git a/web/app/gql/operations/Signup.gql b/web/app/gql/operations/Signup.graphql similarity index 100% rename from web/app/gql/operations/Signup.gql rename to web/app/gql/operations/Signup.graphql diff --git a/web/app/gql/operations/queryUser.gql b/web/app/gql/operations/queryUser.graphql similarity index 100% rename from web/app/gql/operations/queryUser.gql rename to web/app/gql/operations/queryUser.graphql diff --git a/web/app/gql/operations/recipeBySlug.gql b/web/app/gql/operations/recipeBySlug.graphql similarity index 100% rename from web/app/gql/operations/recipeBySlug.gql rename to web/app/gql/operations/recipeBySlug.graphql diff --git a/web/app/routes/_app._index/route.tsx b/web/app/routes/_app._index/route.tsx index ea91c76..b3068f6 100644 --- a/web/app/routes/_app._index/route.tsx +++ b/web/app/routes/_app._index/route.tsx @@ -1,9 +1,7 @@ -import { MetaFunction, useLoaderData, LoaderFunctionArgs } from "react-router" -import { ClientError } from "graphql-request" -import { getSessionOrThrow } from "~/.server/session" -import { getSDK } from "~/gql/client" -import { environment } from "~/.server/env" +import { MetaFunction, useLoaderData } from "react-router" +import { recipesLoader } from "~/.server/loaders/recipes" import RecipeList from "../../components/recipeList/RecipeList" +import type { Route } from "./+types/route" export const meta: MetaFunction = () => { return [ @@ -15,20 +13,7 @@ export const meta: MetaFunction = () => { ] } -export async function loader(args: LoaderFunctionArgs) { - const session = await getSessionOrThrow(args, false) - const auth = session.get("sessionToken") - const sdk = getSDK(`${environment.BACKEND_URL}`, auth) - try { - const data = await sdk.ListRecipes() - return data?.recipe?.list ?? null - } catch (err) { - if (err instanceof ClientError && err.message === "missing auth") { - return null - } - throw err - } -} +export const loader = recipesLoader export default function Index() { const recipes = useLoaderData() diff --git a/web/app/routes/api.recipes.paginate.$cursor/route.ts b/web/app/routes/api.recipes.paginate.$cursor/route.ts new file mode 100644 index 0000000..e9681d6 --- /dev/null +++ b/web/app/routes/api.recipes.paginate.$cursor/route.ts @@ -0,0 +1,3 @@ +import { recipesLoader } from "~/.server/loaders/recipes" +import type { Route } from "./+types/route" +export const loader = recipesLoader diff --git a/web/codegen.ts b/web/codegen.ts index 0cb0415..faf1ea8 100644 --- a/web/codegen.ts +++ b/web/codegen.ts @@ -3,7 +3,7 @@ import type { CodegenConfig } from "@graphql-codegen/cli" const config: CodegenConfig = { overwrite: true, schema: "http://localhost:8000/query", - documents: "app/gql/**/*.gql", + documents: "app/gql/**/*.graphql", generates: { "app/gql/forkd.g.ts": { plugins: [