- {searchResults.length > 0 ? (
+ {Object.values(searchResults).some(arr => arr.length > 0) ? (
- {searchText && searchResults.length === 0 && !showAiSearch && (
+ {searchText && !showAiSearch && (
setShowAiSearch(true)}
>
- ✨ Didn't find what you were looking for? Ask AI
+ ✨ Didn't find what you were looking for? Ask AI
)}
{showAiSearch &&
}
diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx
index 7e89d818..1e119bd3 100644
--- a/web/components/routes/topics/detail/partials/link-item.tsx
+++ b/web/components/routes/topics/detail/partials/link-item.tsx
@@ -100,7 +100,7 @@ export const LinkItem = React.memo(
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
- [personalLink, personalLinks, me, link, router, setOpenPopoverForId]
+ [personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic]
)
const handlePopoverOpenChange = useCallback(
diff --git a/web/components/ui/CommandPalette.tsx b/web/components/ui/CommandPalette.tsx
new file mode 100644
index 00000000..d860e500
--- /dev/null
+++ b/web/components/ui/CommandPalette.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import { AnimatePresence, motion } from "framer-motion"
+import { useEffect, useState, KeyboardEvent as ReactKeyboardEvent } from "react"
+import { Icon } from "../la-editor/components/ui/icon"
+import { linkShowCreateAtom } from "@/store/link"
+import { generateUniqueSlug } from "@/lib/utils"
+import { useAtom } from "jotai"
+import { PersonalPage } from "@/lib/schema/personal-page"
+import { useRouter } from "next/navigation"
+import { useAccount } from "@/lib/providers/jazz-provider"
+import { toast } from "sonner"
+
+export function CommandPalette() {
+ const [showPalette, setShowPalette] = useState(false)
+ const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
+ const router = useRouter()
+ const { me } = useAccount()
+
+ const [commands, setCommands] = useState<
+ { name: string; icon?: React.ReactNode; keybind?: string[]; action: () => void }[]
+ >([
+ {
+ name: "Create new link",
+ icon:
,
+ // keybind: ["Ctrl", "K"],
+ action: () => {
+ if (window.location.pathname !== "/") {
+ router.push("/")
+ }
+ setShowCreate(true)
+ }
+ },
+ {
+ name: "Create page",
+ icon:
,
+ // keybind: ["Ctrl", "P"],
+ action: () => {
+ const personalPages = me?.root?.personalPages?.toJSON() || []
+ const slug = generateUniqueSlug(personalPages, "Untitled Page")
+
+ const newPersonalPage = PersonalPage.create(
+ {
+ title: "Untitled Page",
+ slug: slug,
+ content: ""
+ },
+ { owner: me._owner }
+ )
+
+ me.root?.personalPages?.push(newPersonalPage)
+
+ router.push(`/pages/${newPersonalPage.id}`)
+ }
+ }
+ // {
+ // name: "Assign status..",
+ // // icon:
,
+ // // keybind: ["Ctrl", "P"],
+ // action: () => {}
+ // }
+ ])
+ const [searchTerm, setSearchTerm] = useState("")
+ const [commandResults, setCommandResults] = useState(commands)
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "k") {
+ event.preventDefault()
+ setShowPalette(prev => !prev)
+ } else if (showPalette) {
+ if (["Escape", "Enter", "ArrowDown", "ArrowUp"].includes(event.key)) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Handle the key events here
+ if (event.key === "Escape") {
+ setShowPalette(false)
+ } else if (event.key === "Enter" && commandResults.length > 0) {
+ commandResults[selectedIndex].action()
+ setShowPalette(false)
+ } else if (event.key === "ArrowDown") {
+ setSelectedIndex(prevIndex => (prevIndex < commandResults.length - 1 ? prevIndex + 1 : prevIndex))
+ } else if (event.key === "ArrowUp") {
+ setSelectedIndex(prevIndex => (prevIndex > 0 ? prevIndex - 1 : prevIndex))
+ }
+ }
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown, true)
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown, true)
+ }
+ }, [showPalette, commandResults, selectedIndex])
+
+ // Remove the separate handleKeyDown function for the input
+ // as we're now handling all key events in the global listener
+
+ if (!showPalette) return null
+
+ return (
+
+ setShowPalette(false)}
+ >
+ e.stopPropagation()}
+ className="relative h-fit w-[600px] rounded-lg border border-slate-400/20 bg-white drop-shadow-xl dark:bg-neutral-900"
+ >
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="Search commands..."
+ aria-label="Search commands"
+ autoFocus
+ />
+
+
+
+
+
+ )
+}
diff --git a/web/lib/providers/jazz-provider.tsx b/web/lib/providers/jazz-provider.tsx
index 2dbd8dbf..e5b8fb80 100644
--- a/web/lib/providers/jazz-provider.tsx
+++ b/web/lib/providers/jazz-provider.tsx
@@ -1,24 +1,106 @@
"use client"
-import { createJazzReactContext, DemoAuth } from "jazz-react"
-import { AuthUI } from "@/components/custom/auth-ui"
+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"
-const appName = process.env.NEXT_PUBLIC_APP_NAME!
-
-const auth = DemoAuth
({
- appName,
- Component: AuthUI,
- accountSchema: LaAccount
-})
-
-const Jazz = createJazzReactContext({
- auth,
- peer: "wss://mesh.jazz.tools/?key=example@gmail.com"
+const Jazz = createJazzReactApp({
+ AccountSchema: LaAccount
})
export const { useAccount, useCoState, useAcceptInvite } = Jazz
export function JazzProvider({ children }: { children: React.ReactNode }) {
- return {children}
+ return {children}
+}
+
+export const JazzClerkAuthCtx = createContext<{
+ errors: string[]
+}>({
+ errors: []
+})
+
+export function JazzClerkAuth({ 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])
+
+ return (
+
+ {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
+ }
}
diff --git a/web/lib/utils/canvas.ts b/web/lib/utils/canvas.ts
new file mode 100644
index 00000000..232215cc
--- /dev/null
+++ b/web/lib/utils/canvas.ts
@@ -0,0 +1,52 @@
+/**
+ * Resizes the canvas to match the size it is being displayed.
+ *
+ * @param canvas the canvas to resize
+ * @returns `true` if the canvas was resized
+ */
+export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
+ // Get the size the browser is displaying the canvas in device pixels.
+ let dpr = window.devicePixelRatio
+ let {width, height} = canvas.getBoundingClientRect()
+ let display_width = Math.round(width * dpr)
+ let display_height = Math.round(height * dpr)
+
+ let need_resize = canvas.width != display_width ||
+ canvas.height != display_height
+
+ if (need_resize) {
+ canvas.width = display_width
+ canvas.height = display_height
+ }
+
+ return need_resize
+}
+
+export interface CanvasResizeObserver {
+ /** Canvas was resized since last check. Set it to `false` to reset. */
+ resized: boolean
+ canvas: HTMLCanvasElement
+ observer: ResizeObserver
+}
+
+export function resize(observer: CanvasResizeObserver): boolean {
+ let resized = resizeCanvasToDisplaySize(observer.canvas)
+ observer.resized ||= resized
+ return resized
+}
+
+export function resizeObserver(canvas: HTMLCanvasElement): CanvasResizeObserver {
+ let cro: CanvasResizeObserver = {
+ resized: false,
+ canvas: canvas,
+ observer: null!,
+ }
+ cro.observer = new ResizeObserver(resize.bind(null, cro))
+ resize(cro)
+ cro.observer.observe(canvas)
+ return cro
+}
+
+export function clear(observer: CanvasResizeObserver): void {
+ observer.observer.disconnect()
+}
diff --git a/web/lib/utils/schedule.ts b/web/lib/utils/schedule.ts
new file mode 100644
index 00000000..0908ef8c
--- /dev/null
+++ b/web/lib/utils/schedule.ts
@@ -0,0 +1,148 @@
+export interface Scheduler {
+ trigger: (...args: Args) => void,
+ clear: () => void,
+}
+
+/**
+ * Creates a callback that is debounced and cancellable. The debounced callback is called on **trailing** edge.
+ *
+ * @param callback The callback to debounce
+ * @param wait The duration to debounce in milliseconds
+ *
+ * @example
+ * ```ts
+ * const debounce = schedule.debounce((message: string) => console.log(message), 250)
+ * debounce.trigger('Hello!')
+ * debounce.clear() // clears a timeout in progress
+ * ```
+ */
+export function debounce(
+ callback: (...args: Args) => void,
+ wait?: number,
+): Debounce {
+ return new Debounce(callback, wait)
+}
+
+export class Debounce implements Scheduler {
+ timeout_id: ReturnType | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public wait?: number
+ ) {}
+
+ trigger(...args: Args): void {
+ if (this.timeout_id !== undefined) {
+ this.clear()
+ }
+ this.timeout_id = setTimeout(() => {
+ this.callback(...args)
+ }, this.wait)
+ }
+
+ clear(): void {
+ clearTimeout(this.timeout_id)
+ }
+}
+
+/**
+ * Creates a callback that is throttled and cancellable. The throttled callback is called on **trailing** edge.
+ *
+ * @param callback The callback to throttle
+ * @param wait The duration to throttle
+ *
+ * @example
+ * ```ts
+ * const throttle = schedule.throttle((val: string) => console.log(val), 250)
+ * throttle.trigger('my-new-value')
+ * throttle.clear() // clears a timeout in progress
+ * ```
+ */
+export function throttle(
+ callback: (...args: Args) => void,
+ wait?: number,
+): Throttle {
+ return new Throttle(callback, wait)
+}
+
+export class Throttle implements Scheduler {
+ is_throttled = false
+ timeout_id: ReturnType | undefined
+ last_args: Args | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public wait?: number
+ ) {}
+
+ trigger(...args: Args): void {
+ this.last_args = args
+ if (this.is_throttled) {
+ return
+ }
+ this.is_throttled = true
+ this.timeout_id = setTimeout(() => {
+ this.callback(...this.last_args as Args)
+ this.is_throttled = false
+ }, this.wait)
+ }
+
+ clear(): void {
+ clearTimeout(this.timeout_id)
+ this.is_throttled = false
+ }
+}
+
+/**
+ * Creates a callback throttled using `window.requestIdleCallback()`. ([MDN reference](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback))
+ *
+ * The throttled callback is called on **trailing** edge.
+ *
+ * @param callback The callback to throttle
+ * @param max_wait maximum wait time in milliseconds until the callback is called
+ *
+ * @example
+ * ```ts
+ * const idle = schedule.scheduleIdle((val: string) => console.log(val), 250)
+ * idle.trigger('my-new-value')
+ * idle.clear() // clears a timeout in progress
+ * ```
+ */
+export function scheduleIdle(
+ callback: (...args: Args) => void,
+ max_wait?: number,
+): ScheduleIdle | Throttle {
+ return typeof requestIdleCallback == "function"
+ ? new ScheduleIdle(callback, max_wait)
+ : new Throttle(callback)
+}
+
+export class ScheduleIdle implements Scheduler {
+ is_deferred = false
+ request_id: ReturnType | undefined
+ last_args: Args | undefined
+
+ constructor(
+ public callback: (...args: Args) => void,
+ public max_wait?: number,
+ ) {}
+
+ trigger(...args: Args): void {
+ this.last_args = args
+ if (this.is_deferred) {
+ return
+ }
+ this.is_deferred = true
+ this.request_id = requestIdleCallback(() => {
+ this.callback(...this.last_args as Args)
+ this.is_deferred = false
+ }, {timeout: this.max_wait})
+ }
+
+ clear(): void {
+ if (this.request_id != undefined) {
+ cancelIdleCallback(this.request_id)
+ }
+ this.is_deferred = false
+ }
+}
diff --git a/web/lib/utils/window-size.ts b/web/lib/utils/window-size.ts
new file mode 100644
index 00000000..71ad7b8d
--- /dev/null
+++ b/web/lib/utils/window-size.ts
@@ -0,0 +1,28 @@
+import * as react from "react"
+
+export type WindowSize = {
+ width: number,
+ height: number,
+}
+
+export function getWindowSize(): WindowSize {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }
+}
+
+export function useWindowSize(): WindowSize {
+
+ let [window_size, setWindowSize] = react.useState(getWindowSize())
+
+ react.useEffect(() => {
+ function handleResize() {
+ setWindowSize(getWindowSize())
+ }
+ window.addEventListener("resize", handleResize)
+ return () => window.removeEventListener("resize", handleResize)
+ }, [])
+
+ return window_size
+}
diff --git a/web/middleware.ts b/web/middleware.ts
new file mode 100644
index 00000000..9a3894dd
--- /dev/null
+++ b/web/middleware.ts
@@ -0,0 +1,18 @@
+import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
+
+const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)", "/"])
+
+export default clerkMiddleware((auth, request) => {
+ 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)(.*)"
+ ]
+}
diff --git a/web/package.json b/web/package.json
index 4b9982ef..1a786c21 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,10 +9,12 @@
"test": "jest"
},
"dependencies": {
+ "@clerk/nextjs": "^5.3.7",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0",
- "@omit/react-confirm-dialog": "^1.1.3",
+ "@nothing-but/force-graph": "^0.7.3",
+ "@omit/react-confirm-dialog": "^1.1.5",
"@omit/react-fancy-switch": "^0.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
@@ -64,9 +66,10 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
- "framer-motion": "^11.3.30",
- "jazz-react": "^0.7.34",
- "jazz-tools": "^0.7.34",
+ "framer-motion": "^11.3.31",
+ "jazz-react": "0.7.35-new-auth.1",
+ "jazz-react-auth-clerk": "0.7.33-new-auth.1",
+ "jazz-tools": "0.7.35-new-auth.0",
"jotai": "^2.9.3",
"lowlight": "^3.1.0",
"lucide-react": "^0.429.0",
@@ -90,10 +93,10 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
- "@testing-library/react": "^16.0.0",
+ "@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
- "@types/node": "^22.5.0",
- "@types/react": "^18.3.4",
+ "@types/node": "^22.5.1",
+ "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts
index 49420720..c0309f60 100644
--- a/web/tailwind.config.ts
+++ b/web/tailwind.config.ts
@@ -3,7 +3,13 @@ import { fontFamily } from "tailwindcss/defaultTheme"
const config = {
darkMode: ["class"],
- content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
+ content: [
+ "./pages/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./app/**/*.{ts,tsx}",
+ "./src/**/*.{ts,tsx}",
+ "./lib/**/*.{ts,tsx}"
+ ],
prefix: "",
safelist: [".dark"],
theme: {
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 07d2560d..581de8fa 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,12 +1,13 @@
{
"compilerOptions": {
+ "target": "esnext",
+ "module": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
- "module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,