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:
Aslam
2024-09-06 16:22:48 +07:00
committed by GitHub
parent 2bebcbc20a
commit c3e99d1366
11 changed files with 548 additions and 181 deletions

View File

@@ -1,9 +1,9 @@
import { SignedInClient } from "@/components/custom/clerk/signed-in-client"
import { Sidebar } from "@/components/custom/sidebar/sidebar"
import { PublicHomeRoute } from "@/components/routes/public/PublicHomeRoute"
import { CommandPalette } from "@/components/ui/CommandPalette"
import { JazzClerkAuth, JazzProvider } from "@/lib/providers/jazz-provider"
import { currentUser } from "@clerk/nextjs/server"
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
export default async function PageLayout({ children }: { children: React.ReactNode }) {
const user = await currentUser()

127
web/app/command-palette.css Normal file
View File

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

View File

@@ -71,3 +71,5 @@
@apply bg-background text-foreground;
}
}
@import "./command-palette.css";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 })
interface GraphNode {
export interface GraphNode {
name: string
prettyName: string
connectedTopics: string[]

View File

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

View File

@@ -11,11 +11,11 @@ export function DeepLinkProvider({ children }: DeepLinkProviderProps) {
const eventHandlers: { [key: string]: (event: Event) => void } = {
click: (event: Event) => {
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) => {
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 })
}
}

View File

@@ -9,6 +9,22 @@ export const randomId = () => {
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 "./slug"
export * from "./keyboard"