From c3e99d1366e5cc089062867c094b000f77ef6d08 Mon Sep 17 00:00:00 2001 From: Aslam Date: Fri, 6 Sep 2024 16:22:48 +0700 Subject: [PATCH] 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 --- web/app/(pages)/layout.tsx | 2 +- web/app/command-palette.css | 127 ++++++++++ web/app/globals.css | 2 + .../custom/command-palette/command-data.ts | 67 +++++ .../custom/command-palette/command-items.tsx | 42 ++++ .../command-palette/command-palette.tsx | 230 ++++++++++++++++++ .../hooks/use-command-actions.ts | 60 +++++ .../routes/public/PublicHomeRoute.tsx | 2 +- web/components/ui/CommandPalette.tsx | 177 -------------- web/lib/providers/deep-link-provider.tsx | 4 +- web/lib/utils/index.ts | 16 ++ 11 files changed, 548 insertions(+), 181 deletions(-) create mode 100644 web/app/command-palette.css create mode 100644 web/components/custom/command-palette/command-data.ts create mode 100644 web/components/custom/command-palette/command-items.tsx create mode 100644 web/components/custom/command-palette/command-palette.tsx create mode 100644 web/components/custom/command-palette/hooks/use-command-actions.ts delete mode 100644 web/components/ui/CommandPalette.tsx diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 8cadcceb..47c7fa0d 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -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() diff --git a/web/app/command-palette.css b/web/app/command-palette.css new file mode 100644 index 00000000..808aa88b --- /dev/null +++ b/web/app/command-palette.css @@ -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)]; +} diff --git a/web/app/globals.css b/web/app/globals.css index 623eca9c..8bdc5be5 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -71,3 +71,5 @@ @apply bg-background text-foreground; } } + +@import "./command-palette.css"; diff --git a/web/components/custom/command-palette/command-data.ts b/web/components/custom/command-palette/command-data.ts new file mode 100644 index 00000000..e6d7a3d1 --- /dev/null +++ b/web/components/custom/command-palette/command-data.ts @@ -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, + me: LaAccount +): Record => ({ + 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") } + ] + } + ] +}) diff --git a/web/components/custom/command-palette/command-items.tsx b/web/components/custom/command-palette/command-items.tsx new file mode 100644 index 00000000..0404a01e --- /dev/null +++ b/web/components/custom/command-palette/command-items.tsx @@ -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 { + action: CommandAction + handleAction: (action: CommandAction, payload?: any) => void +} + +export const CommandItem: React.FC = ({ icon, label, action, payload, shortcut, handleAction }) => ( + handleAction(action, payload)}> + {icon && } + {label} + {shortcut && {shortcut}} + +) +export interface CommandGroupProps { + heading?: string + items: CommandItemType[] + handleAction: (action: CommandAction, payload?: any) => void + isLastGroup: boolean +} + +export const CommandGroup: React.FC = ({ heading, items, handleAction, isLastGroup }) => { + return ( + <> + {heading ? ( + + {items.map((item, index) => ( + + ))} + + ) : ( + items.map((item, index) => ( + + )) + )} + {!isLastGroup && } + + ) +} diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx new file mode 100644 index 00000000..a6b5386a --- /dev/null +++ b/web/components/custom/command-palette/command-palette.tsx @@ -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(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 ( + + + + + + Command Palette + Search for commands and actions + + + +
+ +
+ + + No results found. + {filteredCommands.map((group, index, array) => ( + + ))} + +
+
+
+
+ ) +} diff --git a/web/components/custom/command-palette/hooks/use-command-actions.ts b/web/components/custom/command-palette/hooks/use-command-actions.ts new file mode 100644 index 00000000..453e365c --- /dev/null +++ b/web/components/custom/command-palette/hooks/use-command-actions.ts @@ -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 + } +} diff --git a/web/components/routes/public/PublicHomeRoute.tsx b/web/components/routes/public/PublicHomeRoute.tsx index f6668b7a..ea1f7ab1 100644 --- a/web/components/routes/public/PublicHomeRoute.tsx +++ b/web/components/routes/public/PublicHomeRoute.tsx @@ -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[] diff --git a/web/components/ui/CommandPalette.tsx b/web/components/ui/CommandPalette.tsx deleted file mode 100644 index d860e500..00000000 --- a/web/components/ui/CommandPalette.tsx +++ /dev/null @@ -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: , - // keybind: ["Ctrl", "K"], - action: () => { - if (window.location.pathname !== "/") { - router.push("/") - } - setShowCreate(true) - } - }, - { - name: "Create page", - icon: , - // 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: , - // // 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 ( - - setShowPalette(false)} - > -
e.stopPropagation()} - className="relative h-fit w-[600px] rounded-lg border border-slate-400/20 bg-white drop-shadow-xl dark:bg-neutral-900" - > -
-
-
    - {commandResults.map((command, index) => ( -
  • { - command.action() - setShowPalette(false) - }} - > -
    - - - {command.name} -
    - {command.keybind && ( -
    - {command.keybind.map(key => ( - - {key} - - ))} -
    - )} -
  • - ))} - {commandResults.length === 0 && ( -
  • No results found
  • - )} -
-
-
-
- ) -} diff --git a/web/lib/providers/deep-link-provider.tsx b/web/lib/providers/deep-link-provider.tsx index 11236507..7d22c8d9 100644 --- a/web/lib/providers/deep-link-provider.tsx +++ b/web/lib/providers/deep-link-provider.tsx @@ -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 }) } } diff --git a/web/lib/utils/index.ts b/web/lib/utils/index.ts index 742f4e90..5762b1d0 100644 --- a/web/lib/utils/index.ts +++ b/web/lib/utils/index.ts @@ -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"