force graph, palette

This commit is contained in:
Nikita
2024-08-30 16:19:29 +03:00
parent 9e89959dd4
commit 32352ca5f4
38 changed files with 1602 additions and 243 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({
children
}: Readonly<{
children: React.ReactNode
}>) {
return <main className="h-full">{children}</main>
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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&apos;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>

View File

@@ -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%;

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
"use client"
import { ClerkProvider } from "@clerk/nextjs"
export const ClerkProviderClient = ({ children }: { children: React.ReactNode }) => {
return <ClerkProvider>{children}</ClerkProvider>
}

View File

@@ -0,0 +1,7 @@
"use client"
import { SignIn } from "@clerk/nextjs"
export const SignInClient = () => {
return <SignIn />
}

View File

@@ -0,0 +1,7 @@
"use client"
import { SignUp } from "@clerk/nextjs"
export const SignUpClient = () => {
return <SignUp />
}

View File

@@ -0,0 +1,7 @@
"use client"
import { SignedIn } from "@clerk/nextjs"
export const SignedInClient = ({ children }: { children: React.ReactNode }) => {
return <SignedIn>{children}</SignedIn>
}

View File

@@ -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 &quot;{title}&quot;?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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=""
/>
)
}

View 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
}

View 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>
}

View 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>
)
}

View File

@@ -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)}</>
}

File diff suppressed because one or more lines are too long

View File

@@ -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
}
})

View File

@@ -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&apos;t find what you were looking for? Ask AI
</div>
)}
{showAiSearch && <AiSearch searchQuery={searchText} />}

View File

@@ -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(

View 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>
)
}

View File

@@ -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
View 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
View 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
}
}

View 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
View 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)(.*)"
]
}

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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,