The recipes.blue monorepo recipes.blue
recipes appview atproto

feat: improve DX across the site, redirect on unauth request to /recipes/new

+154 -110
+15 -2
apps/web/src/main.tsx
··· 6 6 import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 7 7 import { configureOAuth } from '@atcute/oauth-browser-client'; 8 8 import './index.css' 9 + import { AuthProvider, useAuth } from './state/auth'; 9 10 10 - const router = createRouter({ routeTree }); 11 + const router = createRouter({ 12 + routeTree, 13 + context: { 14 + auth: undefined!, 15 + }, 16 + }); 11 17 12 18 declare module '@tanstack/react-router' { 13 19 interface Register { ··· 32 38 } 33 39 }); 34 40 41 + const InnerApp = () => { 42 + const auth = useAuth(); 43 + return <RouterProvider router={router} context={{ auth }} /> 44 + }; 45 + 35 46 createRoot(document.getElementById('root')!).render( 36 47 <StrictMode> 48 + <AuthProvider> 37 49 <QueryClientProvider client={queryClient}> 38 - <RouterProvider router={router} /> 50 + <InnerApp /> 39 51 <ReactQueryDevtools initialIsOpen={false} /> 40 52 </QueryClientProvider> 53 + </AuthProvider> 41 54 </StrictMode>, 42 55 )
+26 -30
apps/web/src/routeTree.gen.ts
··· 14 14 15 15 import { Route as rootRoute } from './routes/__root' 16 16 import { Route as Import } from './routes/_' 17 + import { Route as authLoginImport } from './routes/_.(auth)/login' 18 + import { Route as appRecipesNewImport } from './routes/_.(app)/recipes/new' 17 19 18 20 // Create Virtual Routes 19 21 20 22 const appIndexLazyImport = createFileRoute('/_/(app)/')() 21 - const authLoginLazyImport = createFileRoute('/_/(auth)/login')() 22 - const appRecipesNewLazyImport = createFileRoute('/_/(app)/recipes/new')() 23 23 const appRecipesAuthorIndexLazyImport = createFileRoute( 24 24 '/_/(app)/recipes/$author/', 25 25 )() ··· 42 42 } as any) 43 43 .lazy(() => import('./routes/_.(app)/index.lazy').then((d) => d.Route)) 44 44 45 - const authLoginLazyRoute = authLoginLazyImport 46 - .update({ 47 - id: '/(auth)/login', 48 - path: '/login', 49 - getParentRoute: () => Route, 50 - } as any) 51 - .lazy(() => import('./routes/_.(auth)/login.lazy').then((d) => d.Route)) 45 + const authLoginRoute = authLoginImport.update({ 46 + id: '/(auth)/login', 47 + path: '/login', 48 + getParentRoute: () => Route, 49 + } as any) 52 50 53 - const appRecipesNewLazyRoute = appRecipesNewLazyImport 54 - .update({ 55 - id: '/(app)/recipes/new', 56 - path: '/recipes/new', 57 - getParentRoute: () => Route, 58 - } as any) 59 - .lazy(() => import('./routes/_.(app)/recipes/new.lazy').then((d) => d.Route)) 51 + const appRecipesNewRoute = appRecipesNewImport.update({ 52 + id: '/(app)/recipes/new', 53 + path: '/recipes/new', 54 + getParentRoute: () => Route, 55 + } as any) 60 56 61 57 const appRecipesAuthorIndexLazyRoute = appRecipesAuthorIndexLazyImport 62 58 .update({ ··· 95 91 id: '/_/(auth)/login' 96 92 path: '/login' 97 93 fullPath: '/login' 98 - preLoaderRoute: typeof authLoginLazyImport 94 + preLoaderRoute: typeof authLoginImport 99 95 parentRoute: typeof rootRoute 100 96 } 101 97 '/_/(app)/': { ··· 109 105 id: '/_/(app)/recipes/new' 110 106 path: '/recipes/new' 111 107 fullPath: '/recipes/new' 112 - preLoaderRoute: typeof appRecipesNewLazyImport 108 + preLoaderRoute: typeof appRecipesNewImport 113 109 parentRoute: typeof rootRoute 114 110 } 115 111 '/_/(app)/recipes/$author/': { ··· 132 128 // Create and export the route tree 133 129 134 130 interface RouteChildren { 135 - authLoginLazyRoute: typeof authLoginLazyRoute 131 + authLoginRoute: typeof authLoginRoute 136 132 appIndexLazyRoute: typeof appIndexLazyRoute 137 - appRecipesNewLazyRoute: typeof appRecipesNewLazyRoute 133 + appRecipesNewRoute: typeof appRecipesNewRoute 138 134 appRecipesAuthorIndexLazyRoute: typeof appRecipesAuthorIndexLazyRoute 139 135 appRecipesAuthorRkeyIndexLazyRoute: typeof appRecipesAuthorRkeyIndexLazyRoute 140 136 } 141 137 142 138 const RouteChildren: RouteChildren = { 143 - authLoginLazyRoute: authLoginLazyRoute, 139 + authLoginRoute: authLoginRoute, 144 140 appIndexLazyRoute: appIndexLazyRoute, 145 - appRecipesNewLazyRoute: appRecipesNewLazyRoute, 141 + appRecipesNewRoute: appRecipesNewRoute, 146 142 appRecipesAuthorIndexLazyRoute: appRecipesAuthorIndexLazyRoute, 147 143 appRecipesAuthorRkeyIndexLazyRoute: appRecipesAuthorRkeyIndexLazyRoute, 148 144 } ··· 151 147 152 148 export interface FileRoutesByFullPath { 153 149 '': typeof RouteWithChildren 154 - '/login': typeof authLoginLazyRoute 150 + '/login': typeof authLoginRoute 155 151 '/': typeof appIndexLazyRoute 156 - '/recipes/new': typeof appRecipesNewLazyRoute 152 + '/recipes/new': typeof appRecipesNewRoute 157 153 '/recipes/$author': typeof appRecipesAuthorIndexLazyRoute 158 154 '/recipes/$author/$rkey': typeof appRecipesAuthorRkeyIndexLazyRoute 159 155 } 160 156 161 157 export interface FileRoutesByTo { 162 - '/login': typeof authLoginLazyRoute 158 + '/login': typeof authLoginRoute 163 159 '/': typeof appIndexLazyRoute 164 - '/recipes/new': typeof appRecipesNewLazyRoute 160 + '/recipes/new': typeof appRecipesNewRoute 165 161 '/recipes/$author': typeof appRecipesAuthorIndexLazyRoute 166 162 '/recipes/$author/$rkey': typeof appRecipesAuthorRkeyIndexLazyRoute 167 163 } ··· 169 165 export interface FileRoutesById { 170 166 __root__: typeof rootRoute 171 167 '/_': typeof RouteWithChildren 172 - '/_/(auth)/login': typeof authLoginLazyRoute 168 + '/_/(auth)/login': typeof authLoginRoute 173 169 '/_/(app)/': typeof appIndexLazyRoute 174 - '/_/(app)/recipes/new': typeof appRecipesNewLazyRoute 170 + '/_/(app)/recipes/new': typeof appRecipesNewRoute 175 171 '/_/(app)/recipes/$author/': typeof appRecipesAuthorIndexLazyRoute 176 172 '/_/(app)/recipes/$author/$rkey/': typeof appRecipesAuthorRkeyIndexLazyRoute 177 173 } ··· 235 231 ] 236 232 }, 237 233 "/_/(auth)/login": { 238 - "filePath": "_.(auth)/login.lazy.tsx", 234 + "filePath": "_.(auth)/login.tsx", 239 235 "parent": "/_" 240 236 }, 241 237 "/_/(app)/": { ··· 243 239 "parent": "/_" 244 240 }, 245 241 "/_/(app)/recipes/new": { 246 - "filePath": "_.(app)/recipes/new.lazy.tsx", 242 + "filePath": "_.(app)/recipes/new.tsx", 247 243 "parent": "/_" 248 244 }, 249 245 "/_/(app)/recipes/$author/": {
+83 -54
apps/web/src/routes/_.(app)/recipes/new.lazy.tsx apps/web/src/routes/_.(app)/recipes/new.tsx
··· 1 - import { createLazyFileRoute, Link } from '@tanstack/react-router' 1 + import { createFileRoute, Link, redirect } from "@tanstack/react-router"; 2 2 import { 3 3 Breadcrumb, 4 4 BreadcrumbItem, ··· 6 6 BreadcrumbList, 7 7 BreadcrumbPage, 8 8 BreadcrumbSeparator, 9 - } from '@/components/ui/breadcrumb' 10 - import { Separator } from '@/components/ui/separator' 11 - import { SidebarTrigger } from '@/components/ui/sidebar' 12 - import { useFieldArray, useForm } from 'react-hook-form' 13 - import { z } from 'zod'; 14 - import { zodResolver } from "@hookform/resolvers/zod" 15 - import { IngredientObject, RecipeRecord } from '@cookware/lexicons' 16 - import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' 17 - import { Button } from '@/components/ui/button' 18 - import { Input } from '@/components/ui/input' 19 - import { Textarea } from '@/components/ui/textarea' 20 - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 21 - import { Sortable, SortableDragHandle, SortableItem } from '@/components/ui/sortable' 22 - import { DragHandleDots2Icon } from '@radix-ui/react-icons' 23 - import { Label } from '@/components/ui/label' 24 - import { TrashIcon } from 'lucide-react' 25 - import { useNewRecipeMutation } from '@/queries/recipe' 9 + } from "@/components/ui/breadcrumb"; 10 + import { Separator } from "@/components/ui/separator"; 11 + import { SidebarTrigger } from "@/components/ui/sidebar"; 12 + import { useFieldArray, useForm } from "react-hook-form"; 13 + import { z } from "zod"; 14 + import { zodResolver } from "@hookform/resolvers/zod"; 15 + import { IngredientObject, RecipeRecord } from "@cookware/lexicons"; 16 + import { 17 + Form, 18 + FormControl, 19 + FormDescription, 20 + FormField, 21 + FormItem, 22 + FormLabel, 23 + FormMessage, 24 + } from "@/components/ui/form"; 25 + import { Button } from "@/components/ui/button"; 26 + import { Input } from "@/components/ui/input"; 27 + import { Textarea } from "@/components/ui/textarea"; 28 + import { 29 + Card, 30 + CardContent, 31 + CardDescription, 32 + CardHeader, 33 + CardTitle, 34 + } from "@/components/ui/card"; 35 + import { 36 + Sortable, 37 + SortableDragHandle, 38 + SortableItem, 39 + } from "@/components/ui/sortable"; 40 + import { DragHandleDots2Icon } from "@radix-ui/react-icons"; 41 + import { Label } from "@/components/ui/label"; 42 + import { TrashIcon } from "lucide-react"; 43 + import { useNewRecipeMutation } from "@/queries/recipe"; 26 44 27 - export const Route = createLazyFileRoute('/_/(app)/recipes/new')({ 45 + export const Route = createFileRoute("/_/(app)/recipes/new")({ 46 + beforeLoad: async ({ context, location }) => { 47 + if (!context.auth.isLoggedIn) { 48 + throw redirect({ 49 + to: '/login', 50 + search: { 51 + redirect: location.href, 52 + }, 53 + }); 54 + } 55 + }, 28 56 component: RouteComponent, 29 - }) 57 + }); 30 58 31 59 const schema = RecipeRecord.extend({ 32 - ingredients: z.array(IngredientObject.extend({ 33 - amount: z.coerce.number().nullable(), 34 - })), 60 + ingredients: z.array( 61 + IngredientObject.extend({ 62 + amount: z.coerce.number().nullable(), 63 + }), 64 + ), 35 65 }); 36 66 37 67 function RouteComponent() { 38 68 const form = useForm<z.infer<typeof schema>>({ 39 69 resolver: zodResolver(schema), 40 70 defaultValues: { 41 - title: '', 42 - description: '', 43 - ingredients: [ 44 - { name: '' }, 45 - ], 46 - steps: [ 47 - { text: '' }, 48 - ], 71 + title: "", 72 + description: "", 73 + ingredients: [{ name: "" }], 74 + steps: [{ text: "" }], 49 75 }, 50 76 }); 51 77 ··· 76 102 </CardHeader> 77 103 <CardContent> 78 104 <Form {...form}> 79 - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> 80 - 105 + <form 106 + onSubmit={form.handleSubmit(onSubmit)} 107 + className="space-y-8" 108 + > 81 109 <FormField 82 110 name="title" 83 111 control={form.control} ··· 104 132 <FormControl> 105 133 <Textarea 106 134 className="resize-none" 107 - value={value || ''} 135 + value={value || ""} 108 136 {...field} 109 137 /> 110 138 </FormControl> ··· 117 145 <Label>Ingredients</Label> 118 146 <Sortable 119 147 value={ingredients.fields} 120 - onMove={({ activeIndex, overIndex }) => ingredients.move(activeIndex, overIndex)} 148 + onMove={({ activeIndex, overIndex }) => 149 + ingredients.move(activeIndex, overIndex)} 121 150 > 122 151 <div className="flex w-full flex-col gap-2"> 123 152 {ingredients.fields.map((field, index) => ( 124 - <SortableItem 125 - key={field.id} 126 - value={field.id} 127 - asChild 128 - > 153 + <SortableItem key={field.id} value={field.id} asChild> 129 154 <div className="grid grid-cols-[2rem_1fr_0.2fr_0.2fr_2rem] items-center gap-2"> 130 155 <SortableDragHandle 131 156 type="button" ··· 165 190 <Input 166 191 type="number" 167 192 placeholder="#" 168 - value={value || '0'} 193 + value={value || "0"} 169 194 className="h-8" 170 195 {...field} 171 196 /> ··· 184 209 <Input 185 210 placeholder="Unit" 186 211 className="h-8" 187 - value={value || ''} 212 + value={value || ""} 188 213 {...field} 189 214 /> 190 215 </FormControl> ··· 214 239 variant="secondary" 215 240 onClick={(e) => { 216 241 e.preventDefault(); 217 - ingredients.append({ name: '', amount: null, unit: null }); 242 + ingredients.append({ 243 + name: "", 244 + amount: null, 245 + unit: null, 246 + }); 218 247 }} 219 - >Add</Button> 248 + > 249 + Add 250 + </Button> 220 251 </div> 221 252 222 253 <div className="grid gap-2"> 223 254 <Label>Steps</Label> 224 255 <Sortable 225 256 value={steps.fields} 226 - onMove={({ activeIndex, overIndex }) => steps.move(activeIndex, overIndex)} 257 + onMove={({ activeIndex, overIndex }) => 258 + steps.move(activeIndex, overIndex)} 227 259 > 228 260 <div className="flex w-full flex-col gap-2"> 229 261 {steps.fields.map((field, index) => ( 230 - <SortableItem 231 - key={field.id} 232 - value={field.id} 233 - asChild 234 - > 262 + <SortableItem key={field.id} value={field.id} asChild> 235 263 <div className="grid grid-cols-[2rem_auto_2rem] items-center gap-2"> 236 264 <SortableDragHandle 237 265 type="button" ··· 278 306 variant="secondary" 279 307 onClick={(e) => { 280 308 e.preventDefault(); 281 - steps.append({ text: '' }) 309 + steps.append({ text: "" }); 282 310 }} 283 - >Add</Button> 311 + > 312 + Add 313 + </Button> 284 314 </div> 285 315 286 316 <div className="grid justify-end"> ··· 292 322 Submit 293 323 </Button> 294 324 </div> 295 - 296 325 </form> 297 326 </Form> 298 327 </CardContent> 299 328 </Card> 300 329 </div> 301 330 </> 302 - ) 331 + ); 303 332 } 304 333 305 334 const Breadcrumbs = () => (
+15 -12
apps/web/src/routes/_.(auth)/login.lazy.tsx apps/web/src/routes/_.(auth)/login.tsx
··· 18 18 import { Separator } from '@/components/ui/separator' 19 19 import { SidebarTrigger } from '@/components/ui/sidebar' 20 20 import { sleep } from '@/lib/utils' 21 - import { createAuthorizationUrl, resolveFromIdentity } from '@atcute/oauth-browser-client' 21 + import { 22 + createAuthorizationUrl, 23 + resolveFromIdentity, 24 + } from '@atcute/oauth-browser-client' 22 25 import { useMutation } from '@tanstack/react-query' 23 - import { createLazyFileRoute } from '@tanstack/react-router' 26 + import { createFileRoute } from '@tanstack/react-router' 24 27 import { useState } from 'react' 25 28 26 - export const Route = createLazyFileRoute('/_/(auth)/login')({ 29 + export const Route = createFileRoute('/_/(auth)/login')({ 27 30 component: RouteComponent, 28 31 }) 29 32 ··· 33 36 const { mutate, isPending, error } = useMutation({ 34 37 mutationKey: ['login'], 35 38 mutationFn: async () => { 36 - const { identity, metadata } = await resolveFromIdentity(handle); 39 + const { identity, metadata } = await resolveFromIdentity(handle) 37 40 38 41 const authUrl = await createAuthorizationUrl({ 39 42 metadata: metadata, 40 43 identity: identity, 41 44 scope: 'atproto transition:generic', 42 - }); 45 + }) 43 46 44 - await sleep(200); 47 + await sleep(200) 45 48 46 - return authUrl; 49 + return authUrl 47 50 }, 48 51 onSuccess: async (authUrl: URL) => { 49 - window.location.assign(authUrl); 52 + window.location.assign(authUrl) 50 53 51 54 await new Promise((_resolve, reject) => { 52 55 const listener = () => { 53 - reject(new Error(`user aborted the login request`)); 54 - }; 56 + reject(new Error(`user aborted the login request`)) 57 + } 55 58 56 - window.addEventListener('pageshow', listener, { once: true }); 57 - }); 59 + window.addEventListener('pageshow', listener, { once: true }) 60 + }) 58 61 }, 59 62 }) 60 63
+13 -11
apps/web/src/routes/__root.tsx
··· 3 3 SidebarInset, 4 4 SidebarProvider, 5 5 } from '@/components/ui/sidebar' 6 - import { AuthProvider } from '@/state/auth'; 7 - import { Outlet, createRootRoute } from '@tanstack/react-router' 6 + import { AuthContextType } from '@/state/auth'; 7 + import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' 8 8 9 - export const Route = createRootRoute({ 9 + type RootContext = { 10 + auth: AuthContextType; 11 + }; 12 + 13 + export const Route = createRootRouteWithContext<RootContext>()({ 10 14 component: RootComponent, 11 15 }); 12 16 13 17 function RootComponent() { 14 18 return ( 15 - <AuthProvider> 16 - <SidebarProvider> 17 - <AppSidebar /> 18 - <SidebarInset> 19 - <Outlet /> 20 - </SidebarInset> 21 - </SidebarProvider> 22 - </AuthProvider> 19 + <SidebarProvider> 20 + <AppSidebar /> 21 + <SidebarInset> 22 + <Outlet /> 23 + </SidebarInset> 24 + </SidebarProvider> 23 25 ) 24 26 }
+2 -1
apps/web/src/state/auth.tsx
··· 2 2 import { finalizeAuthorization, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 3 import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; 4 4 5 - type AuthContextType = { 5 + export type AuthContextType = { 6 6 isLoggedIn: boolean; 7 7 agent?: OAuthUserAgent; 8 8 logOut: () => Promise<void>; ··· 30 30 31 31 localStorage.setItem("lastSignedIn", did); 32 32 return session; 33 + 33 34 } else { 34 35 const lastSignedIn = localStorage.getItem("lastSignedIn"); 35 36