mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: command palette (#140)
* wip * feat: new command palette * chore: add universal search * chore: cleanup * feat: use title class for heading * feat: add topic * chore: advance search
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { SignedInClient } from "@/components/custom/clerk/signed-in-client"
|
import { SignedInClient } from "@/components/custom/clerk/signed-in-client"
|
||||||
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||||
import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
|
import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
|
||||||
import { CommandPalette } from "@/components/ui/CommandPalette"
|
|
||||||
import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider"
|
import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider"
|
||||||
import { currentUser } from "@clerk/nextjs/server"
|
import { currentUser } from "@clerk/nextjs/server"
|
||||||
|
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
|
||||||
|
|
||||||
export default async function PageLayout({ children }: { children: React.ReactNode }) {
|
export default async function PageLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await currentUser()
|
const user = await currentUser()
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
@keyframes scaleIn {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.97) translateX(-50%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1) translateX(-50%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleOut {
|
||||||
|
0% {
|
||||||
|
transform: scale(1) translateX(-50%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(0.97) translateX(-50%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--cmdk-shadow: rgba(0, 0, 0, 0.12) 0px 4px 30px, rgba(0, 0, 0, 0.04) 0px 3px 17px, rgba(0, 0, 0, 0.04) 0px 2px 8px,
|
||||||
|
rgba(0, 0, 0, 0.04) 0px 1px 1px;
|
||||||
|
--cmdk-bg: rgb(255, 255, 255);
|
||||||
|
--cmdk-border-color: rgb(216, 216, 216);
|
||||||
|
|
||||||
|
--cmdk-input-color: rgb(48, 48, 49);
|
||||||
|
--cmdk-input-placeholder: hsl(0, 0%, 56.1%);
|
||||||
|
|
||||||
|
--cmdk-accent: rgb(243, 243, 243);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--cmdk-shadow: rgba(0, 0, 0, 0.15) 0px 4px 40px, rgba(0, 0, 0, 0.184) 0px 3px 20px, rgba(0, 0, 0, 0.184) 0px 3px 12px,
|
||||||
|
rgba(0, 0, 0, 0.184) 0px 2px 8px, rgba(0, 0, 0, 0.184) 0px 1px 1px;
|
||||||
|
--cmdk-bg: rgb(27, 28, 31);
|
||||||
|
--cmdk-border-color: rgb(56, 59, 65);
|
||||||
|
|
||||||
|
--cmdk-input-color: rgb(228, 229, 233);
|
||||||
|
--cmdk-input-placeholder: hsl(0, 0%, 43.9%);
|
||||||
|
|
||||||
|
--cmdk-accent: rgb(44, 48, 57);
|
||||||
|
}
|
||||||
|
|
||||||
|
[la-overlay][cmdk-overlay] {
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
@apply fixed inset-0 z-50 opacity-80;
|
||||||
|
}
|
||||||
|
|
||||||
|
[la-dialog][cmdk-dialog] {
|
||||||
|
top: 15%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 640px;
|
||||||
|
background: var(--cmdk-bg);
|
||||||
|
box-shadow: var(--cmdk-shadow);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: scaleIn 0.2s ease;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
border: 0.5px solid var(--cmdk-border-color);
|
||||||
|
@apply fixed left-1/2 z-50 overflow-hidden rounded-lg outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[la-dialog][cmdk-dialog][data-state="closed"] {
|
||||||
|
animation: scaleOut 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-input-wrapper] {
|
||||||
|
border-bottom: 1px solid var(--cmdk-border-color);
|
||||||
|
height: 62px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-input] {
|
||||||
|
font-size: inherit;
|
||||||
|
height: 62px;
|
||||||
|
color: var(--cmdk-input-color);
|
||||||
|
caret-color: rgb(110, 94, 210);
|
||||||
|
@apply m-0 w-full appearance-none border-none bg-transparent p-5 outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-input]::placeholder {
|
||||||
|
color: var(--cmdk-input-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-list] {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
transition: 100ms ease;
|
||||||
|
transition-property: height;
|
||||||
|
@apply p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-group-heading] {
|
||||||
|
font-size: 13px;
|
||||||
|
height: 30px;
|
||||||
|
@apply text-muted-foreground flex items-center px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-empty] {
|
||||||
|
@apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la [cmdk-item] {
|
||||||
|
scroll-margin: 8px 0;
|
||||||
|
@apply flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm aria-selected:bg-[var(--cmdk-accent)];
|
||||||
|
}
|
||||||
@@ -71,3 +71,5 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@import "./command-palette.css";
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { icons } from "lucide-react"
|
||||||
|
import { useCommandActions } from "./hooks/use-command-actions"
|
||||||
|
import { LaAccount } from "@/lib/schema"
|
||||||
|
|
||||||
|
export type CommandAction = string | (() => void)
|
||||||
|
|
||||||
|
export type CommandItemType = {
|
||||||
|
icon?: keyof typeof icons
|
||||||
|
label: string
|
||||||
|
action: CommandAction
|
||||||
|
payload?: any
|
||||||
|
shortcut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandGroupType = {
|
||||||
|
heading?: string
|
||||||
|
items: CommandItemType[]
|
||||||
|
}[]
|
||||||
|
|
||||||
|
export const createCommandGroups = (
|
||||||
|
actions: ReturnType<typeof useCommandActions>,
|
||||||
|
me: LaAccount
|
||||||
|
): Record<string, CommandGroupType> => ({
|
||||||
|
home: [
|
||||||
|
{
|
||||||
|
heading: "General",
|
||||||
|
items: [
|
||||||
|
{ icon: "SunMoon", label: "Change Theme...", action: "CHANGE_PAGE", payload: "changeTheme" },
|
||||||
|
{
|
||||||
|
icon: "Copy",
|
||||||
|
label: "Copy Current URL",
|
||||||
|
action: actions.copyCurrentURL
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Personal Links",
|
||||||
|
items: [
|
||||||
|
{ icon: "TextSearch", label: "Search Links...", action: "CHANGE_PAGE", payload: "searchLinks" },
|
||||||
|
{ icon: "Plus", label: "Create New Link...", action: () => actions.navigateTo("/") }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Personal Pages",
|
||||||
|
items: [
|
||||||
|
{ icon: "FileSearch", label: "Search Pages...", action: "CHANGE_PAGE", payload: "searchPages" },
|
||||||
|
{
|
||||||
|
icon: "Plus",
|
||||||
|
label: "Create New Page...",
|
||||||
|
action: () => actions.createNewPage(me)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
searchLinks: [],
|
||||||
|
searchPages: [],
|
||||||
|
topics: [],
|
||||||
|
changeTheme: [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ icon: "Moon", label: "Change Theme to Dark", action: () => actions.changeTheme("dark") },
|
||||||
|
{ icon: "Sun", label: "Change Theme to Light", action: () => actions.changeTheme("light") },
|
||||||
|
{ icon: "Monitor", label: "Change Theme to System", action: () => actions.changeTheme("system") }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Command } from "cmdk"
|
||||||
|
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
||||||
|
import { LaIcon } from "../la-icon"
|
||||||
|
import { CommandItemType, CommandAction } from "./command-data"
|
||||||
|
|
||||||
|
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||||
|
action: CommandAction
|
||||||
|
handleAction: (action: CommandAction, payload?: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandItem: React.FC<CommandItemProps> = ({ icon, label, action, payload, shortcut, handleAction }) => (
|
||||||
|
<Command.Item onSelect={() => handleAction(action, payload)}>
|
||||||
|
{icon && <LaIcon name={icon} />}
|
||||||
|
<span>{label}</span>
|
||||||
|
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||||
|
</Command.Item>
|
||||||
|
)
|
||||||
|
export interface CommandGroupProps {
|
||||||
|
heading?: string
|
||||||
|
items: CommandItemType[]
|
||||||
|
handleAction: (action: CommandAction, payload?: any) => void
|
||||||
|
isLastGroup: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandGroup: React.FC<CommandGroupProps> = ({ heading, items, handleAction, isLastGroup }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{heading ? (
|
||||||
|
<Command.Group heading={heading}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<CommandItem key={`${heading}-${item.label}-${index}`} {...item} handleAction={handleAction} />
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
) : (
|
||||||
|
items.map((item, index) => (
|
||||||
|
<CommandItem key={`item-${item.label}-${index}`} {...item} handleAction={handleAction} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{!isLastGroup && <CommandSeparator className="my-1.5" />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { Command } from "cmdk"
|
||||||
|
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { CommandGroup } from "./command-items"
|
||||||
|
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
|
||||||
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
|
import { searchSafeRegExp, toTitleCase } from "@/lib/utils"
|
||||||
|
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
||||||
|
import { useCommandActions } from "./hooks/use-command-actions"
|
||||||
|
|
||||||
|
let graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||||
|
|
||||||
|
const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||||
|
items.filter(item => searchRegex.test(item.label)).slice(0, 6)
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||||
|
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
|
const [activePage, setActivePage] = React.useState("home")
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const actions = useCommandActions()
|
||||||
|
const commandGroups = React.useMemo(() => me && createCommandGroups(actions, me), [actions, me])
|
||||||
|
|
||||||
|
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down)
|
||||||
|
return () => document.removeEventListener("keydown", down)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const bounce = React.useCallback(() => {
|
||||||
|
if (dialogRef.current) {
|
||||||
|
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
|
||||||
|
setTimeout(() => {
|
||||||
|
if (dialogRef.current) {
|
||||||
|
dialogRef.current.style.transform = ""
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
bounce()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePage !== "home" && !inputValue && e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActivePage("home")
|
||||||
|
setInputValue("")
|
||||||
|
bounce()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activePage, inputValue, bounce]
|
||||||
|
)
|
||||||
|
|
||||||
|
const allCommands = React.useMemo(() => {
|
||||||
|
if (!commandGroups) return []
|
||||||
|
|
||||||
|
return Object.entries(commandGroups).map(([key, value]) => ({
|
||||||
|
heading: toTitleCase(key),
|
||||||
|
items: value.flatMap(subgroup => subgroup.items)
|
||||||
|
}))
|
||||||
|
}, [commandGroups])
|
||||||
|
|
||||||
|
const topics = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
heading: "Topics",
|
||||||
|
items: raw_graph_data.map(topic => ({
|
||||||
|
icon: "Circle" as const,
|
||||||
|
label: topic?.prettyName || "",
|
||||||
|
action: () => actions.navigateTo(`/${topic?.name}`)
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
[raw_graph_data, actions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const personalLinks = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
heading: "Personal Links",
|
||||||
|
items:
|
||||||
|
me?.root.personalLinks?.map(link => ({
|
||||||
|
icon: "Link" as const,
|
||||||
|
label: link?.title || "Untitled",
|
||||||
|
action: () => actions.openLinkInNewTab(link?.url || "#")
|
||||||
|
})) || []
|
||||||
|
}),
|
||||||
|
[me?.root.personalLinks, actions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const personalPages = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
heading: "Personal Pages",
|
||||||
|
items:
|
||||||
|
me?.root.personalPages?.map(page => ({
|
||||||
|
icon: "FileText" as const,
|
||||||
|
label: page?.title || "Untitled",
|
||||||
|
action: () => actions.navigateTo(`/pages/${page?.id}`)
|
||||||
|
})) || []
|
||||||
|
}),
|
||||||
|
[me?.root.personalPages, actions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getFilteredCommands = React.useCallback(() => {
|
||||||
|
if (!commandGroups) return []
|
||||||
|
|
||||||
|
const searchRegex = searchSafeRegExp(inputValue)
|
||||||
|
|
||||||
|
if (activePage === "home") {
|
||||||
|
if (!inputValue) return commandGroups.home
|
||||||
|
|
||||||
|
return [...allCommands, personalLinks, personalPages, topics]
|
||||||
|
.map(group => ({
|
||||||
|
heading: group.heading,
|
||||||
|
items: filterItems(group.items, searchRegex)
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (activePage) {
|
||||||
|
case "searchLinks":
|
||||||
|
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
|
||||||
|
case "searchPages":
|
||||||
|
return [...commandGroups.searchPages, { items: filterItems(personalPages.items, searchRegex) }]
|
||||||
|
default:
|
||||||
|
const pageCommands = commandGroups[activePage]
|
||||||
|
if (!inputValue) return pageCommands
|
||||||
|
return pageCommands
|
||||||
|
.map(group => ({
|
||||||
|
heading: group.heading,
|
||||||
|
items: filterItems(group.items, searchRegex)
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length > 0)
|
||||||
|
}
|
||||||
|
}, [inputValue, activePage, allCommands, personalLinks, personalPages, commandGroups, topics])
|
||||||
|
|
||||||
|
const handleAction = React.useCallback(
|
||||||
|
(action: CommandAction, payload?: any) => {
|
||||||
|
const closeDialog = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof action === "function") {
|
||||||
|
action()
|
||||||
|
closeDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "CHANGE_PAGE":
|
||||||
|
if (payload) {
|
||||||
|
setActivePage(payload)
|
||||||
|
setInputValue("")
|
||||||
|
bounce()
|
||||||
|
} else {
|
||||||
|
console.error(`Invalid page: ${payload}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled action: ${action}`)
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bounce]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredCommands = React.useMemo(() => getFilteredCommands(), [getFilteredCommands])
|
||||||
|
|
||||||
|
const commandKey = React.useMemo(() => {
|
||||||
|
return filteredCommands
|
||||||
|
.map(group => {
|
||||||
|
const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|")
|
||||||
|
return `${group.heading}:${itemsKey}`
|
||||||
|
})
|
||||||
|
.join("__")
|
||||||
|
}, [filteredCommands])
|
||||||
|
|
||||||
|
if (!me) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogPrimitive.Overlay la-overlay="" cmdk-overlay="" />
|
||||||
|
<DialogPrimitive.Content la-dialog="" cmdk-dialog="" className="la" ref={dialogRef}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>Command Palette</DialogTitle>
|
||||||
|
<DialogDescription>Search for commands and actions</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Command key={commandKey} onKeyDown={handleKeyDown}>
|
||||||
|
<div cmdk-input-wrapper="">
|
||||||
|
<Command.Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List>
|
||||||
|
<Command.Empty>No results found.</Command.Empty>
|
||||||
|
{filteredCommands.map((group, index, array) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={`${group.heading}-${index}`}
|
||||||
|
heading={group.heading}
|
||||||
|
items={group.items}
|
||||||
|
handleAction={handleAction}
|
||||||
|
isLastGroup={index === array.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Command.List>
|
||||||
|
</Command>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ensureUrlProtocol } from "@/lib/utils"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { LaAccount, PersonalPage } from "@/lib/schema"
|
||||||
|
|
||||||
|
export const useCommandActions = () => {
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const changeTheme = React.useCallback(
|
||||||
|
(theme: string) => {
|
||||||
|
setTheme(theme)
|
||||||
|
toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
|
||||||
|
},
|
||||||
|
[setTheme]
|
||||||
|
)
|
||||||
|
|
||||||
|
const navigateTo = React.useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
router.push(path)
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openLinkInNewTab = React.useCallback((url: string) => {
|
||||||
|
window.open(ensureUrlProtocol(url), "_blank")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const copyCurrentURL = React.useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(window.location.href)
|
||||||
|
toast.success("URL copied to clipboard.", { position: "bottom-right" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createNewPage = React.useCallback(
|
||||||
|
(me: LaAccount) => {
|
||||||
|
try {
|
||||||
|
const newPersonalPage = PersonalPage.create(
|
||||||
|
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
{ owner: me._owner }
|
||||||
|
)
|
||||||
|
|
||||||
|
me.root?.personalPages?.push(newPersonalPage)
|
||||||
|
router.push(`/pages/${newPersonalPage.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create page")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeTheme,
|
||||||
|
navigateTo,
|
||||||
|
openLinkInNewTab,
|
||||||
|
copyCurrentURL,
|
||||||
|
createNewPage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ let graph_data_promise = import("./graph-data.json").then(a => a.default)
|
|||||||
|
|
||||||
const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false })
|
const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false })
|
||||||
|
|
||||||
interface GraphNode {
|
export interface GraphNode {
|
||||||
name: string
|
name: string
|
||||||
prettyName: string
|
prettyName: string
|
||||||
connectedTopics: string[]
|
connectedTopics: string[]
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,11 @@ export function DeepLinkProvider({ children }: DeepLinkProviderProps) {
|
|||||||
const eventHandlers: { [key: string]: (event: Event) => void } = {
|
const eventHandlers: { [key: string]: (event: Event) => void } = {
|
||||||
click: (event: Event) => {
|
click: (event: Event) => {
|
||||||
const e = event as MouseEvent
|
const e = event as MouseEvent
|
||||||
console.log("Click event:", { x: e.clientX, y: e.clientY })
|
// console.log("Click event:", { x: e.clientX, y: e.clientY })
|
||||||
},
|
},
|
||||||
keydown: (event: Event) => {
|
keydown: (event: Event) => {
|
||||||
const e = event as KeyboardEvent
|
const e = event as KeyboardEvent
|
||||||
console.log("Keydown event:", { key: e.key, code: e.code })
|
// console.log("Keydown event:", { key: e.key, code: e.code })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ export const randomId = () => {
|
|||||||
return Math.random().toString(36).substring(7)
|
return Math.random().toString(36).substring(7)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const toTitleCase = (str: string): string => {
|
||||||
|
return str
|
||||||
|
.replace(/([A-Z])/g, " $1")
|
||||||
|
.replace(/^./, str => str.toUpperCase())
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(string: string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchSafeRegExp = (inputValue: string) => {
|
||||||
|
const escapedChars = inputValue.split("").map(escapeRegExp)
|
||||||
|
return new RegExp(escapedChars.join(".*"), "i")
|
||||||
|
}
|
||||||
|
|
||||||
export * from "./urls"
|
export * from "./urls"
|
||||||
export * from "./slug"
|
export * from "./slug"
|
||||||
export * from "./keyboard"
|
export * from "./keyboard"
|
||||||
|
|||||||
Reference in New Issue
Block a user