mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-20 23:41:29 +02:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
142
web/app/components/command-palette/command-data.ts
Normal file
142
web/app/components/command-palette/command-data.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { icons } from "lucide-react"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { HTMLLikeElement } from "@/lib/utils"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
|
||||
export type CommandAction = string | (() => void)
|
||||
|
||||
export interface CommandItemType {
|
||||
id?: string
|
||||
icon?: keyof typeof icons
|
||||
value: string
|
||||
label: HTMLLikeElement | string
|
||||
action: CommandAction
|
||||
payload?: any
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export type CommandGroupType = Array<{
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
}>
|
||||
|
||||
const createNavigationItem = (
|
||||
icon: keyof typeof icons,
|
||||
value: string,
|
||||
path: string,
|
||||
actions: ReturnType<typeof useCommandActions>,
|
||||
): CommandItemType => ({
|
||||
icon,
|
||||
value: `Go to ${value}`,
|
||||
label: {
|
||||
tag: "span",
|
||||
children: [
|
||||
"Go to ",
|
||||
{
|
||||
tag: "span",
|
||||
attributes: { className: "font-semibold" },
|
||||
children: [value],
|
||||
},
|
||||
],
|
||||
},
|
||||
action: () => actions.navigateTo(path),
|
||||
})
|
||||
|
||||
export const createCommandGroups = (
|
||||
actions: ReturnType<typeof useCommandActions>,
|
||||
me: LaAccount,
|
||||
): Record<string, CommandGroupType> => ({
|
||||
home: [
|
||||
{
|
||||
heading: "General",
|
||||
items: [
|
||||
{
|
||||
icon: "SunMoon",
|
||||
value: "Change Theme...",
|
||||
label: "Change Theme...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "changeTheme",
|
||||
},
|
||||
{
|
||||
icon: "Copy",
|
||||
value: "Copy Current URL",
|
||||
label: "Copy Current URL",
|
||||
action: actions.copyCurrentURL,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Personal Links",
|
||||
items: [
|
||||
{
|
||||
icon: "TextSearch",
|
||||
value: "Search Links...",
|
||||
label: "Search Links...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchLinks",
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Link...",
|
||||
label: "Create New Link...",
|
||||
action: () => actions.navigateTo("/links?create=true"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Personal Pages",
|
||||
items: [
|
||||
{
|
||||
icon: "FileSearch",
|
||||
value: "Search Pages...",
|
||||
label: "Search Pages...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchPages",
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Page...",
|
||||
label: "Create New Page...",
|
||||
action: () => actions.createNewPage(me),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Navigation",
|
||||
items: [
|
||||
createNavigationItem("ArrowRight", "Links", "/links", actions),
|
||||
createNavigationItem("ArrowRight", "Pages", "/pages", actions),
|
||||
createNavigationItem("ArrowRight", "Search", "/search", actions),
|
||||
createNavigationItem("ArrowRight", "Profile", "/profile", actions),
|
||||
createNavigationItem("ArrowRight", "Settings", "/settings", actions),
|
||||
],
|
||||
},
|
||||
],
|
||||
searchLinks: [],
|
||||
searchPages: [],
|
||||
topics: [],
|
||||
changeTheme: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: "Moon",
|
||||
value: "Change Theme to Dark",
|
||||
label: "Change Theme to Dark",
|
||||
action: () => actions.changeTheme("dark"),
|
||||
},
|
||||
{
|
||||
icon: "Sun",
|
||||
value: "Change Theme to Light",
|
||||
label: "Change Theme to Light",
|
||||
action: () => actions.changeTheme("light"),
|
||||
},
|
||||
{
|
||||
icon: "Monitor",
|
||||
value: "Change Theme to System",
|
||||
label: "Change Theme to System",
|
||||
action: () => actions.changeTheme("system"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
73
web/app/components/command-palette/command-group.tsx
Normal file
73
web/app/components/command-palette/command-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { CommandItemType, CommandAction } from "./command-data"
|
||||
import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils"
|
||||
|
||||
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||
action: CommandAction
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
}
|
||||
|
||||
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> =
|
||||
React.memo(({ content }) => {
|
||||
return (
|
||||
<span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
|
||||
)
|
||||
})
|
||||
|
||||
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
|
||||
|
||||
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
||||
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
|
||||
<Command.Item
|
||||
value={`${item.id}-${item.value}`}
|
||||
onSelect={() => handleAction(action, payload)}
|
||||
>
|
||||
{icon && <LaIcon name={icon} />}
|
||||
<HTMLLikeRenderer content={label} />
|
||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||
</Command.Item>
|
||||
),
|
||||
)
|
||||
|
||||
CommandItem.displayName = "CommandItem"
|
||||
|
||||
export interface CommandGroupProps {
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
isLastGroup: boolean
|
||||
}
|
||||
|
||||
export const CommandGroup: React.FC<CommandGroupProps> = React.memo(
|
||||
({ 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" />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CommandGroup.displayName = "CommandGroup"
|
||||
214
web/app/components/command-palette/command-palette.tsx
Normal file
214
web/app/components/command-palette/command-palette.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
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-group"
|
||||
import { CommandAction, createCommandGroups } from "./command-data"
|
||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
import { useCommandActions } from "~/hooks/use-command-actions"
|
||||
import {
|
||||
filterItems,
|
||||
getTopics,
|
||||
getPersonalLinks,
|
||||
getPersonalPages,
|
||||
handleAction,
|
||||
} from "./utils"
|
||||
import { searchSafeRegExp } from "~/lib/utils"
|
||||
import { commandPaletteOpenAtom } from "~/store/any-store"
|
||||
|
||||
export function CommandPalette() {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
if (me._type === "Anonymous") return null
|
||||
|
||||
return <RealCommandPalette />
|
||||
}
|
||||
|
||||
export function RealCommandPalette() {
|
||||
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] = useAtom(commandPaletteOpenAtom)
|
||||
|
||||
const actions = useCommandActions()
|
||||
const commandGroups = React.useMemo(
|
||||
() => me && createCommandGroups(actions, me),
|
||||
[actions, me],
|
||||
)
|
||||
|
||||
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 topics = React.useMemo(() => getTopics(actions), [actions])
|
||||
const personalLinks = React.useMemo(
|
||||
() => getPersonalLinks(me?.root.personalLinks || [], actions),
|
||||
[me?.root.personalLinks, actions],
|
||||
)
|
||||
const personalPages = React.useMemo(
|
||||
() => getPersonalPages(me?.root.personalPages || [], actions),
|
||||
[me?.root.personalPages, actions],
|
||||
)
|
||||
|
||||
const getFilteredCommands = React.useCallback(() => {
|
||||
if (!commandGroups) return []
|
||||
|
||||
const searchRegex = searchSafeRegExp(inputValue)
|
||||
|
||||
if (activePage === "home") {
|
||||
if (!inputValue) {
|
||||
return commandGroups.home
|
||||
}
|
||||
|
||||
const allGroups = [
|
||||
...Object.values(commandGroups).flat(),
|
||||
personalLinks,
|
||||
personalPages,
|
||||
topics,
|
||||
]
|
||||
|
||||
return allGroups
|
||||
.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,
|
||||
commandGroups,
|
||||
personalLinks,
|
||||
personalPages,
|
||||
topics,
|
||||
])
|
||||
|
||||
const handleActionWrapper = React.useCallback(
|
||||
(action: CommandAction, payload?: any) => {
|
||||
handleAction(action, payload, {
|
||||
setActivePage,
|
||||
setInputValue,
|
||||
bounce,
|
||||
closeDialog: () => setOpen(false),
|
||||
})
|
||||
},
|
||||
[bounce, setOpen],
|
||||
)
|
||||
|
||||
const filteredCommands = React.useMemo(
|
||||
() => getFilteredCommands(),
|
||||
[getFilteredCommands],
|
||||
)
|
||||
|
||||
const commandKey = React.useMemo(() => {
|
||||
return filteredCommands
|
||||
.map((group) => {
|
||||
const itemsKey = group.items
|
||||
.map((item) => `${item.label}-${item.value}`)
|
||||
.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={handleActionWrapper}
|
||||
isLastGroup={index === array.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
74
web/app/components/command-palette/utils.ts
Normal file
74
web/app/components/command-palette/utils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { GraphData } from "~/lib/constants"
|
||||
import { CommandAction, CommandItemType } from "./command-data"
|
||||
|
||||
export const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||
items.filter((item) => searchRegex.test(item.value)).slice(0, 10)
|
||||
|
||||
export const getTopics = (actions: { navigateTo: (path: string) => void }) => ({
|
||||
heading: "Topics",
|
||||
items: GraphData.map((topic) => ({
|
||||
icon: "Circle" as const,
|
||||
value: topic?.prettyName || "",
|
||||
label: topic?.prettyName || "",
|
||||
action: () => actions.navigateTo(`/${topic?.name}`),
|
||||
})),
|
||||
})
|
||||
|
||||
export const getPersonalLinks = (
|
||||
personalLinks: any[],
|
||||
actions: { openLinkInNewTab: (url: string) => void },
|
||||
) => ({
|
||||
heading: "Personal Links",
|
||||
items: personalLinks.map((link) => ({
|
||||
id: link?.id,
|
||||
icon: "Link" as const,
|
||||
value: link?.title || "Untitled",
|
||||
label: link?.title || "Untitled",
|
||||
action: () => actions.openLinkInNewTab(link?.url || "#"),
|
||||
})),
|
||||
})
|
||||
|
||||
export const getPersonalPages = (
|
||||
personalPages: any[],
|
||||
actions: { navigateTo: (path: string) => void },
|
||||
) => ({
|
||||
heading: "Personal Pages",
|
||||
items: personalPages.map((page) => ({
|
||||
id: page?.id,
|
||||
icon: "FileText" as const,
|
||||
value: page?.title || "Untitled",
|
||||
label: page?.title || "Untitled",
|
||||
action: () => actions.navigateTo(`/pages/${page?.id}`),
|
||||
})),
|
||||
})
|
||||
|
||||
export const handleAction = (
|
||||
action: CommandAction,
|
||||
payload: any,
|
||||
callbacks: {
|
||||
setActivePage: (page: string) => void
|
||||
setInputValue: (value: string) => void
|
||||
bounce: () => void
|
||||
closeDialog: () => void
|
||||
},
|
||||
) => {
|
||||
const { setActivePage, setInputValue, bounce, closeDialog } = callbacks
|
||||
|
||||
if (typeof action === "function") {
|
||||
action()
|
||||
closeDialog()
|
||||
return
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "CHANGE_PAGE":
|
||||
if (payload) {
|
||||
setActivePage(payload)
|
||||
setInputValue("")
|
||||
bounce()
|
||||
}
|
||||
break
|
||||
default:
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user