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: begin setting up auth profile setup

+111 -50
+23 -2
apps/app/app/auth/signup/page.tsx
··· 8 8 import { redirect } from "next/navigation"; 9 9 import { Input } from "@/components/ui/input"; 10 10 import { Textarea } from "@/components/ui/textarea"; 11 + import { BlueRecipesActorProfile } from "@cookware/lexicons"; 12 + import { parse } from "@atcute/lexicons/validations"; 11 13 12 14 export default () => { 13 15 const auth = useAuth(); ··· 21 23 22 24 const submit = async (e: FormEvent) => { 23 25 e.preventDefault(); 24 - console.log('Submit profile payload now'); 26 + 27 + const res = await auth.pdsClient.post('com.atproto.repo.putRecord', { 28 + input: { 29 + rkey: 'self', 30 + repo: auth.actorDid, 31 + collection: BlueRecipesActorProfile.mainSchema.object.shape.$type.expected, 32 + record: parse(BlueRecipesActorProfile.mainSchema, { 33 + $type: BlueRecipesActorProfile.mainSchema.object.shape.$type.expected, 34 + displayName: displayName, 35 + description: bio, 36 + pronouns: pronouns, 37 + }), 38 + } 39 + }); 40 + 41 + if (!res.ok) { 42 + setError(res.data.error); 43 + return; 44 + } 45 + 46 + throw redirect('/home'); 25 47 }; 26 48 27 49 return ( ··· 82 104 <Input 83 105 name="pronouns" 84 106 id="signup_pronouns" 85 - autoFocus 86 107 aria-invalid={false} 87 108 tabIndex={3} 88 109 value={pronouns}
+27 -3
apps/app/components/app-sidebar/nav-auth.tsx
··· 1 1 "use client"; 2 2 3 - import { IconBrandBluesky, IconArrowRight } from "@tabler/icons-react"; 3 + import { IconBrandBluesky, IconArrowRight, IconDotsVertical } from "@tabler/icons-react"; 4 4 import { SidebarMenuItem, SidebarMenuButton, SidebarMenu } from "../ui/sidebar"; 5 5 import Link from "next/link"; 6 6 import { useAuth } from "@/lib/state/auth"; 7 7 import { redirect } from "next/navigation"; 8 + import { DropdownMenu, DropdownMenuTrigger } from "../ui/dropdown-menu"; 9 + import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 8 10 9 11 export const NavAuth = () => { 10 12 const auth = useAuth(); 11 13 12 14 if (auth.loggedIn) { 13 15 if (auth.profile.exists) { 14 - return <>Profile menu here</> 16 + return ( 17 + <SidebarMenu> 18 + <SidebarMenuItem className="flex items-center gap-2"> 19 + <DropdownMenu> 20 + <DropdownMenuTrigger asChild> 21 + <SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"> 22 + <Avatar className="h-8 w-8 rounded-lg"> 23 + <AvatarImage src={auth.profile.profileView.avatar} alt={auth.profile.profileView.displayName ?? auth.profile.profileView.handle} /> 24 + <AvatarFallback className="rounded-lg"> 25 + {auth.profile.profileView.handle.split('.').map(v => v[0]).join('')} 26 + </AvatarFallback> 27 + </Avatar> 28 + <div className="grid flex-1 text-left text-sm leading-tight"> 29 + <span className="truncate text-medium">{auth.profile.profileView.displayName ?? auth.profile.profileView.handle}</span> 30 + <span className="text-muted-foreground truncate text-cs">@{auth.profile.profileView.handle}</span> 31 + </div> 32 + <IconDotsVertical className="ml-auto size-4" /> 33 + </SidebarMenuButton> 34 + </DropdownMenuTrigger> 35 + </DropdownMenu> 36 + </SidebarMenuItem> 37 + </SidebarMenu> 38 + ); 15 39 } else { 16 - throw redirect("/auth/login"); 40 + throw redirect("/auth/signup"); 17 41 } 18 42 } else { 19 43 return (
+30 -27
apps/app/components/views/recipe-card.tsx
··· 2 2 import { ClockIcon } from "lucide-react"; 3 3 import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "../ui/card"; 4 4 import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 5 + import Link from "next/link"; 5 6 6 7 export type RecipeCardProps = { 7 8 recipe: BlueRecipesFeedDefs.RecipeView; 8 9 } 9 10 10 11 export const RecipeCard = ({ recipe }: RecipeCardProps) => ( 11 - <Card> 12 - <CardHeader> 13 - <CardTitle>{recipe.record.title}</CardTitle> 14 - {recipe.record.description && <CardDescription>{recipe.record.description}</CardDescription>} 15 - </CardHeader> 12 + <Link href={`/recipes/${recipe.author.handle}/${recipe.rkey}`}> 13 + <Card> 14 + <CardHeader> 15 + <CardTitle>{recipe.record.title}</CardTitle> 16 + {recipe.record.description && <CardDescription>{recipe.record.description}</CardDescription>} 17 + </CardHeader> 16 18 17 - {recipe.imageUrl && ( 18 - <CardContent> 19 - <img className="rounded-md ratio-[4/3] object-cover" src={recipe.imageUrl} /> 20 - </CardContent> 21 - )} 19 + {recipe.imageUrl && ( 20 + <CardContent> 21 + <img className="rounded-md ratio-[4/3] object-cover" src={recipe.imageUrl} /> 22 + </CardContent> 23 + )} 22 24 23 - <CardFooter> 24 - <div className="flex flex-col gap-2"> 25 - <div className="flex items-center gap-2 text-sm"> 26 - <Avatar className="!size-8"> 27 - <AvatarImage> 28 - <img src={recipe.author.avatar} /> 29 - </AvatarImage> 30 - <AvatarFallback>{recipe.author.handle.split('.').map(v => v.startsWith('@') ? v[1] : v[0]).join('').toUpperCase()}</AvatarFallback> 31 - </Avatar> 32 - <span>{recipe.author.displayName ?? recipe.author.handle}</span> 33 - </div> 25 + <CardFooter> 26 + <div className="flex flex-col gap-2"> 27 + <div className="flex items-center gap-2 text-sm"> 28 + <Avatar className="!size-8"> 29 + <AvatarImage> 30 + <img src={recipe.author.avatar} /> 31 + </AvatarImage> 32 + <AvatarFallback>{recipe.author.handle.split('.').map(v => v.startsWith('@') ? v[1] : v[0]).join('').toUpperCase()}</AvatarFallback> 33 + </Avatar> 34 + <span>{recipe.author.displayName ?? recipe.author.handle}</span> 35 + </div> 34 36 35 - <div className="flex items-center gap-2 text-sm"> 36 - <ClockIcon className="!size-4" /> 37 - <span>{recipe.record.time} mins</span> 37 + <div className="flex items-center gap-2 text-sm"> 38 + <ClockIcon className="!size-4" /> 39 + <span>{recipe.record.time} mins</span> 40 + </div> 38 41 </div> 39 - </div> 40 - </CardFooter> 41 - </Card> 42 + </CardFooter> 43 + </Card> 44 + </Link> 42 45 );
+4
apps/app/lib/consts.ts
··· 9 9 ? `http://localhost?redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(OAUTH_SCOPE.join(' '))}` 10 10 : "https://cookware.social/oauth-client-metadata.json"; 11 11 12 + export const COOKWARE_API_URL = IS_DEV 13 + ? "http://localhost:4000" 14 + : "https://api.cookware.social"; 15 + 12 16 export const BLUESKY_PROFILE_URL = "https://bsky.app/profile/cookware.social"; 13 17 export const DISCORD_URL = "https://discord.gg/64nAv9QPJ9"; 14 18 export const HANDLE_RESOLUTION_SERVICE = "https://slingshot.microcosm.blue";
+22 -17
apps/app/lib/state/auth.tsx
··· 7 7 import { useClient } from "./query-provider"; 8 8 import { Did, isDid } from "@atcute/lexicons/syntax"; 9 9 import { BlueRecipesActorDefs } from "@cookware/lexicons"; 10 + import { Client } from "@atcute/client"; 10 11 11 12 type AuthContext = { 12 13 loggedIn: false; 13 14 } | { 14 15 loggedIn: true; 15 16 actorDid: Did; 17 + pdsClient: Client; 16 18 profile: { 17 19 exists: true; 18 20 profileView: BlueRecipesActorDefs.ProfileViewDetailed; ··· 23 25 24 26 const authContext = createContext<AuthContext | undefined>(undefined); 25 27 28 + export const identityResolver = new LocalActorResolver({ 29 + handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLUTION_SERVICE }), 30 + didDocumentResolver: new CompositeDidDocumentResolver({ 31 + methods: { 32 + plc: new PlcDidDocumentResolver(), 33 + web: new WebDidDocumentResolver(), 34 + }, 35 + }), 36 + }); 37 + 26 38 export const AuthProvider = ({ children }: PropsWithChildren) => { 27 39 const { client, setHandler } = useClient(); 28 40 const [loading, setLoading] = useState<boolean>(true); 29 41 const [loggedIn, setLoggedIn] = useState<boolean>(false); 30 42 const [actorDid, setActorDid] = useState<Did>(); 43 + const [pdsClient, setPdsClient] = useState<Client>(); 31 44 const [hasProfile, setHasProfile] = useState<boolean>(false); 32 45 const [profileView, setProfileView] = useState<BlueRecipesActorDefs.ProfileViewDetailed>(); 33 46 34 47 useLayoutEffect(() => { 35 48 configureOAuth({ 49 + identityResolver, 36 50 metadata: { 37 51 client_id: CLIENT_ID, 38 52 redirect_uri: REDIRECT_URI, 39 53 }, 40 - identityResolver: new LocalActorResolver({ 41 - handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLUTION_SERVICE }), 42 - didDocumentResolver: new CompositeDidDocumentResolver({ 43 - methods: { 44 - plc: new PlcDidDocumentResolver(), 45 - web: new WebDidDocumentResolver(), 46 - }, 47 - }), 48 - }), 49 54 }); 50 55 51 56 const params = new URLSearchParams(location.hash.slice(1)); ··· 56 61 finalizeAuthorization(params) 57 62 .then(({ session }) => { 58 63 localStorage.setItem('lastSignedIn', session.info.sub); 59 - setHandler(new OAuthUserAgent(session)); 64 + const agent = new OAuthUserAgent(session); 65 + setHandler(agent); 60 66 setActorDid(session.info.sub); 61 67 setLoggedIn(true); 68 + setPdsClient(new Client({ handler: agent })); 62 69 }); 63 70 } else { 64 71 const existing = localStorage.getItem('lastSignedIn'); ··· 68 75 }; 69 76 getSession(existing, { allowStale: true }) 70 77 .then(session => { 78 + localStorage.setItem('lastSignedIn', session.info.sub); 79 + const agent = new OAuthUserAgent(session); 71 80 setHandler(new OAuthUserAgent(session)); 72 81 setActorDid(session.info.sub); 73 82 setLoggedIn(true); 83 + setPdsClient(new Client({ handler: agent })); 74 84 setLoading(false); 75 85 }); 76 86 } ··· 91 101 } else { 92 102 setHasProfile(true); 93 103 setProfileView(res.data); 104 + if (loading) setLoading(false); 94 105 } 95 106 }); 96 - console.log('Do check for profile...'); 97 107 } 98 108 }, [loggedIn]); 99 109 100 - useEffect(() => { 101 - if (!loading && !hasProfile) { 102 - console.log("No profile -- todo redirect"); 103 - } 104 - }, [loading, hasProfile]); 105 - 106 110 if (loading) return <>Loading...</>; 107 111 108 112 return ( ··· 111 115 ? { 112 116 loggedIn, 113 117 actorDid: actorDid!, 118 + pdsClient: pdsClient!, 114 119 profile: hasProfile ? { 115 120 exists: hasProfile, 116 121 profileView: profileView!,
+2 -1
apps/app/lib/state/query-provider.tsx
··· 2 2 3 3 import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; 4 4 import { Client, CredentialManager, FetchHandler, FetchHandlerObject } from "@atcute/client"; 5 + import { COOKWARE_API_URL } from "../consts"; 5 6 6 7 type QueryContext = { 7 8 client: Client; ··· 14 15 export const ClientProvider = ({ children }: PropsWithChildren) => { 15 16 const [isLoaded, setIsLoaded] = useState(false); 16 17 const [handler, setHandler] = useState<QueryContext["handler"]>( 17 - new CredentialManager({ service: 'http://localhost:4000' }), 18 + new CredentialManager({ service: COOKWARE_API_URL }), 18 19 ); 19 20 const [client, setClient] = useState<QueryContext["client"]>(new Client({ handler })); 20 21
+1
apps/app/package.json
··· 9 9 "lint": "eslint" 10 10 }, 11 11 "dependencies": { 12 + "@atcute/atproto": "catalog:", 12 13 "@atcute/client": "catalog:", 13 14 "@atcute/identity": "catalog:", 14 15 "@atcute/identity-resolver": "catalog:",
+1
apps/app/tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 + "types": ["@atcute/atproto", "@cookware/lexicons"], 3 4 "target": "ES2017", 4 5 "lib": ["dom", "dom.iterable", "esnext"], 5 6 "allowJs": true,
+1
bun.lock
··· 41 41 "name": "app", 42 42 "version": "0.1.0", 43 43 "dependencies": { 44 + "@atcute/atproto": "catalog:", 44 45 "@atcute/client": "catalog:", 45 46 "@atcute/identity": "catalog:", 46 47 "@atcute/identity-resolver": "catalog:",