The recipes.blue monorepo recipes.blue
recipes appview atproto
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: move queries to query hooks

+212 -123
+10 -1
apps/web/src/components/app-sidebar.tsx
··· 13 13 SidebarFooter, 14 14 SidebarRail, 15 15 } from "@/components/ui/sidebar" 16 + import { Suspense } from "react" 17 + import { Button } from "./ui/button" 18 + import { Link } from "@tanstack/react-router" 16 19 17 20 const data = { 18 21 user: { ··· 75 78 <NavMain items={data.navMain} /> 76 79 </SidebarContent> 77 80 <SidebarFooter> 78 - <NavUser /> 81 + <Suspense fallback={ 82 + <Button asChild> 83 + <Link href="/login" className="w-full">Log in</Link> 84 + </Button> 85 + } name="nav-user-data"> 86 + <NavUser /> 87 + </Suspense> 79 88 </SidebarFooter> 80 89 <SidebarRail /> 81 90 </Sidebar>
+79 -62
apps/web/src/components/nav-user.tsx
··· 5 5 ChevronsUpDown, 6 6 LogOut, 7 7 } from "lucide-react" 8 - 9 8 import { 10 9 Avatar, 11 10 AvatarFallback, ··· 26 25 SidebarMenuItem, 27 26 useSidebar, 28 27 } from "@/components/ui/sidebar" 29 - import { useQuery } from "@tanstack/react-query" 30 - import axios from "axios"; 31 - import type { AppBskyActorGetProfile } from "@atcute/client/lexicons" 32 - import QueryPlaceholder from "./query-placeholder" 28 + import { useUserQuery } from "@/queries/self" 33 29 import { Button } from "./ui/button" 34 30 import { Link } from "@tanstack/react-router" 31 + import { Skeleton } from "./ui/skeleton" 35 32 36 33 export function NavUser() { 37 34 const { isMobile } = useSidebar() 38 35 39 - const userQuery = useQuery({ 40 - queryKey: ['/oauth/me'], 41 - queryFn: async () => { 42 - const res = await axios.get<AppBskyActorGetProfile.Output>('/oauth/me'); 43 - return res.data; 44 - }, 45 - }); 46 - const { data } = userQuery; 36 + const userQuery = useUserQuery(); 37 + 38 + if (userQuery.isLoading) { 39 + return ( 40 + <SidebarMenu> 41 + <SidebarMenuItem> 42 + <SidebarMenuButton 43 + size="lg" 44 + className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 45 + > 46 + <Skeleton className="h-8 w-8 rounded-lg" /> 47 + <div className="grid flex-1 text-left text-sm leading-tight"> 48 + <Skeleton className="h-2 w-20 rounded-lg" /> 49 + </div> 50 + </SidebarMenuButton> 51 + </SidebarMenuItem> 52 + </SidebarMenu> 53 + ) 54 + } 55 + 56 + if (userQuery.isError || !userQuery.data) { 57 + return ( 58 + <SidebarMenu> 59 + <SidebarMenuItem> 60 + <SidebarMenuButton 61 + size="lg" 62 + className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 63 + > 64 + <Button asChild> 65 + <Link href="/login" className="w-full">Log in</Link> 66 + </Button> 67 + </SidebarMenuButton> 68 + </SidebarMenuItem> 69 + </SidebarMenu> 70 + ); 71 + } 47 72 48 73 return ( 49 74 <SidebarMenu> 50 75 <SidebarMenuItem> 51 - <QueryPlaceholder query={userQuery} 52 - noData={ 53 - <Button asChild> 54 - <Link href="/login" className="w-full">Log in</Link> 55 - </Button> 56 - } 57 - > 58 - <DropdownMenu> 59 - <DropdownMenuTrigger asChild> 60 - <SidebarMenuButton 61 - size="lg" 62 - className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 63 - > 76 + <DropdownMenu> 77 + <DropdownMenuTrigger asChild> 78 + <SidebarMenuButton 79 + size="lg" 80 + className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 81 + > 82 + <Avatar className="h-8 w-8 rounded-lg"> 83 + <AvatarImage src={userQuery.data.avatar} alt={userQuery.data.displayName ?? `@${userQuery.data.handle}`} /> 84 + <AvatarFallback className="rounded-lg">{userQuery.data.handle.substring(2)}</AvatarFallback> 85 + </Avatar> 86 + <div className="grid flex-1 text-left text-sm leading-tight"> 87 + <span className="truncate font-semibold">{userQuery.data.displayName ?? `@${userQuery.data.handle}`}</span> 88 + </div> 89 + <ChevronsUpDown className="ml-auto size-4" /> 90 + </SidebarMenuButton> 91 + </DropdownMenuTrigger> 92 + <DropdownMenuContent 93 + className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" 94 + side={isMobile ? "bottom" : "right"} 95 + align="end" 96 + sideOffset={4} 97 + > 98 + <DropdownMenuLabel className="p-0 font-normal"> 99 + <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 64 100 <Avatar className="h-8 w-8 rounded-lg"> 65 - <AvatarImage src={data?.avatar} alt={data?.displayName ?? `@${data?.handle}`} /> 66 - <AvatarFallback className="rounded-lg">{data?.handle.substring(2)}</AvatarFallback> 101 + <AvatarImage src={userQuery.data.avatar} alt={userQuery.data.displayName ?? `@${userQuery.data.handle}`} /> 102 + <AvatarFallback className="rounded-lg">{userQuery.data.handle.substring(2)}</AvatarFallback> 67 103 </Avatar> 68 104 <div className="grid flex-1 text-left text-sm leading-tight"> 69 - <span className="truncate font-semibold">{data?.displayName ?? `@${data?.handle}`}</span> 70 - </div> 71 - <ChevronsUpDown className="ml-auto size-4" /> 72 - </SidebarMenuButton> 73 - </DropdownMenuTrigger> 74 - <DropdownMenuContent 75 - className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" 76 - side={isMobile ? "bottom" : "right"} 77 - align="end" 78 - sideOffset={4} 79 - > 80 - <DropdownMenuLabel className="p-0 font-normal"> 81 - <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 82 - <Avatar className="h-8 w-8 rounded-lg"> 83 - <AvatarImage src={data?.avatar} alt={data?.displayName ?? `@${data?.handle}`} /> 84 - <AvatarFallback className="rounded-lg">{data?.handle.substring(2)}</AvatarFallback> 85 - </Avatar> 86 - <div className="grid flex-1 text-left text-sm leading-tight"> 87 - <span className="truncate font-semibold">{data?.displayName ?? `@${data?.handle}`}</span> 88 - </div> 105 + <span className="truncate font-semibold">{userQuery.data.displayName ?? `@${userQuery.data.handle}`}</span> 89 106 </div> 90 - </DropdownMenuLabel> 91 - <DropdownMenuSeparator /> 92 - <DropdownMenuGroup> 93 - <DropdownMenuItem> 94 - <BadgeCheck /> 95 - Account 96 - </DropdownMenuItem> 97 - </DropdownMenuGroup> 98 - <DropdownMenuSeparator /> 107 + </div> 108 + </DropdownMenuLabel> 109 + <DropdownMenuSeparator /> 110 + <DropdownMenuGroup> 99 111 <DropdownMenuItem> 100 - <LogOut /> 101 - Log out 112 + <BadgeCheck /> 113 + Account 102 114 </DropdownMenuItem> 103 - </DropdownMenuContent> 104 - </DropdownMenu> 105 - </QueryPlaceholder> 115 + </DropdownMenuGroup> 116 + <DropdownMenuSeparator /> 117 + <DropdownMenuItem> 118 + <LogOut /> 119 + Log out 120 + </DropdownMenuItem> 121 + </DropdownMenuContent> 122 + </DropdownMenu> 106 123 </SidebarMenuItem> 107 124 </SidebarMenu> 108 125 )
+9 -1
apps/web/src/main.tsx
··· 19 19 20 20 const creds = new CredentialManager({ service: `https://${SERVER_URL}/api/` }); 21 21 const rpc = new XRPC({ handler: creds }); 22 - const queryClient = new QueryClient(); 22 + const queryClient = new QueryClient({ 23 + defaultOptions: { 24 + queries: { 25 + refetchOnWindowFocus: false, 26 + structuralSharing: false, 27 + retry: false, 28 + } 29 + } 30 + }); 23 31 24 32 createRoot(document.getElementById('root')!).render( 25 33 <StrictMode>
+31
apps/web/src/queries/recipe.ts
··· 1 + import { useXrpc } from "@/hooks/use-xrpc"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + 4 + const RQKEY_ROOT = 'posts'; 5 + export const RQKEY = (cursor: string, did: string, rkey: string) => [RQKEY_ROOT, cursor, did, rkey]; 6 + 7 + export const useRecipesQuery = (cursor: string, did?: string) => { 8 + const { rpc } = useXrpc(); 9 + return useQuery({ 10 + queryKey: RQKEY(cursor, '', ''), 11 + queryFn: async () => { 12 + const res = await rpc.get('moe.hayden.cookware.getRecipes', { 13 + params: { cursor, did }, 14 + }); 15 + return res.data; 16 + }, 17 + }); 18 + }; 19 + 20 + export const useRecipeQuery = (did: string, rkey: string) => { 21 + const { rpc } = useXrpc(); 22 + return useQuery({ 23 + queryKey: RQKEY('', did, rkey), 24 + queryFn: async () => { 25 + const res = await rpc.get('moe.hayden.cookware.getRecipe', { 26 + params: { did, rkey }, 27 + }); 28 + return res.data; 29 + }, 30 + }); 31 + };
+13
apps/web/src/queries/self.ts
··· 1 + import { AppBskyActorGetProfile } from "@atcute/client/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import axios from "axios"; 4 + 5 + export const useUserQuery = () => { 6 + return useQuery({ 7 + queryKey: ['self'], 8 + queryFn: async () => { 9 + const res = await axios.get<AppBskyActorGetProfile.Output>('/oauth/me'); 10 + return res.data; 11 + }, 12 + }); 13 + }
+14 -43
apps/web/src/routes/(app)/index.lazy.tsx
··· 9 9 } from '@/components/ui/breadcrumb' 10 10 import { Separator } from '@/components/ui/separator' 11 11 import { SidebarTrigger } from '@/components/ui/sidebar' 12 - import { useQuery } from '@tanstack/react-query' 13 - import { 14 - Card, 15 - CardContent, 16 - CardFooter, 17 - CardHeader, 18 - CardTitle, 19 - } from '@/components/ui/card' 20 - import { useXrpc } from '@/hooks/use-xrpc' 21 - import { Clock, CookingPot, ListIcon } from 'lucide-react' 22 12 import QueryPlaceholder from '@/components/query-placeholder' 13 + import { useRecipesQuery } from '@/queries/recipe' 14 + import { RecipeCard } from '@/screens/Recipes/RecipeCard' 23 15 24 16 export const Route = createLazyFileRoute('/(app)/')({ 25 17 component: RouteComponent, 26 18 }) 27 19 28 20 function RouteComponent() { 29 - const { rpc } = useXrpc() 30 - 31 - const query = useQuery({ 32 - queryKey: ['moe.hayden.cookware.getRecipes', { cursor: '' }], 33 - queryFn: () => 34 - rpc.get('moe.hayden.cookware.getRecipes', { 35 - params: { cursor: '' }, 36 - }), 37 - }) 21 + const query = useRecipesQuery(''); 38 22 39 23 return ( 40 24 <> ··· 58 42 <div className="flex flex-1 flex-col gap-4 p-4 pt-0"> 59 43 <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> 60 44 <QueryPlaceholder query={query} cards cardsCount={12}> 61 - {query.data?.data.recipes.map((v, idx) => ( 62 - <Link key={idx} href={`/recipes/${v.author}/${v.rkey}`}> 63 - <Card> 64 - <CardHeader> 65 - <CardTitle>{v.title}</CardTitle> 66 - </CardHeader> 67 - <CardContent> 68 - <p>{v.description}</p> 69 - </CardContent> 70 - <CardFooter className="flex gap-6 text-sm text-muted-foreground"> 71 - <span className="flex items-center gap-2"> 72 - <ListIcon className="size-4" /> <span>{v.steps}</span> 73 - </span> 74 - 75 - <span className="flex items-center gap-2"> 76 - <CookingPot className="size-4" /> <span>{v.ingredients}</span> 77 - </span> 78 - 79 - <span className="flex items-center gap-2"> 80 - <Clock className="size-4" /> <span>30min.</span> 81 - </span> 82 - </CardFooter> 83 - </Card> 84 - </Link> 45 + {query.data?.recipes.map((recipe, idx) => ( 46 + <RecipeCard 47 + title={recipe.title} 48 + description={recipe.description} 49 + rkey={recipe.rkey} 50 + author={recipe.author} 51 + time={{ amount: 30, unit: 'min' }} 52 + steps={recipe.steps} 53 + ingredients={recipe.ingredients} 54 + key={idx} 55 + /> 85 56 ))} 86 57 </QueryPlaceholder> 87 58 </div>
+8 -15
apps/web/src/routes/(app)/recipes.$did.$rkey.tsx
··· 9 9 } from '@/components/ui/breadcrumb' 10 10 import { Separator } from '@/components/ui/separator' 11 11 import { SidebarTrigger } from '@/components/ui/sidebar' 12 - import { useQuery } from '@tanstack/react-query' 13 - import { useXrpc } from '@/hooks/use-xrpc' 14 12 import QueryPlaceholder from '@/components/query-placeholder' 15 13 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 14 + import { useRecipeQuery } from '@/queries/recipe' 16 15 17 16 export const Route = createFileRoute('/(app)/recipes/$did/$rkey')({ 18 17 component: RouteComponent, ··· 20 19 21 20 function RouteComponent() { 22 21 const { did, rkey } = Route.useParams() 23 - const { rpc } = useXrpc(); 24 - 25 - const query = useQuery({ 26 - queryKey: ['moe.hayden.cookware.getRecipe', { did, rkey }], 27 - queryFn: () => 28 - rpc.get('moe.hayden.cookware.getRecipe', { params: { did, rkey } }), 29 - }); 22 + const query = useRecipeQuery(did, rkey); 30 23 31 24 return ( 32 25 <> ··· 45 38 </BreadcrumbItem> 46 39 <BreadcrumbSeparator className="hidden md:block" /> 47 40 <BreadcrumbItem className="hidden md:block"> 48 - <BreadcrumbLink asChild><Link href={`/profiles/${did}`}>{query.data ? query.data.data.recipe.author.handle : did}</Link></BreadcrumbLink> 41 + <BreadcrumbLink asChild><Link href={`/profiles/${did}`}>{query.data ? query.data.recipe.author.handle : did}</Link></BreadcrumbLink> 49 42 </BreadcrumbItem> 50 43 <BreadcrumbSeparator className="hidden md:block" /> 51 44 <BreadcrumbItem> 52 - <BreadcrumbPage>{query.data ? query.data.data.recipe.title : rkey}</BreadcrumbPage> 45 + <BreadcrumbPage>{query.data ? query.data.recipe.title : rkey}</BreadcrumbPage> 53 46 </BreadcrumbItem> 54 47 </BreadcrumbList> 55 48 </Breadcrumb> ··· 58 51 <div className="flex flex-1 flex-col gap-4 p-4 pt-0"> 59 52 <QueryPlaceholder query={query}> 60 53 <div className="max-w-6xl"> 61 - <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">{query.data?.data.recipe.title}</h1> 62 - <p className="leading-7 [&:not(:first-child)]:mt-6">{query.data?.data.recipe.description}</p> 54 + <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">{query.data?.recipe.title}</h1> 55 + <p className="leading-7 [&:not(:first-child)]:mt-6">{query.data?.recipe.description}</p> 63 56 </div> 64 57 65 58 <div className="grid lg:grid-cols-3 gap-4"> ··· 69 62 </CardHeader> 70 63 <CardContent> 71 64 <ul> 72 - {query.data?.data.recipe.ingredients.map((ing, idx) => ( 65 + {query.data?.recipe.ingredients.map((ing, idx) => ( 73 66 <li key={idx}>{ing.name} ({ing.amount} {ing.unit})</li> 74 67 ))} 75 68 </ul> ··· 82 75 </CardHeader> 83 76 <CardContent> 84 77 <ol> 85 - {query.data?.data.recipe.steps.map((ing, idx) => ( 78 + {query.data?.recipe.steps.map((ing, idx) => ( 86 79 <li key={idx}>{ing.text}</li> 87 80 ))} 88 81 </ol>
+3 -1
apps/web/src/routes/(auth)/login.lazy.tsx
··· 17 17 function RouteComponent() { 18 18 const [handle, setHandle] = useState(''); 19 19 20 - const { mutate, isPending } = useMutation({ 20 + const { mutate, isPending, error } = useMutation({ 21 21 mutationKey: ['login'], 22 22 mutationFn: async () => { 23 23 const res = await fetch(`https://${SERVER_URL}/oauth/login`, { ··· 63 63 <div className="flex flex-col gap-2"> 64 64 <Label htmlFor="handle">Handle</Label> 65 65 <Input 66 + className={`${error ? 'border-destructive text-destructive' : ''}`} 66 67 type="text" 67 68 id="handle" 68 69 name="handle" ··· 71 72 value={handle} 72 73 onChange={e => setHandle(e.currentTarget.value)} 73 74 /> 75 + {error && <p className="text-sm font-medium text-destructive">{error.message}</p>} 74 76 </div> 75 77 </CardContent> 76 78 <CardFooter>
+45
apps/web/src/screens/Recipes/RecipeCard.tsx
··· 1 + import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { Clock, CookingPot, ListIcon } from "lucide-react"; 4 + 5 + type RecipeCardProps = { 6 + rkey: string; 7 + author: string; 8 + 9 + title: string; 10 + description?: string; 11 + steps: number; 12 + ingredients: number; 13 + time: { 14 + amount: number; 15 + unit: string; 16 + }; 17 + }; 18 + 19 + export const RecipeCard = ({ rkey, author, ...recipe }: RecipeCardProps) => { 20 + return ( 21 + <Link href={`/recipes/${author}/${rkey}`}> 22 + <Card> 23 + <CardHeader> 24 + <CardTitle>{recipe.title}</CardTitle> 25 + </CardHeader> 26 + <CardContent> 27 + <p>{recipe.description}</p> 28 + </CardContent> 29 + <CardFooter className="flex gap-6 text-sm text-muted-foreground"> 30 + <span className="flex items-center gap-2"> 31 + <ListIcon className="size-4" /> <span>{recipe.steps}</span> 32 + </span> 33 + 34 + <span className="flex items-center gap-2"> 35 + <CookingPot className="size-4" /> <span>{recipe.ingredients}</span> 36 + </span> 37 + 38 + <span className="flex items-center gap-2"> 39 + <Clock className="size-4" /> <span>{`${recipe.time.amount} ${recipe.time.unit}`}</span> 40 + </span> 41 + </CardFooter> 42 + </Card> 43 + </Link> 44 + ); 45 + };