diff --git a/bun.lockb b/bun.lockb index 033042b2..90ac2ee4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 900ca126..7c66e8cf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@tauri-apps/cli": "^2.0.0-rc.12", "@tauri-apps/plugin-fs": "^2.0.0-rc.2", - "jazz-nodejs": "0.7.35-unique.2", + "jazz-nodejs": "0.7.35-guest-auth.5", "react-icons": "^5.3.0" }, "devDependencies": { diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index c4d3ec97..ef9144e7 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -1,32 +1,30 @@ -import { SignedInClient } from "@/components/custom/clerk/signed-in-client" +"use client" + import { Sidebar } from "@/components/custom/sidebar/sidebar" -import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider" -import { currentUser } from "@clerk/nextjs/server" import { CommandPalette } from "@/components/custom/command-palette/command-palette" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { usePathname } from "next/navigation" +import PublicHomeRoute from "@/components/routes/public/PublicHomeRoute" -export default async function PageLayout({ children }: { children: React.ReactNode }) { - const user = await currentUser() +export default function PageLayout({ children }: { children: React.ReactNode }) { + const { me } = useAccountOrGuest() + const pathname = usePathname() - if (!user) { - return children + if (me._type === "Anonymous" && pathname === "/") { + return } return ( - - - -
- - +
+ -
-
- {children} -
-
-
- - - + {me._type !== "Anonymous" && } + +
+
+ {children} +
+
+
) } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index d2e3d43a..488b22b5 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -8,6 +8,7 @@ import { Toaster } from "@/components/ui/sonner" import { ConfirmProvider } from "@/lib/providers/confirm-provider" import { DeepLinkProvider } from "@/lib/providers/deep-link-provider" import { GeistMono, GeistSans } from "./fonts" +import { JazzAndAuth } from "@/lib/providers/jazz-provider" export const metadata: Metadata = { title: "Learn Anything", @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index ec947915..54c4d1c7 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -14,6 +14,8 @@ import { LinkSection } from "./partial/link-section" import { PageSection } from "./partial/page-section" import { TopicSection } from "./partial/topic-section" import { ProfileSection } from "./partial/profile-section" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { SignInButton } from "@clerk/nextjs" interface SidebarContextType { isCollapsed: boolean @@ -109,7 +111,9 @@ const LogoAndSearch: React.FC = React.memo(() => { LogoAndSearch.displayName = "LogoAndSearch" const SidebarContent: React.FC = React.memo(() => { + const { me } = useAccountOrGuest() const pathname = usePathname() + return ( <> - + + {me._type === "Account" ? ( + + ) : ( +
+ Fake profile section +
+ )} ) }) diff --git a/web/components/routes/public/Autocomplete.tsx b/web/components/routes/public/Autocomplete.tsx index 4315cdbf..7c022a83 100644 --- a/web/components/routes/public/Autocomplete.tsx +++ b/web/components/routes/public/Autocomplete.tsx @@ -13,7 +13,7 @@ interface GraphNode { interface AutocompleteProps { topics: GraphNode[] - onSelect: (topic: GraphNode) => void + onSelect: (topic: string) => void onInputChange: (value: string) => void } @@ -46,18 +46,16 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl const handleSelect = useCallback( (topic: GraphNode) => { - setInputValue(topic.prettyName) + // setInputValue(topicPrettyName) setOpen(false) - onSelect(topic) + onSelect(topic.name) }, [onSelect] ) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === "Enter" && filteredTopics.length > 0) { - handleSelect(filteredTopics[0]) - } else if ((e.key === "Backspace" || e.key === "Delete") && inputRef.current?.value === "") { + if ((e.key === "Backspace" || e.key === "Delete") && inputRef.current?.value === "") { setOpen(true) setIsInitialOpen(true) } @@ -65,7 +63,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl setHasInteracted(true) } }, - [filteredTopics, handleSelect, hasInteracted] + [hasInteracted] ) const handleInputChange = useCallback( @@ -143,6 +141,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl {filteredTopics.map((topic, index) => ( handleSelect(topic)} className="min-h-10 rounded-none px-3 py-1.5" > diff --git a/web/components/routes/public/PublicHomeRoute.tsx b/web/components/routes/public/PublicHomeRoute.tsx index 8f9e6344..ed694a66 100644 --- a/web/components/routes/public/PublicHomeRoute.tsx +++ b/web/components/routes/public/PublicHomeRoute.tsx @@ -23,8 +23,8 @@ export function PublicHomeRoute() { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] const [filterQuery, setFilterQuery] = React.useState("") - const handleTopicSelect = (topicName: string) => { - router.push(`/${topicName}`) + const handleTopicSelect = (topic: string) => { + router.replace(`/${topic}`) } const handleInputChange = (value: string) => { @@ -34,11 +34,7 @@ export function PublicHomeRoute() { return ( <>
- handleTopicSelect(val)} - filter_query={filterQuery} - /> +
@@ -53,11 +49,7 @@ export function PublicHomeRoute() { > I want to learn - handleTopicSelect(topic.name)} - onInputChange={handleInputChange} - /> +
diff --git a/web/components/routes/topics/detail/Header.tsx b/web/components/routes/topics/detail/Header.tsx index 0b9a9834..244dbc06 100644 --- a/web/components/routes/topics/detail/Header.tsx +++ b/web/components/routes/topics/detail/Header.tsx @@ -4,15 +4,16 @@ import * as React from "react" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" import { ListOfTopics, Topic } from "@/lib/schema" import { LearningStateSelector } from "@/components/custom/learning-state-selector" -import { useAccount } from "@/lib/providers/jazz-provider" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { LearningStateValue } from "@/lib/constants" +import { toast } from "sonner" interface TopicDetailHeaderProps { topic: Topic } export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) { - const { me } = useAccount({ + const { me } = useAccountOrGuest({ root: { topicsWantToLearn: [], topicsLearning: [], @@ -26,34 +27,44 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic } learningState: LearningStateValue } | null = null - const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1 + const wantToLearnIndex = + me?._type === "Anonymous" ? -1 : (me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1) if (wantToLearnIndex !== -1) { p = { index: wantToLearnIndex, - topic: me?.root.topicsWantToLearn[wantToLearnIndex], + // TODO: fix this type error by doing better conditionals on both index and p + topic: me && me._type !== "Anonymous" ? me.root.topicsWantToLearn[wantToLearnIndex] : undefined, learningState: "wantToLearn" } } - const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1 + const learningIndex = + me?._type === "Anonymous" ? -1 : (me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1) if (learningIndex !== -1) { p = { index: learningIndex, - topic: me?.root.topicsLearning[learningIndex], + topic: me && me._type !== "Anonymous" ? me?.root.topicsLearning[learningIndex] : undefined, learningState: "learning" } } - const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1 + const learnedIndex = + me?._type === "Anonymous" ? -1 : (me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1) if (learnedIndex !== -1) { p = { index: learnedIndex, - topic: me?.root.topicsLearned[learnedIndex], + topic: me && me._type !== "Anonymous" ? me?.root.topicsLearned[learnedIndex] : undefined, learningState: "learned" } } const handleAddToProfile = (learningState: LearningStateValue) => { + if (me?._type === "Anonymous") { + // TODO: handle better + toast.error("You need to sign in to add links to your personal list.") + return + } + const topicLists: Record = { wantToLearn: me?.root.topicsWantToLearn, learning: me?.root.topicsLearning, diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index 8688ceea..1bd5f42c 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -4,9 +4,8 @@ import React, { useMemo, useRef } from "react" import { TopicDetailHeader } from "./Header" import { TopicSections } from "./partials/topic-sections" import { atom } from "jotai" -import { useAccount, useCoState } from "@/lib/providers/jazz-provider" -import { Topic } from "@/lib/schema" -import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { useTopicData } from "@/hooks/use-topic-data" interface TopicDetailRouteProps { topicName: string @@ -15,10 +14,8 @@ interface TopicDetailRouteProps { export const openPopoverForIdAtom = atom(null) export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { - const { me } = useAccount({ root: { personalLinks: [] } }) - - const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) - const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } }) + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const { topic } = useTopicData(topicName, me) // const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks) const linksRefDummy = useRef<(HTMLLIElement | null)[]>([]) const containerRefDummy = useRef(null) @@ -37,8 +34,6 @@ export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { setActiveIndex={() => {}} linkRefs={linksRefDummy} containerRef={containerRefDummy} - me={me} - personalLinks={me.root.personalLinks} /> ) diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx index 1e119bd3..4758f01e 100644 --- a/web/components/routes/topics/detail/partials/link-item.tsx +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -13,6 +13,7 @@ import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils" import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema" import { openPopoverForIdAtom } from "../TopicDetailRoute" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" interface LinkItemProps { topic: Topic @@ -20,23 +21,24 @@ interface LinkItemProps { isActive: boolean index: number setActiveIndex: (index: number) => void - me: { - root: { - personalLinks: PersonalLinkLists - } & UserRoot - } & LaAccount - personalLinks: PersonalLinkLists } export const LinkItem = React.memo( React.forwardRef( - ({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => { + ({ topic, link, isActive, index, setActiveIndex }, ref) => { const router = useRouter() const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }); + + const personalLinks = useMemo(() => { + if (!me || me._type === "Anonymous") return undefined; + return me?.root?.personalLinks || [] + }, [me]) + const personalLink = useMemo(() => { - return personalLinks.find(pl => pl?.link?.id === link.id) + return personalLinks?.find(pl => pl?.link?.id === link.id) }, [personalLinks, link.id]) const selectedLearningState = useMemo(() => { @@ -53,6 +55,14 @@ export const LinkItem = React.memo( const handleSelectLearningState = useCallback( (learningState: LearningStateValue) => { + if (!personalLinks || !me || me?._type === "Anonymous") { + if (me?._type === "Anonymous") { + // TODO: handle better + toast.error("You need to sign in to add links to your personal list.") + } + return + }; + const defaultToast = { duration: 5000, position: "bottom-right" as const, diff --git a/web/components/routes/topics/detail/partials/section.tsx b/web/components/routes/topics/detail/partials/section.tsx index f4b8398d..7f816432 100644 --- a/web/components/routes/topics/detail/partials/section.tsx +++ b/web/components/routes/topics/detail/partials/section.tsx @@ -11,24 +11,9 @@ interface SectionProps { startIndex: number linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]> setActiveIndex: (index: number) => void - me: { - root: { - personalLinks: PersonalLinkLists - } & UserRoot - } & LaAccount - personalLinks: PersonalLinkLists } -export function Section({ - topic, - section, - activeIndex, - setActiveIndex, - startIndex, - linkRefs, - me, - personalLinks -}: SectionProps) { +export function Section({ topic, section, activeIndex, setActiveIndex, startIndex, linkRefs }: SectionProps) { const [nLinksToLoad, setNLinksToLoad] = useState(10) const linksToLoad = useMemo(() => { @@ -55,8 +40,6 @@ export function Section({ ref={el => { linkRefs.current[startIndex + index] = el }} - me={me} - personalLinks={personalLinks} /> ) : ( diff --git a/web/components/routes/topics/detail/partials/topic-sections.tsx b/web/components/routes/topics/detail/partials/topic-sections.tsx index b4c13dc7..a8b6ed5f 100644 --- a/web/components/routes/topics/detail/partials/topic-sections.tsx +++ b/web/components/routes/topics/detail/partials/topic-sections.tsx @@ -9,12 +9,6 @@ interface TopicSectionsProps { setActiveIndex: (index: number) => void linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]> containerRef: React.RefObject - me: { - root: { - personalLinks: PersonalLinkLists - } & UserRoot - } & LaAccount - personalLinks: PersonalLinkLists } export function TopicSections({ @@ -24,8 +18,6 @@ export function TopicSections({ setActiveIndex, linkRefs, containerRef, - me, - personalLinks }: TopicSectionsProps) { return (
@@ -42,8 +34,6 @@ export function TopicSections({ setActiveIndex={setActiveIndex} startIndex={sections.slice(0, sectionIndex).reduce((acc, s) => acc + (s?.links?.length || 0), 0)} linkRefs={linkRefs} - me={me} - personalLinks={personalLinks} /> ) )} diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts new file mode 100644 index 00000000..8280762b --- /dev/null +++ b/web/hooks/use-topic-data.ts @@ -0,0 +1,15 @@ +import { useMemo } from "react" +import { useCoState } from "@/lib/providers/jazz-provider" +import { PublicGlobalGroup } from "@/lib/schema/master/public-group" +import { Account, AnonymousJazzAgent, ID } from "jazz-tools" +import { Link, Topic } from "@/lib/schema" + +const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID + +export function useTopicData(topicName: string, me: Account | AnonymousJazzAgent | undefined) { + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, GLOBAL_GROUP_ID, me), [topicName, me]) + + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } }) + + return { topic } +} diff --git a/web/lib/providers/jazz-provider.tsx b/web/lib/providers/jazz-provider.tsx index e5b8fb80..3ca9ca9a 100644 --- a/web/lib/providers/jazz-provider.tsx +++ b/web/lib/providers/jazz-provider.tsx @@ -3,104 +3,27 @@ import { createJazzReactApp } from "jazz-react" import { LaAccount } from "@/lib/schema" import { useClerk } from "@clerk/nextjs" -import { createContext, useMemo, useState } from "react" -import { AuthMethodCtx } from "jazz-react" +import { useJazzClerkAuth } from "jazz-react-auth-clerk" const Jazz = createJazzReactApp({ AccountSchema: LaAccount }) -export const { useAccount, useCoState, useAcceptInvite } = Jazz +export const { useAccount, useAccountOrGuest, useCoState, useAcceptInvite } = Jazz -export function JazzProvider({ children }: { children: React.ReactNode }) { - return {children} -} - -export const JazzClerkAuthCtx = createContext<{ - errors: string[] -}>({ - errors: [] -}) - -export function JazzClerkAuth({ children }: { children: React.ReactNode }) { +export function JazzAndAuth({ children }: { children: React.ReactNode }) { const clerk = useClerk() - const [errors, setErrors] = useState([]) - const authMethod = useMemo(() => { - return new BrowserClerkAuth( - { - onError: error => { - void clerk.signOut() - setErrors(errors => [...errors, error.toString()]) - } - }, - clerk - ) - }, [clerk]) + const [auth, state] = useJazzClerkAuth(clerk) return ( - - {children} - + <> + {state.errors.map((error) => ( +
{error}
+ ))} + + {children} + + ) -} - -import { Account, AuthMethod, AuthResult, ID } from "jazz-tools" -import type { LoadedClerk } from "@clerk/types" -import { AgentSecret } from "cojson" - -export class BrowserClerkAuth implements AuthMethod { - constructor( - public driver: BrowserClerkAuth.Driver, - private readonly clerkClient: LoadedClerk - ) {} - - async start(): Promise { - if (this.clerkClient.user) { - const storedCredentials = this.clerkClient.user.unsafeMetadata - if (storedCredentials.jazzAccountID) { - if (!storedCredentials.jazzAccountSecret) { - throw new Error("No secret for existing user") - } - return { - type: "existing", - credentials: { - accountID: storedCredentials.jazzAccountID as ID, - secret: storedCredentials.jazzAccountSecret as AgentSecret - }, - onSuccess: () => {}, - onError: (error: string | Error) => { - this.driver.onError(error) - } - } - } else { - return { - type: "new", - creationProps: { - name: this.clerkClient.user.fullName || this.clerkClient.user.username || this.clerkClient.user.id - }, - saveCredentials: async (credentials: { accountID: ID; secret: AgentSecret }) => { - await this.clerkClient.user?.update({ - unsafeMetadata: { - jazzAccountID: credentials.accountID, - jazzAccountSecret: credentials.secret - } - }) - }, - onSuccess: () => {}, - onError: (error: string | Error) => { - this.driver.onError(error) - } - } - } - } else { - throw new Error("Not signed in") - } - } -} - -export namespace BrowserClerkAuth { - export interface Driver { - onError: (error: string | Error) => void - } -} +} \ No newline at end of file diff --git a/web/middleware.ts b/web/middleware.ts index 3059c4a8..92bdc125 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -1,19 +1,23 @@ -import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server" +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' -const publicRoutes = ["/", "/sign-in(.*)", "/sign-up(.*)"] -const isPublicRoute = createRouteMatcher(publicRoutes) +const isPublicRoute = createRouteMatcher([ + '/sign-in(.*)', + '/sign-up(.*)', + '/', + '/:topicName(.*)' +]) export default clerkMiddleware((auth, request) => { - if (!isPublicRoute(request)) { - auth().protect() - } + if (!isPublicRoute(request)) { + auth().protect() + } }) export const config = { - matcher: [ - // Skip Next.js internals and all static files, unless found in search params - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", - // Always run for API routes - "/(api|trpc)(.*)" - ] + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes + '/(api|trpc)(.*)' + ] } diff --git a/web/package.json b/web/package.json index 0009e03b..50c1db55 100644 --- a/web/package.json +++ b/web/package.json @@ -70,9 +70,10 @@ "date-fns": "^3.6.0", "framer-motion": "^11.5.4", "geist": "^1.3.1", - "jazz-react": "0.7.35-unique.2", - "jazz-react-auth-clerk": "0.7.33-new-auth.1", - "jazz-tools": "0.7.35-unique.2", + "jazz-react": "0.7.35-guest-auth.5", + "jazz-browser-auth-clerk": "0.7.35-guest-auth.5", + "jazz-react-auth-clerk": "0.7.35-guest-auth.5", + "jazz-tools": "0.7.35-guest-auth.5", "jotai": "^2.9.3", "lowlight": "^3.1.0", "lucide-react": "^0.429.0",