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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user