mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
force graph, palette
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
NEXT_PUBLIC_APP_NAME="Learn Anything"
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
NEXT_PUBLIC_JAZZ_GLOBAL_GROUP=""
|
||||
NEXT_PUBLIC_JAZZ_GLOBAL_GROUP=""
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
7
web/app/(auth)/layout.tsx
Normal file
7
web/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return <main className="h-full">{children}</main>
|
||||
}
|
||||
9
web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Normal file
9
web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SignInClient } from "@/components/custom/clerk/sign-in-client"
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<SignInClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Normal file
9
web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SignUpClient } from "@/components/custom/clerk/sign-up-client"
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<SignUpClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { SignedInClient } from "@/components/custom/clerk/signed-in-client"
|
||||
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||
import PublicHomeRoute from "@/components/routes/PublicHomeRoute"
|
||||
import { PublicHomeRoute } from "@/components/routes/PublicHomeRoute"
|
||||
import { CommandPalette } from "@/components/ui/CommandPalette"
|
||||
import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider"
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
// TODO: get it from jazz/clerk
|
||||
const loggedIn = true
|
||||
export default async function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser()
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (!user) {
|
||||
return <PublicHomeRoute />
|
||||
}
|
||||
return <PublicHomeRoute />
|
||||
|
||||
return (
|
||||
<JazzClerkAuth>
|
||||
<SignedInClient>
|
||||
<JazzProvider>
|
||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<CommandPalette />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</JazzProvider>
|
||||
</SignedInClient>
|
||||
</JazzClerkAuth>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ProfileWrapper = () => {
|
||||
<div className="flex h-screen flex-col py-3 text-black dark:text-white">
|
||||
<div className="flex flex-1 flex-col rounded-3xl border border-neutral-800">
|
||||
<p className="my-10 h-[74px] border-b border-neutral-900 text-center text-2xl font-semibold">
|
||||
Oops! This account doesn't exist.
|
||||
Oops! This account doesn't exist.
|
||||
</p>
|
||||
<p className="mb-5 text-center text-lg font-semibold">Try searching for another.</p>
|
||||
<p className="mb-5 text-center text-lg font-semibold">
|
||||
@@ -91,7 +91,7 @@ export const ProfileWrapper = () => {
|
||||
onClick={clickEdit}
|
||||
className="shadow-outer ml-auto flex h-[34px] cursor-pointer flex-row space-x-2 rounded-lg bg-white px-3 text-black shadow-[1px_1px_1px_1px_rgba(0,0,0,0.3)] hover:bg-black/10 dark:bg-[#222222] dark:text-white dark:hover:opacity-60"
|
||||
>
|
||||
<LaIcon name="UserCog" className="cursor-pointer text-neutral-200" />
|
||||
<LaIcon name="UserCog" className="text-foreground cursor-pointer" />
|
||||
<span>Edit Profile</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--result: 240 5.9% 96%;
|
||||
--input: 240 5.9% 96%;
|
||||
--result: 240 5.9% 96%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
@@ -51,8 +51,8 @@
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 220 9% 10%;
|
||||
--result: 0 0% 7%;
|
||||
--input: 220 9% 10%;
|
||||
--result: 0 0% 7%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Inter as FontSans } from "next/font/google"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
||||
import "./globals.css"
|
||||
import { JazzProvider } from "@/lib/providers/jazz-provider"
|
||||
|
||||
import { ClerkProviderClient } from "@/components/custom/clerk/clerk-provider-client"
|
||||
import { JotaiProvider } from "@/lib/providers/jotai-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { ConfirmProvider } from "@/lib/providers/confirm-provider"
|
||||
@@ -25,8 +26,8 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="h-full w-full" suppressHydrationWarning>
|
||||
<body className={cn("h-full w-full font-sans antialiased", fontSans.variable)}>
|
||||
<JazzProvider>
|
||||
<ClerkProviderClient>
|
||||
<body className={cn("h-full w-full font-sans antialiased", fontSans.variable)}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<JotaiProvider>
|
||||
<ConfirmProvider>
|
||||
@@ -35,8 +36,8 @@ export default function RootLayout({
|
||||
</ConfirmProvider>
|
||||
</JotaiProvider>
|
||||
</ThemeProvider>
|
||||
</JazzProvider>
|
||||
</body>
|
||||
</body>
|
||||
</ClerkProviderClient>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
|
||||
if (root_el.current) {
|
||||
root_el.current.appendChild(md_el)
|
||||
}
|
||||
}, [root_el.current, md_el])
|
||||
}, [md_el])
|
||||
|
||||
useEffect(() => {
|
||||
let question = props.searchQuery
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { DemoAuth } from "jazz-react"
|
||||
import { Input } from "../ui/input"
|
||||
import { Button } from "../ui/button"
|
||||
|
||||
export const AuthUI: DemoAuth.Component = ({ existingUsers, logInAs, signUp, appName, loading }) => {
|
||||
const [username, setUsername] = useState<string>("")
|
||||
|
||||
if (loading) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex w-72 flex-col gap-8">
|
||||
<h1>{appName}</h1>
|
||||
<form
|
||||
className="flex w-72 flex-col gap-2"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
signUp(username)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
/>
|
||||
<Button type="submit">Sign Up</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{existingUsers.map(user => (
|
||||
<Button key={user} onClick={() => logInAs(user)}>
|
||||
Log In as "{user}"
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthUI
|
||||
7
web/components/custom/clerk/clerk-provider-client.tsx
Normal file
7
web/components/custom/clerk/clerk-provider-client.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ClerkProvider } from "@clerk/nextjs"
|
||||
|
||||
export const ClerkProviderClient = ({ children }: { children: React.ReactNode }) => {
|
||||
return <ClerkProvider>{children}</ClerkProvider>
|
||||
}
|
||||
7
web/components/custom/clerk/sign-in-client.tsx
Normal file
7
web/components/custom/clerk/sign-in-client.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SignIn } from "@clerk/nextjs"
|
||||
|
||||
export const SignInClient = () => {
|
||||
return <SignIn />
|
||||
}
|
||||
7
web/components/custom/clerk/sign-up-client.tsx
Normal file
7
web/components/custom/clerk/sign-up-client.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SignUp } from "@clerk/nextjs"
|
||||
|
||||
export const SignUpClient = () => {
|
||||
return <SignUp />
|
||||
}
|
||||
7
web/components/custom/clerk/signed-in-client.tsx
Normal file
7
web/components/custom/clerk/signed-in-client.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SignedIn } from "@clerk/nextjs"
|
||||
|
||||
export const SignedInClient = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SignedIn>{children}</SignedIn>
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function DeletePageModal({ isOpen, onClose, onConfirm, title }: D
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete "{title}"?</DialogTitle>
|
||||
<DialogTitle>Delete "{title}"?</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -72,7 +72,7 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
>
|
||||
<p className="flex items-center text-xs font-medium">
|
||||
Pages
|
||||
{pageCount && <span className="text-muted-foreground ml-1">{pageCount}</span>}
|
||||
{pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>}
|
||||
</p>
|
||||
</Button>
|
||||
<div className={cn("flex items-center gap-px pr-2")}>
|
||||
@@ -86,6 +86,8 @@ const NewPageButton: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const router = useRouter()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
@@ -248,6 +250,4 @@ const ShowAllForm: React.FC = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageSection
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import Link from "next/link"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
@@ -48,9 +49,10 @@ const MenuItem = ({
|
||||
)
|
||||
}
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { me, logOut } = useAccount({
|
||||
const { me } = useAccount({
|
||||
profile: true
|
||||
})
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const closeMenu = () => setMenuOpen(false)
|
||||
@@ -86,7 +88,7 @@ export const ProfileSection: React.FC = () => {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="LogOut" text="Log out" onClick={logOut} onClose={closeMenu} />
|
||||
<MenuItem icon="LogOut" text="Log out" onClick={signOut} onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -50,7 +50,7 @@ const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount }) =
|
||||
>
|
||||
<p className="flex items-center text-xs font-medium">
|
||||
Topics
|
||||
{topicCount && <span className="text-muted-foreground ml-1">{topicCount}</span>}
|
||||
{topicCount > 0 && <span className="text-muted-foreground ml-1">{topicCount}</span>}
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -131,6 +131,4 @@ const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicSection
|
||||
}
|
||||
@@ -1,40 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
|
||||
export default function EditProfileRoute() {
|
||||
const account = useAccount()
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<p className="h-[74px] p-[20px] text-2xl font-semibold text-white/30">Profile</p>
|
||||
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5 text-white">
|
||||
<div className="flex flex-1 flex-col text-sm text-black dark:text-white">
|
||||
<p className="h-[74px] p-[20px] text-2xl font-semibold opacity-60">Edit Profile</p>
|
||||
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5">
|
||||
<div className="flex w-full max-w-2xl align-top">
|
||||
<button className="mr-3 h-[130px] w-[130px] flex-col items-center justify-center rounded-xl border border-dashed border-white/10 bg-neutral-100 text-white/50 dark:bg-neutral-900">
|
||||
<button className="bg-input mr-3 h-[130px] w-[130px] flex-col items-center justify-center rounded-xl border border-dashed border-black/10 bg-neutral-100 dark:border-white/10">
|
||||
<p className="text-sm tracking-wide">Photo</p>
|
||||
</button>
|
||||
<div className="ml-6 flex-1 space-y-4 font-light">
|
||||
<div className="ml-6 flex-1 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
className="w-full rounded-md bg-[#121212] p-3 font-light tracking-wide text-white/70 placeholder-white/20 outline-none"
|
||||
className="bg-input w-full rounded-md p-3 tracking-wide outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/70 placeholder-white/20 outline-none"
|
||||
className="bg-input w-full rounded-md p-3 tracking-wide outline-none"
|
||||
/>
|
||||
<p className="text-white/30">learn-anything.xyz/@</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Website"
|
||||
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/30 placeholder-white/20 outline-none"
|
||||
className="bg-input tracking-wideoutline-none w-full rounded-md p-3"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Bio"
|
||||
className="h-[120px] w-full rounded-md bg-[#121212] p-3 text-left font-light tracking-wide text-white/30 placeholder-white/20 outline-none"
|
||||
className="bg-input h-[120px] w-full rounded-md p-3 text-left tracking-wide outline-none"
|
||||
/>
|
||||
<button className="mt-4 w-[120px] rounded-md bg-[#222222] px-3 py-2 font-light tracking-wide text-white/70 outline-none hover:opacity-60">
|
||||
<button className="bg-input mt-4 w-[120px] rounded-md px-3 py-2 tracking-wide outline-none hover:opacity-60">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
"use client"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
|
||||
import { glob } from "fs"
|
||||
import { ID } from "jazz-tools"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export default function PublicHomeRoute() {
|
||||
// const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
|
||||
// root: { topicGraph: [{ connectedTopics: [{}] }] }
|
||||
// })
|
||||
import * as react from "react"
|
||||
|
||||
import type * as force_graph from "./force-graph-client"
|
||||
|
||||
let graph_data_promise = import("./graph-data.json").then(a => a.default)
|
||||
let ForceGraphClient = react.lazy(() => import("./force-graph-client-lazy"))
|
||||
|
||||
export function PublicHomeRoute() {
|
||||
let raw_graph_data = react.use(graph_data_promise)
|
||||
|
||||
let graph_items = react.useMemo(() => {
|
||||
return raw_graph_data.map(
|
||||
(item): force_graph.ConnectionItem => ({
|
||||
key: item.name,
|
||||
title: item.prettyName,
|
||||
connections: item.connectedTopics
|
||||
})
|
||||
)
|
||||
}, [raw_graph_data])
|
||||
|
||||
// const graph = useMemo(() => {
|
||||
// return globalGroup?.root.topicGraph?.map(
|
||||
// topic =>
|
||||
// ({
|
||||
// name: topic.name,
|
||||
// prettyName: topic.prettyName,
|
||||
// connectedTopics: topic.connectedTopics.map(connected => connected?.name)
|
||||
// }) || []
|
||||
// )
|
||||
// }, [globalGroup?.root.topicGraph])
|
||||
// const [{}]
|
||||
// console.log(globalGroup, "graph")
|
||||
return (
|
||||
<>
|
||||
<h1>I want to learn</h1>
|
||||
<input type="text" />
|
||||
</>
|
||||
<ForceGraphClient
|
||||
raw_nodes={raw_graph_data}
|
||||
onNodeClick={val => {
|
||||
console.log("clicked", val)
|
||||
}}
|
||||
filter_query=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
84
web/components/routes/anim.ts
Normal file
84
web/components/routes/anim.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t
|
||||
}
|
||||
|
||||
export interface AnimationLoop {
|
||||
/** User callback to be called on each animation frame. */
|
||||
callback: FrameRequestCallback
|
||||
/** {@link loopFrame} bound to this loop. */
|
||||
frame: FrameRequestCallback
|
||||
/** The current frame id returned by {@link requestAnimationFrame}. */
|
||||
frame_id: number
|
||||
}
|
||||
|
||||
export function animationLoop(callback: FrameRequestCallback): AnimationLoop {
|
||||
const loop: AnimationLoop = {
|
||||
callback: callback,
|
||||
frame: t => loopFrame(loop, t),
|
||||
frame_id: 0,
|
||||
}
|
||||
return loop
|
||||
}
|
||||
export function loopFrame(loop: AnimationLoop, time: number): void {
|
||||
loop.frame_id = requestAnimationFrame(loop.frame)
|
||||
loop.callback(time)
|
||||
}
|
||||
export function loopStart(loop: AnimationLoop): void {
|
||||
loop.frame_id ||= requestAnimationFrame(loop.frame)
|
||||
}
|
||||
export function loopClear(loop: AnimationLoop): void {
|
||||
cancelAnimationFrame(loop.frame_id)
|
||||
loop.frame_id = 0
|
||||
}
|
||||
|
||||
export const DEFAULT_TARGET_FPS = 44
|
||||
|
||||
export interface FrameIterationsLimit {
|
||||
target_fps: number
|
||||
last_timestamp: number
|
||||
}
|
||||
|
||||
export function frameIterationsLimit(
|
||||
target_fps: number = DEFAULT_TARGET_FPS,
|
||||
): FrameIterationsLimit {
|
||||
return {
|
||||
target_fps,
|
||||
last_timestamp: performance.now(),
|
||||
}
|
||||
}
|
||||
export function calcIterations(limit: FrameIterationsLimit, current_time: number): number {
|
||||
let target_ms = 1000 / limit.target_fps
|
||||
let delta_time = current_time - limit.last_timestamp
|
||||
let times = Math.floor(delta_time / target_ms)
|
||||
limit.last_timestamp += times * target_ms
|
||||
return times
|
||||
}
|
||||
|
||||
export interface AlphaUpdateSteps {
|
||||
increment: number
|
||||
decrement: number
|
||||
}
|
||||
export const DEFAULT_ALPHA_UPDATE_STEPS: AlphaUpdateSteps = {
|
||||
increment: 0.03,
|
||||
decrement: 0.005,
|
||||
}
|
||||
export const updateAlpha = (
|
||||
alpha: number,
|
||||
is_playing: boolean,
|
||||
update_steps = DEFAULT_ALPHA_UPDATE_STEPS,
|
||||
): number => {
|
||||
return is_playing
|
||||
? lerp(alpha, 1, update_steps.increment)
|
||||
: lerp(alpha, 0, update_steps.decrement)
|
||||
}
|
||||
|
||||
export const DEFAULT_BUMP_TIMEOUT_DURATION = 2000
|
||||
|
||||
export const bump = (
|
||||
bump_end: number,
|
||||
duration: number = DEFAULT_BUMP_TIMEOUT_DURATION,
|
||||
): number => {
|
||||
const start = performance.now()
|
||||
const end = start + duration
|
||||
return end > bump_end ? end : bump_end
|
||||
}
|
||||
354
web/components/routes/force-graph-client-lazy.tsx
Normal file
354
web/components/routes/force-graph-client-lazy.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client"
|
||||
|
||||
import * as react from "react"
|
||||
import * as fg from "@nothing-but/force-graph"
|
||||
import {ease, trig, raf} from "@nothing-but/utils"
|
||||
|
||||
import * as schedule from "@/lib/utils/schedule"
|
||||
import * as canvas from "@/lib/utils/canvas"
|
||||
|
||||
export type RawGraphNode = {
|
||||
name: string,
|
||||
prettyName: string,
|
||||
connectedTopics: string[],
|
||||
}
|
||||
|
||||
type HSL = [hue: number, saturation: number, lightness: number]
|
||||
|
||||
const COLORS: readonly HSL[] = [
|
||||
[3, 86, 64],
|
||||
[31, 90, 69],
|
||||
[15, 87, 66]
|
||||
]
|
||||
|
||||
/* use a plain object instead of Map for faster lookups */
|
||||
type ColorMap = { [key: string]: string }
|
||||
type HSLMap = Map<fg.graph.Node, HSL>
|
||||
|
||||
const MAX_COLOR_ITERATIONS = 10
|
||||
|
||||
/**
|
||||
* Add a color to a node and all its connected nodes.
|
||||
*/
|
||||
const visitColorNode = (
|
||||
prev: fg.graph.Node,
|
||||
node: fg.graph.Node,
|
||||
hsl_map: HSLMap,
|
||||
add: HSL,
|
||||
iteration: number = 1
|
||||
): void => {
|
||||
if (iteration > MAX_COLOR_ITERATIONS) return
|
||||
|
||||
const color = hsl_map.get(node)
|
||||
|
||||
if (!color) {
|
||||
hsl_map.set(node, [...add])
|
||||
} else {
|
||||
const add_strength = MAX_COLOR_ITERATIONS / iteration
|
||||
color[0] = (color[0] + add[0] * add_strength) / (1 + add_strength)
|
||||
color[1] = (color[1] + add[1] * add_strength) / (1 + add_strength)
|
||||
color[2] = (color[2] + add[2] * add_strength) / (1 + add_strength)
|
||||
}
|
||||
|
||||
for (const edge of node.edges) {
|
||||
const other_node = edge.a === node ? edge.b : edge.a
|
||||
if (other_node === prev) continue
|
||||
visitColorNode(node, other_node, hsl_map, add, iteration + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function generateColorMap(nodes: readonly fg.graph.Node[]): ColorMap
|
||||
{
|
||||
const hls_map: HSLMap = new Map()
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]!
|
||||
const color = COLORS[i % COLORS.length]!
|
||||
visitColorNode(node, node, hls_map, color)
|
||||
}
|
||||
|
||||
const color_map: ColorMap = {}
|
||||
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
|
||||
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
|
||||
}
|
||||
|
||||
return color_map
|
||||
}
|
||||
|
||||
function generateNodesFromRawData(raw_data: RawGraphNode[]): [fg.graph.Node[], fg.graph.Edge[]]
|
||||
{
|
||||
const nodes_map = new Map<string, fg.graph.Node>()
|
||||
const edges: fg.graph.Edge[] = []
|
||||
|
||||
for (const raw of raw_data) {
|
||||
const node = fg.graph.zeroNode()
|
||||
node.key = raw.name
|
||||
node.label = raw.prettyName
|
||||
nodes_map.set(raw.name, node)
|
||||
}
|
||||
|
||||
for (const raw of raw_data) {
|
||||
const node_a = nodes_map.get(raw.name)!
|
||||
|
||||
for (const name_b of raw.connectedTopics) {
|
||||
const node_b = nodes_map.get(name_b)!
|
||||
const edge = fg.graph.connect(node_a, node_b)
|
||||
edges.push(edge)
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodes_map.values())
|
||||
|
||||
fg.graph.randomizeNodePositions(nodes, GRAPH_OPTIONS.grid_size)
|
||||
|
||||
return [nodes, edges]
|
||||
}
|
||||
|
||||
function filterNodes(
|
||||
graph: fg.graph.Graph,
|
||||
nodes: readonly fg.graph.Node[],
|
||||
edges: readonly fg.graph.Edge[],
|
||||
filter: string
|
||||
): void {
|
||||
if (filter === "") {
|
||||
graph.nodes = nodes.slice()
|
||||
graph.edges = edges.slice()
|
||||
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
|
||||
return
|
||||
}
|
||||
|
||||
// regex matching all letters of the filter (out of order)
|
||||
const regex = new RegExp(filter.split("").join(".*"), "i")
|
||||
|
||||
graph.nodes = nodes.filter((node) => regex.test(node.label))
|
||||
graph.edges = edges.filter(
|
||||
(edge) => regex.test(edge.a.label) && regex.test(edge.b.label)
|
||||
)
|
||||
|
||||
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
|
||||
}
|
||||
|
||||
const GRAPH_OPTIONS: fg.graph.Options = {
|
||||
min_move: 0.001,
|
||||
inertia_strength: 0.3,
|
||||
origin_strength: 0.01,
|
||||
repel_distance: 40,
|
||||
repel_strength: 2,
|
||||
link_strength: 0.015,
|
||||
grid_size: 500,
|
||||
}
|
||||
|
||||
const TITLE_SIZE_PX = 400
|
||||
|
||||
const simulateGraph = (
|
||||
alpha: number,
|
||||
graph: fg.graph.Graph,
|
||||
canvas: fg.canvas.CanvasState,
|
||||
vw: number,
|
||||
vh: number
|
||||
): void => {
|
||||
alpha = alpha / 10 // slow things down a bit
|
||||
|
||||
fg.graph.simulate(graph, alpha)
|
||||
|
||||
/*
|
||||
Push nodes away from the center (the title)
|
||||
*/
|
||||
let grid_radius = graph.grid.size / 2
|
||||
let origin_x = grid_radius + canvas.translate.x
|
||||
let origin_y = grid_radius + canvas.translate.y
|
||||
let vmax = Math.max(vw, vh)
|
||||
let push_radius =
|
||||
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) *
|
||||
(graph.grid.size / canvas.scale) +
|
||||
80 /* additional margin for when scrolled in */
|
||||
|
||||
for (let node of graph.nodes) {
|
||||
let dist_x = node.position.x - origin_x
|
||||
let dist_y = (node.position.y - origin_y) * 2
|
||||
let dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
|
||||
if (dist > push_radius) continue
|
||||
|
||||
let strength = ease.in_expo((push_radius - dist) / push_radius)
|
||||
|
||||
node.velocity.x += strength * (node.position.x - origin_x) * 10 * alpha
|
||||
node.velocity.y += strength * (node.position.y - origin_y) * 10 * alpha
|
||||
}
|
||||
}
|
||||
|
||||
const drawGraph = (
|
||||
canvas: fg.canvas.CanvasState,
|
||||
color_map: ColorMap
|
||||
): void => {
|
||||
fg.canvas.resetFrame(canvas)
|
||||
fg.canvas.drawEdges(canvas)
|
||||
|
||||
/*
|
||||
Draw text nodes
|
||||
*/
|
||||
let {ctx, graph} = canvas
|
||||
let {width, height} = canvas.ctx.canvas
|
||||
let max_size = Math.max(width, height)
|
||||
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
for (let node of graph.nodes) {
|
||||
|
||||
let opacity = 0.6 + ((node.mass-1) / 50) * 4
|
||||
|
||||
ctx.font = `${max_size/200 + (((node.mass-1) / 5) * (max_size/100)) / canvas.scale}px sans-serif`
|
||||
|
||||
ctx.fillStyle = node.anchor || canvas.hovered_node === node
|
||||
? `rgba(129, 140, 248, ${opacity})`
|
||||
: `hsl(${color_map[node.key as string]} / ${opacity})`
|
||||
|
||||
ctx.fillText(node.label,
|
||||
(node.position.x / graph.grid.size) * max_size,
|
||||
(node.position.y / graph.grid.size) * max_size)
|
||||
}
|
||||
}
|
||||
|
||||
class State {
|
||||
ctx: CanvasRenderingContext2D | null = null
|
||||
|
||||
nodes: fg.graph.Node[] = []
|
||||
edges: fg.graph.Edge[] = []
|
||||
graph: fg.graph.Graph = fg.graph.makeGraph(GRAPH_OPTIONS, [], [])
|
||||
gestures: fg.canvas.CanvasGestures | null = null
|
||||
|
||||
loop: raf.AnimationLoop | null = null
|
||||
bump_end = 0
|
||||
alpha = 9
|
||||
frame_iter_limit = raf.frameIterationsLimit()
|
||||
schedule_filter = schedule.scheduleIdle(filterNodes)
|
||||
ro: ResizeObserver = new ResizeObserver(() => {})
|
||||
}
|
||||
|
||||
function init(
|
||||
s : State,
|
||||
props: {
|
||||
onNodeClick: (name: string) => void
|
||||
raw_nodes: RawGraphNode[]
|
||||
canvas_el: HTMLCanvasElement | null
|
||||
}) {
|
||||
let {canvas_el, raw_nodes} = props
|
||||
|
||||
if (canvas_el == null) return
|
||||
|
||||
s.ctx = canvas_el.getContext("2d")
|
||||
if (s.ctx == null) return
|
||||
|
||||
[s.nodes, s.edges] = generateNodesFromRawData(raw_nodes)
|
||||
let color_map = generateColorMap(s.nodes)
|
||||
|
||||
s.graph = fg.graph.makeGraph(GRAPH_OPTIONS, s.nodes.slice(), s.edges.slice())
|
||||
|
||||
let canvas_state = fg.canvas.canvasState({
|
||||
ctx: s.ctx,
|
||||
graph: s.graph,
|
||||
max_scale: 3,
|
||||
init_scale: 1.7,
|
||||
init_grid_pos: trig.ZERO
|
||||
})
|
||||
|
||||
s.ro = new ResizeObserver(() => {
|
||||
if (canvas.resizeCanvasToDisplaySize(canvas_el)) {
|
||||
fg.canvas.updateTranslate(canvas_state, canvas_state.translate.x, canvas_state.translate.y)
|
||||
}
|
||||
})
|
||||
s.ro.observe(canvas_el)
|
||||
|
||||
let loop = s.loop = raf.makeAnimationLoop((time) => {
|
||||
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
|
||||
let iterations = raf.calcIterations(s.frame_iter_limit, time)
|
||||
|
||||
for (let i = Math.min(iterations, 2); i >= 0; i--) {
|
||||
s.alpha = raf.updateAlpha(s.alpha, is_active || time < s.bump_end)
|
||||
simulateGraph(s.alpha, s.graph, canvas_state, window.innerWidth, window.innerHeight)
|
||||
}
|
||||
drawGraph(canvas_state, color_map)
|
||||
})
|
||||
raf.loopStart(loop)
|
||||
|
||||
let gestures = s.gestures = fg.canvas.canvasGestures({
|
||||
canvas: canvas_state,
|
||||
onGesture: (e) => {
|
||||
switch (e.type) {
|
||||
case fg.canvas.GestureEventType.Translate:
|
||||
s.bump_end = raf.bump(s.bump_end)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeClick:
|
||||
props.onNodeClick(e.node.key as string)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeDrag:
|
||||
fg.graph.changeNodePosition(
|
||||
canvas_state.graph.grid,
|
||||
e.node,
|
||||
e.pos.x,
|
||||
e.pos.y
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateQuery(s: State, filter_query: string) {
|
||||
s.schedule_filter.trigger(s.graph, s.nodes, s.edges, filter_query)
|
||||
s.bump_end = raf.bump(s.bump_end)
|
||||
}
|
||||
|
||||
function cleanup(s: State) {
|
||||
s.loop && raf.loopClear(s.loop)
|
||||
s.gestures && fg.canvas.cleanupCanvasGestures(s.gestures)
|
||||
s.schedule_filter.clear()
|
||||
s.ro.disconnect()
|
||||
}
|
||||
|
||||
export type ForceGraphProps = {
|
||||
onNodeClick: (name: string) => void
|
||||
/**
|
||||
* Filter the displayed nodes by name.
|
||||
*
|
||||
* `""` means no filter
|
||||
*/
|
||||
filter_query: string
|
||||
raw_nodes: RawGraphNode[]
|
||||
}
|
||||
|
||||
export default function ForceGraphClient(props: ForceGraphProps): react.JSX.Element {
|
||||
|
||||
const [canvas_el, setCanvasEl] = react.useState<HTMLCanvasElement | null>(null)
|
||||
|
||||
const state = react.useRef(new State())
|
||||
|
||||
react.useEffect(() => {
|
||||
init(state.current, {
|
||||
canvas_el: canvas_el,
|
||||
onNodeClick: props.onNodeClick,
|
||||
raw_nodes: props.raw_nodes,
|
||||
})
|
||||
}, [canvas_el])
|
||||
|
||||
react.useEffect(() => {
|
||||
updateQuery(state.current, props.filter_query)
|
||||
}, [props.filter_query])
|
||||
|
||||
react.useEffect(() => {
|
||||
return () => cleanup(state.current)
|
||||
}, [])
|
||||
|
||||
return <div className="absolute inset-0 overflow-hidden">
|
||||
<canvas
|
||||
ref={setCanvasEl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-10%",
|
||||
left: "-10%",
|
||||
width: "120%",
|
||||
height: "120%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
336
web/components/routes/force-graph-client.tsx
Normal file
336
web/components/routes/force-graph-client.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
"use client"
|
||||
|
||||
import * as react from "react"
|
||||
import * as fg from "@nothing-but/force-graph"
|
||||
import { ease, trig } from "@nothing-but/utils"
|
||||
|
||||
import * as schedule from "@/lib/utils/schedule"
|
||||
import * as ws from "@/lib/utils/window-size"
|
||||
import * as canvas from "@/lib/utils/canvas"
|
||||
|
||||
import * as anim from "./anim"
|
||||
|
||||
export type ConnectionItem = {
|
||||
key: string
|
||||
title: string
|
||||
connections: string[]
|
||||
}
|
||||
|
||||
export type ForceGraphClientProps = {
|
||||
items: ConnectionItem[]
|
||||
}
|
||||
|
||||
export default function ForceGraphClient(props: ForceGraphClientProps) {
|
||||
return (
|
||||
<code>
|
||||
<pre>{JSON.stringify(props.items, null, 4)}</pre>
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
export type RawNode = {
|
||||
name: string
|
||||
prettyName: string
|
||||
connections: string[]
|
||||
}
|
||||
|
||||
type HSL = [hue: number, saturation: number, lightness: number]
|
||||
|
||||
const COLORS: readonly HSL[] = [
|
||||
[3, 86, 64],
|
||||
[31, 90, 69],
|
||||
[15, 87, 66]
|
||||
]
|
||||
|
||||
/* use a plain object instead of Map for faster lookups */
|
||||
type ColorMap = { [key: string]: string }
|
||||
type HSLMap = Map<fg.graph.Node, HSL>
|
||||
|
||||
const MAX_COLOR_ITERATIONS = 10
|
||||
|
||||
/**
|
||||
* Add a color to a node and all its connected nodes.
|
||||
*/
|
||||
const visitColorNode = (
|
||||
prev: fg.graph.Node,
|
||||
node: fg.graph.Node,
|
||||
hsl_map: HSLMap,
|
||||
add: HSL,
|
||||
iteration: number = 1
|
||||
): void => {
|
||||
if (iteration > MAX_COLOR_ITERATIONS) return
|
||||
|
||||
const color = hsl_map.get(node)
|
||||
|
||||
if (!color) {
|
||||
hsl_map.set(node, [...add])
|
||||
} else {
|
||||
const add_strength = MAX_COLOR_ITERATIONS / iteration
|
||||
color[0] = (color[0] + add[0] * add_strength) / (1 + add_strength)
|
||||
color[1] = (color[1] + add[1] * add_strength) / (1 + add_strength)
|
||||
color[2] = (color[2] + add[2] * add_strength) / (1 + add_strength)
|
||||
}
|
||||
|
||||
for (const edge of node.edges) {
|
||||
const other_node = edge.a === node ? edge.b : edge.a
|
||||
if (other_node === prev) continue
|
||||
visitColorNode(node, other_node, hsl_map, add, iteration + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const generateColorMap = (nodes: readonly fg.graph.Node[]): ColorMap => {
|
||||
const hls_map: HSLMap = new Map()
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]!
|
||||
const color = COLORS[i % COLORS.length]!
|
||||
visitColorNode(node, node, hls_map, color)
|
||||
}
|
||||
|
||||
const color_map: ColorMap = {}
|
||||
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
|
||||
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
|
||||
}
|
||||
|
||||
return color_map
|
||||
}
|
||||
|
||||
const generateNodesFromRawData = (raw_data: RawNode[]): [fg.graph.Node[], fg.graph.Edge[]] => {
|
||||
const nodes_map = new Map<string, fg.graph.Node>()
|
||||
const edges: fg.graph.Edge[] = []
|
||||
|
||||
for (const raw of raw_data) {
|
||||
const node = fg.graph.zeroNode()
|
||||
node.key = raw.name
|
||||
node.label = raw.prettyName
|
||||
nodes_map.set(raw.name, node)
|
||||
}
|
||||
|
||||
for (const raw of raw_data) {
|
||||
const node_a = nodes_map.get(raw.name)!
|
||||
|
||||
for (const name_b of raw.connections) {
|
||||
const node_b = nodes_map.get(name_b)!
|
||||
const edge = fg.graph.connect(node_a, node_b)
|
||||
edges.push(edge)
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodes_map.values())
|
||||
|
||||
fg.graph.randomizeNodePositions(nodes, graph_options.grid_size)
|
||||
|
||||
return [nodes, edges]
|
||||
}
|
||||
|
||||
const filterNodes = (
|
||||
graph: fg.graph.Graph,
|
||||
nodes: readonly fg.graph.Node[],
|
||||
edges: readonly fg.graph.Edge[],
|
||||
filter: string
|
||||
): void => {
|
||||
if (filter === "") {
|
||||
graph.nodes = nodes.slice()
|
||||
graph.edges = edges.slice()
|
||||
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
|
||||
return
|
||||
}
|
||||
|
||||
// regex matching all letters of the filter (out of order)
|
||||
const regex = new RegExp(filter.split("").join(".*"), "i")
|
||||
|
||||
graph.nodes = nodes.filter(node => regex.test(node.label))
|
||||
graph.edges = edges.filter(edge => regex.test(edge.a.label) && regex.test(edge.b.label))
|
||||
|
||||
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
|
||||
}
|
||||
|
||||
const graph_options: fg.graph.Options = {
|
||||
min_move: 0.001,
|
||||
inertia_strength: 0.3,
|
||||
origin_strength: 0.01,
|
||||
repel_distance: 40,
|
||||
repel_strength: 2,
|
||||
link_strength: 0.015,
|
||||
grid_size: 500
|
||||
}
|
||||
|
||||
const TITLE_SIZE_PX = 400
|
||||
|
||||
const simulateGraph = (
|
||||
alpha: number,
|
||||
graph: fg.graph.Graph,
|
||||
canvas: fg.canvas.CanvasState,
|
||||
vw: number,
|
||||
vh: number
|
||||
): void => {
|
||||
alpha = alpha / 10 // slow things down a bit
|
||||
|
||||
fg.graph.simulate(graph, alpha)
|
||||
|
||||
/*
|
||||
Push nodes away from the center (the title)
|
||||
*/
|
||||
const grid_radius = graph.grid.size / 2
|
||||
const origin_x = grid_radius + canvas.translate.x
|
||||
const origin_y = grid_radius + canvas.translate.y
|
||||
const vmax = Math.max(vw, vh)
|
||||
const push_radius =
|
||||
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) * (graph.grid.size / canvas.scale) +
|
||||
80 /* additional margin for when scrolled in */
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const dist_x = node.position.x - origin_x
|
||||
const dist_y = (node.position.y - origin_y) * 2
|
||||
const dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
|
||||
if (dist > push_radius) continue
|
||||
|
||||
const strength = ease.in_expo((push_radius - dist) / push_radius)
|
||||
|
||||
node.velocity.x += strength * (node.position.x - origin_x) * 10 * alpha
|
||||
node.velocity.y += strength * (node.position.y - origin_y) * 10 * alpha
|
||||
}
|
||||
}
|
||||
|
||||
const drawGraph = (canvas: fg.canvas.CanvasState, color_map: ColorMap): void => {
|
||||
fg.canvas.resetFrame(canvas)
|
||||
fg.canvas.drawEdges(canvas)
|
||||
|
||||
/*
|
||||
Draw text nodes
|
||||
*/
|
||||
const { ctx, graph } = canvas
|
||||
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const { x, y } = node.position
|
||||
const opacity = 0.6 + ((node.mass - 1) / 50) * 4
|
||||
|
||||
ctx.font = `${
|
||||
canvas.max_size / 200 + (((node.mass - 1) / 5) * (canvas.max_size / 100)) / canvas.scale
|
||||
}px sans-serif`
|
||||
|
||||
ctx.fillStyle =
|
||||
node.anchor || canvas.hovered_node === node
|
||||
? `rgba(129, 140, 248, ${opacity})`
|
||||
: `hsl(${color_map[node.key as string]} / ${opacity})`
|
||||
|
||||
ctx.fillText(node.label, (x / graph.grid.size) * canvas.max_size, (y / graph.grid.size) * canvas.max_size)
|
||||
}
|
||||
}
|
||||
|
||||
export type ForceGraphProps = {
|
||||
onNodeClick: (name: string) => void
|
||||
/**
|
||||
* Filter the displayed nodes by name.
|
||||
*
|
||||
* `""` means no filter
|
||||
*/
|
||||
filter_query: string
|
||||
raw_nodes: RawNode[]
|
||||
}
|
||||
|
||||
export const createForceGraph = (props: ForceGraphProps): react.JSX.Element => {
|
||||
if (props.raw_nodes.length === 0) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
let [nodes, edges] = generateNodesFromRawData(props.raw_nodes)
|
||||
|
||||
let color_map = generateColorMap(nodes)
|
||||
|
||||
let graph = fg.graph.makeGraph(graph_options, nodes.slice(), edges.slice())
|
||||
|
||||
/*
|
||||
Filter nodes when the filter query changes
|
||||
*/
|
||||
let schedule_filter_nodes = schedule.scheduleIdle(filterNodes)
|
||||
react.useEffect(() => {
|
||||
schedule_filter_nodes.trigger(graph, nodes, edges, props.filter_query)
|
||||
bump_end = anim.bump(bump_end)
|
||||
}, [props.filter_query])
|
||||
|
||||
let canvas_el = react.useRef<HTMLCanvasElement>(null)
|
||||
|
||||
react.useEffect(() => {
|
||||
let el = canvas_el.current
|
||||
if (!el) return
|
||||
|
||||
let ctx = el.getContext("2d")
|
||||
if (!ctx) throw new Error("no context")
|
||||
|
||||
let canvas_state = fg.canvas.canvasState({
|
||||
ctx,
|
||||
graph,
|
||||
max_scale: 3,
|
||||
init_scale: 1.7,
|
||||
init_grid_pos: trig.ZERO
|
||||
})
|
||||
|
||||
let window_size = ws.useWindowSize()
|
||||
|
||||
let alpha = 0 // 0 - 1
|
||||
let bump_end = anim.bump(0)
|
||||
let frame_iter_limit = anim.frameIterationsLimit()
|
||||
|
||||
let loop = anim.animationLoop(time => {
|
||||
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
|
||||
let iterations = anim.calcIterations(frame_iter_limit, time)
|
||||
|
||||
for (let i = Math.min(iterations, 2); i >= 0; i--) {
|
||||
alpha = anim.updateAlpha(alpha, is_active || time < bump_end)
|
||||
simulateGraph(alpha, graph, canvas_state, window_size.width, window_size.height)
|
||||
}
|
||||
drawGraph(canvas_state, color_map)
|
||||
})
|
||||
anim.loopStart(loop)
|
||||
|
||||
let ro = new ResizeObserver(() => {
|
||||
if (canvas.resizeCanvasToDisplaySize(el)) {
|
||||
fg.canvas.updateTranslate(canvas_state, canvas_state.translate.x, canvas_state.translate.y)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
|
||||
let gestures = fg.canvas.canvasGestures({
|
||||
canvas: canvas_state,
|
||||
onGesture: e => {
|
||||
switch (e.type) {
|
||||
case fg.canvas.GestureEventType.Translate:
|
||||
bump_end = anim.bump(bump_end)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeClick:
|
||||
props.onNodeClick(e.node.key as string)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeDrag:
|
||||
fg.graph.changeNodePosition(canvas_state.graph.grid, e.node, e.pos.x, e.pos.y)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
anim.loopClear(loop)
|
||||
ro.disconnect()
|
||||
fg.canvas.cleanupCanvasGestures(gestures)
|
||||
schedule_filter_nodes.clear()
|
||||
}
|
||||
}, [canvas_el.current])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvas_el}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-10%",
|
||||
left: "-10%",
|
||||
width: "120%",
|
||||
height: "120%"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
|
||||
import { glob } from "fs"
|
||||
import { ID } from "jazz-tools"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export default function ForceGraph() {
|
||||
const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
|
||||
root: { topicGraph: [{ connectedTopics: [{}] }] }
|
||||
})
|
||||
|
||||
const graph = useMemo(() => {
|
||||
return globalGroup?.root.topicGraph?.map(
|
||||
(topic: { name: string; prettyName: string; connectedTopics: Array<{ name?: string }> }) =>
|
||||
({
|
||||
name: topic.name,
|
||||
prettyName: topic.prettyName,
|
||||
connectedTopics: topic.connectedTopics.map(connected => connected?.name)
|
||||
}) || []
|
||||
)
|
||||
}, [globalGroup?.root.topicGraph])
|
||||
// const [{}]
|
||||
console.log(globalGroup, "graph")
|
||||
return <>{JSON.stringify(graph)}</>
|
||||
}
|
||||
1
web/components/routes/graph-data.json
Normal file
1
web/components/routes/graph-data.json
Normal file
File diff suppressed because one or more lines are too long
@@ -56,6 +56,8 @@ export const LinkBottomBar: React.FC = () => {
|
||||
|
||||
const cancelBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const deleteBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -66,6 +68,8 @@ export const LinkBottomBar: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom([
|
||||
overlayRef,
|
||||
contentRef,
|
||||
deleteBtnRef,
|
||||
editMoreBtnRef,
|
||||
cancelBtnRef,
|
||||
@@ -84,17 +88,19 @@ export const LinkBottomBar: React.FC = () => {
|
||||
alertDialogTitle: {
|
||||
className: "text-base"
|
||||
},
|
||||
customActions(onConfirm, onCancel) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onCancel} ref={cancelBtnRef}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} ref={confirmBtnRef}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
alertDialogOverlay: {
|
||||
ref: overlayRef
|
||||
},
|
||||
alertDialogContent: {
|
||||
ref: contentRef
|
||||
},
|
||||
cancelButton: {
|
||||
variant: "outline",
|
||||
ref: cancelBtnRef
|
||||
},
|
||||
confirmButton: {
|
||||
variant: "destructive",
|
||||
ref: confirmBtnRef
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,34 +1,84 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import AiSearch from "../../custom/ai-search"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { ID } from "jazz-tools"
|
||||
import Link from "next/link"
|
||||
import { Topic, PersonalLink, PersonalPage } from "@/lib/schema"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
|
||||
interface SearchTitleProps {
|
||||
topics: string[]
|
||||
topicTitle: string
|
||||
title: string
|
||||
count: number
|
||||
}
|
||||
interface SearchItemProps {
|
||||
icon: string
|
||||
href: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
const SearchTitle: React.FC<SearchTitleProps> = ({ topicTitle, topics }) => {
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
<h2 className="text-lg font-semibold">{topicTitle}</h2>
|
||||
<div className="mx-4 flex-grow">
|
||||
<div className="h-px bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
<span className="text-base font-light text-opacity-55">{topics.length}</span>
|
||||
const SearchTitle: React.FC<SearchTitleProps> = ({ title, count }) => (
|
||||
<div className="flex w-full items-center">
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
<div className="mx-4 flex-grow">
|
||||
<div className="bg-result h-px"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span className="text-base font-light text-opacity-55">{count}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SearchItem: React.FC<SearchItemProps> = ({ icon, href, title, subtitle, topic }) => (
|
||||
<div className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2">
|
||||
<LaIcon
|
||||
name={icon as "Square"}
|
||||
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
|
||||
/>
|
||||
<div className="group flex items-center justify-between">
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
prefetch={false}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:text-primary text-sm font-medium hover:opacity-70"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
{subtitle && (
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
prefetch={false}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground ml-2 truncate text-xs hover:underline"
|
||||
>
|
||||
{subtitle}
|
||||
</Link>
|
||||
)}
|
||||
{topic && (
|
||||
<span className="ml-2 text-xs opacity-45">
|
||||
{topic.latestGlobalGuide?.sections?.reduce((total, section) => total + (section?.links?.length || 0), 0) || 0}{" "}
|
||||
links
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SearchWrapper = () => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [showAiSearch, setShowAiSearch] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<Topic[]>([])
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
topics: Topic[]
|
||||
links: PersonalLink[]
|
||||
pages: PersonalPage[]
|
||||
}>({ topics: [], links: [], pages: [] })
|
||||
|
||||
const { me } = useAccount({
|
||||
root: { personalLinks: [], personalPages: [] }
|
||||
})
|
||||
|
||||
const globalGroup = useCoState(
|
||||
PublicGlobalGroup,
|
||||
@@ -41,21 +91,35 @@ export const SearchWrapper = () => {
|
||||
)
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
const value = e.target.value.toLowerCase()
|
||||
setSearchText(value)
|
||||
|
||||
const results =
|
||||
value && globalGroup?.root.topics
|
||||
? globalGroup.root.topics.filter(
|
||||
(topic): topic is Topic => topic !== null && topic.prettyName.toLowerCase().startsWith(value.toLowerCase())
|
||||
)
|
||||
: []
|
||||
setSearchResults(results)
|
||||
if (!value) {
|
||||
setSearchResults({ topics: [], links: [], pages: [] })
|
||||
return
|
||||
}
|
||||
setSearchResults({
|
||||
topics:
|
||||
globalGroup?.root.topics?.filter(
|
||||
(topic: Topic | null): topic is Topic => topic !== null && topic.prettyName.toLowerCase().startsWith(value)
|
||||
) || [],
|
||||
links:
|
||||
me?.root.personalLinks?.filter(
|
||||
(link: PersonalLink | null): link is PersonalLink =>
|
||||
link !== null && link.title.toLowerCase().startsWith(value)
|
||||
) || [],
|
||||
pages:
|
||||
me?.root.personalPages?.filter(
|
||||
(page): page is PersonalPage =>
|
||||
page !== null && page.title !== undefined && page.title.toLowerCase().startsWith(value)
|
||||
) || []
|
||||
})
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchText("")
|
||||
setSearchResults([])
|
||||
setSearchResults({ topics: [], links: [], pages: [] })
|
||||
setShowAiSearch(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -64,7 +128,7 @@ export const SearchWrapper = () => {
|
||||
<div className="w-full max-w-[70%] sm:px-6 lg:px-8">
|
||||
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300">
|
||||
<div className="relative my-5 flex w-full items-center space-x-2">
|
||||
<LaIcon name="Search" className="absolute left-4 size-4 flex-shrink-0 text-black/50 dark:text-white/50" />
|
||||
<LaIcon name="Search" className="text-foreground absolute left-4 size-4 flex-shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
@@ -73,58 +137,57 @@ export const SearchWrapper = () => {
|
||||
placeholder="Search something..."
|
||||
className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600"
|
||||
/>
|
||||
|
||||
{searchText && (
|
||||
<LaIcon
|
||||
name="X"
|
||||
className="absolute right-3 size-4 flex-shrink-0 cursor-pointer text-black/50 dark:text-white/50"
|
||||
className="text-foreground/50 absolute right-3 size-4 flex-shrink-0 cursor-pointer"
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full pb-5">
|
||||
{searchResults.length > 0 ? (
|
||||
{Object.values(searchResults).some(arr => arr.length > 0) ? (
|
||||
<div className="space-y-1">
|
||||
<SearchTitle topicTitle="Topics" topics={searchResults.map(topic => topic.prettyName)} />
|
||||
{searchResults.map((topic, index) => (
|
||||
<div
|
||||
key={topic.id}
|
||||
className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2"
|
||||
>
|
||||
<LaIcon
|
||||
name="Square"
|
||||
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
|
||||
/>
|
||||
<div className="group">
|
||||
<Link
|
||||
{searchResults.links.length > 0 && (
|
||||
<>
|
||||
<SearchTitle title="Links" count={searchResults.links.length} />
|
||||
{searchResults.links.map(link => (
|
||||
<SearchItem key={link.id} icon="Square" href={link.url} title={link.title} subtitle={link.url} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.pages.length > 0 && (
|
||||
<>
|
||||
<SearchTitle title="Pages" count={searchResults.pages.length} />
|
||||
{searchResults.pages.map(page => (
|
||||
<SearchItem key={page.id} icon="Square" href={`/pages/${page.id}`} title={page.title || ""} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.topics.length > 0 && (
|
||||
<>
|
||||
<SearchTitle title="Topics" count={searchResults.topics.length} />
|
||||
{searchResults.topics.map(topic => (
|
||||
<SearchItem
|
||||
key={topic.id}
|
||||
icon="Square"
|
||||
href={`/${topic.name}`}
|
||||
passHref
|
||||
prefetch={false}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:text-primary text-sm font-medium hover:opacity-70"
|
||||
>
|
||||
{topic.prettyName}
|
||||
<span className="ml-2 text-xs opacity-45">
|
||||
{topic.latestGlobalGuide?.sections?.reduce(
|
||||
(total, section) => total + (section?.links?.length || 0),
|
||||
0
|
||||
) || 0}{" "}
|
||||
links
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
title={topic.prettyName}
|
||||
topic={topic}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
{searchText && searchResults.length === 0 && !showAiSearch && (
|
||||
{searchText && !showAiSearch && (
|
||||
<div
|
||||
className="cursor-pointer rounded-lg bg-blue-700 p-4 font-semibold text-white"
|
||||
onClick={() => setShowAiSearch(true)}
|
||||
>
|
||||
✨ Didn't find what you were looking for? Ask AI
|
||||
✨ Didn't find what you were looking for? Ask AI
|
||||
</div>
|
||||
)}
|
||||
{showAiSearch && <AiSearch searchQuery={searchText} />}
|
||||
|
||||
@@ -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(
|
||||
|
||||
177
web/components/ui/CommandPalette.tsx
Normal file
177
web/components/ui/CommandPalette.tsx
Normal file
@@ -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: <Icon name="Link" />,
|
||||
// keybind: ["Ctrl", "K"],
|
||||
action: () => {
|
||||
if (window.location.pathname !== "/") {
|
||||
router.push("/")
|
||||
}
|
||||
setShowCreate(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Create page",
|
||||
icon: <Icon name="File" />,
|
||||
// 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: <Icon name="File" />,
|
||||
// // 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 (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed left-0 top-0 z-[100] flex h-screen w-screen justify-center pt-[100px]"
|
||||
onClick={() => setShowPalette(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command Palette"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="relative h-fit w-[600px] rounded-lg border border-slate-400/20 bg-white drop-shadow-xl dark:bg-neutral-900"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-slate-400/20 p-4">
|
||||
<Icon name="Search" className="h-[20px] w-[20px] opacity-70" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent text-[18px] outline-none"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder="Search commands..."
|
||||
aria-label="Search commands"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex h-full max-h-[500px] flex-col gap-2 p-2 text-[12px]" role="listbox">
|
||||
{commandResults.map((command, index) => (
|
||||
<li
|
||||
key={index}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
className={`flex w-full cursor-pointer items-center justify-between rounded-lg p-3 transition-all ${
|
||||
index === selectedIndex
|
||||
? "bg-gray-100 dark:bg-neutral-800"
|
||||
: "hover:bg-gray-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
onClick={() => {
|
||||
command.action()
|
||||
setShowPalette(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4" aria-hidden="true">
|
||||
{command.icon}
|
||||
</span>
|
||||
|
||||
<span>{command.name}</span>
|
||||
</div>
|
||||
{command.keybind && (
|
||||
<div className="flex items-center gap-1 opacity-60">
|
||||
{command.keybind.map(key => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="flex h-[24px] w-fit min-w-[24px] items-center justify-center rounded-md bg-gray-200 px-2 dark:bg-neutral-700/60"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{commandResults.length === 0 && (
|
||||
<li className="p-3 text-center text-sm text-slate-400">No results found</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -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<LaAccount>({
|
||||
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 <Jazz.Provider>{children}</Jazz.Provider>
|
||||
return <Jazz.Provider peer="wss://mesh.jazz.tools/?key=example@gmail.com">{children}</Jazz.Provider>
|
||||
}
|
||||
|
||||
export const JazzClerkAuthCtx = createContext<{
|
||||
errors: string[]
|
||||
}>({
|
||||
errors: []
|
||||
})
|
||||
|
||||
export function JazzClerkAuth({ children }: { children: React.ReactNode }) {
|
||||
const clerk = useClerk()
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
|
||||
const authMethod = useMemo(() => {
|
||||
return new BrowserClerkAuth(
|
||||
{
|
||||
onError: error => {
|
||||
void clerk.signOut()
|
||||
setErrors(errors => [...errors, error.toString()])
|
||||
}
|
||||
},
|
||||
clerk
|
||||
)
|
||||
}, [clerk])
|
||||
|
||||
return (
|
||||
<JazzClerkAuthCtx.Provider value={{ errors }}>
|
||||
<AuthMethodCtx.Provider value={authMethod}>{children}</AuthMethodCtx.Provider>
|
||||
</JazzClerkAuthCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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<AuthResult> {
|
||||
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<Account>,
|
||||
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<Account>; 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
|
||||
}
|
||||
}
|
||||
|
||||
52
web/lib/utils/canvas.ts
Normal file
52
web/lib/utils/canvas.ts
Normal file
@@ -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()
|
||||
}
|
||||
148
web/lib/utils/schedule.ts
Normal file
148
web/lib/utils/schedule.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export interface Scheduler<Args extends unknown[]> {
|
||||
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<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
wait?: number,
|
||||
): Debounce<Args> {
|
||||
return new Debounce(callback, wait)
|
||||
}
|
||||
|
||||
export class Debounce<Args extends unknown[]> implements Scheduler<Args> {
|
||||
timeout_id: ReturnType<typeof setTimeout> | 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<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
wait?: number,
|
||||
): Throttle<Args> {
|
||||
return new Throttle(callback, wait)
|
||||
}
|
||||
|
||||
export class Throttle<Args extends unknown[]> implements Scheduler<Args> {
|
||||
is_throttled = false
|
||||
timeout_id: ReturnType<typeof setTimeout> | 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<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
max_wait?: number,
|
||||
): ScheduleIdle<Args> | Throttle<Args> {
|
||||
return typeof requestIdleCallback == "function"
|
||||
? new ScheduleIdle(callback, max_wait)
|
||||
: new Throttle(callback)
|
||||
}
|
||||
|
||||
export class ScheduleIdle<Args extends unknown[]> implements Scheduler<Args> {
|
||||
is_deferred = false
|
||||
request_id: ReturnType<typeof requestIdleCallback> | 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
|
||||
}
|
||||
}
|
||||
28
web/lib/utils/window-size.ts
Normal file
28
web/lib/utils/window-size.ts
Normal file
@@ -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
|
||||
}
|
||||
18
web/middleware.ts
Normal file
18
web/middleware.ts
Normal file
@@ -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)(.*)"
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user