mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-28 19:27:20 +02:00
feat: navigation for command palette (#150)
* feat: add dotenv to dev dep * chore: jest use env * feat * feat: command palette navigation
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import { icons } from "lucide-react"
|
import { icons } from "lucide-react"
|
||||||
import { useCommandActions } from "./hooks/use-command-actions"
|
import { useCommandActions } from "./hooks/use-command-actions"
|
||||||
import { LaAccount } from "@/lib/schema"
|
import { LaAccount } from "@/lib/schema"
|
||||||
|
import { HTMLLikeElement } from "@/lib/utils"
|
||||||
|
|
||||||
export type CommandAction = string | (() => void)
|
export type CommandAction = string | (() => void)
|
||||||
|
|
||||||
export type CommandItemType = {
|
export type CommandItemType = {
|
||||||
icon?: keyof typeof icons
|
icon?: keyof typeof icons
|
||||||
label: string
|
value: string
|
||||||
|
label: HTMLLikeElement | string
|
||||||
action: CommandAction
|
action: CommandAction
|
||||||
payload?: any
|
payload?: any
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
@@ -25,9 +27,16 @@ export const createCommandGroups = (
|
|||||||
{
|
{
|
||||||
heading: "General",
|
heading: "General",
|
||||||
items: [
|
items: [
|
||||||
{ icon: "SunMoon", label: "Change Theme...", action: "CHANGE_PAGE", payload: "changeTheme" },
|
{
|
||||||
|
icon: "SunMoon",
|
||||||
|
value: "Change Theme...",
|
||||||
|
label: "Change Theme...",
|
||||||
|
action: "CHANGE_PAGE",
|
||||||
|
payload: "changeTheme"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: "Copy",
|
icon: "Copy",
|
||||||
|
value: "Copy Current URL",
|
||||||
label: "Copy Current URL",
|
label: "Copy Current URL",
|
||||||
action: actions.copyCurrentURL
|
action: actions.copyCurrentURL
|
||||||
}
|
}
|
||||||
@@ -36,20 +45,88 @@ export const createCommandGroups = (
|
|||||||
{
|
{
|
||||||
heading: "Personal Links",
|
heading: "Personal Links",
|
||||||
items: [
|
items: [
|
||||||
{ icon: "TextSearch", label: "Search Links...", action: "CHANGE_PAGE", payload: "searchLinks" },
|
{
|
||||||
{ icon: "Plus", label: "Create New Link...", action: () => actions.navigateTo("/") }
|
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("/")
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Personal Pages",
|
heading: "Personal Pages",
|
||||||
items: [
|
items: [
|
||||||
{ icon: "FileSearch", label: "Search Pages...", action: "CHANGE_PAGE", payload: "searchPages" },
|
{
|
||||||
|
icon: "FileSearch",
|
||||||
|
value: "Search Pages...",
|
||||||
|
label: "Search Pages...",
|
||||||
|
action: "CHANGE_PAGE",
|
||||||
|
payload: "searchPages"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: "Plus",
|
icon: "Plus",
|
||||||
|
value: "Create New Page...",
|
||||||
label: "Create New Page...",
|
label: "Create New Page...",
|
||||||
action: () => actions.createNewPage(me)
|
action: () => actions.createNewPage(me)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Navigation",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "ArrowRight",
|
||||||
|
value: "Go to Links",
|
||||||
|
label: {
|
||||||
|
tag: "span",
|
||||||
|
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: ["links"] }]
|
||||||
|
},
|
||||||
|
action: () => actions.navigateTo("/links")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ArrowRight",
|
||||||
|
value: "Go to Pages",
|
||||||
|
label: {
|
||||||
|
tag: "span",
|
||||||
|
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: ["pages"] }]
|
||||||
|
},
|
||||||
|
action: () => actions.navigateTo("/pages")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ArrowRight",
|
||||||
|
value: "Go to Search",
|
||||||
|
label: {
|
||||||
|
tag: "span",
|
||||||
|
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: ["search"] }]
|
||||||
|
},
|
||||||
|
action: () => actions.navigateTo("/search")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ArrowRight",
|
||||||
|
value: "Go to Profile",
|
||||||
|
label: {
|
||||||
|
tag: "span",
|
||||||
|
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: ["profile"] }]
|
||||||
|
},
|
||||||
|
action: () => actions.navigateTo("/profile")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ArrowRight",
|
||||||
|
value: "Go to Settings",
|
||||||
|
label: {
|
||||||
|
tag: "span",
|
||||||
|
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: ["settings"] }]
|
||||||
|
},
|
||||||
|
action: () => actions.navigateTo("/settings")
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
searchLinks: [],
|
searchLinks: [],
|
||||||
@@ -58,9 +135,24 @@ export const createCommandGroups = (
|
|||||||
changeTheme: [
|
changeTheme: [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ icon: "Moon", label: "Change Theme to Dark", action: () => actions.changeTheme("dark") },
|
{
|
||||||
{ icon: "Sun", label: "Change Theme to Light", action: () => actions.changeTheme("light") },
|
icon: "Moon",
|
||||||
{ icon: "Monitor", label: "Change Theme to System", action: () => actions.changeTheme("system") }
|
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: "changeThemeToSystem",
|
||||||
|
label: "Change Theme to System",
|
||||||
|
action: () => actions.changeTheme("system")
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import { Command } from "cmdk"
|
|||||||
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
||||||
import { LaIcon } from "../la-icon"
|
import { LaIcon } from "../la-icon"
|
||||||
import { CommandItemType, CommandAction } from "./command-data"
|
import { CommandItemType, CommandAction } from "./command-data"
|
||||||
|
import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils"
|
||||||
|
|
||||||
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||||
action: CommandAction
|
action: CommandAction
|
||||||
handleAction: (action: CommandAction, payload?: any) => void
|
handleAction: (action: CommandAction, payload?: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = ({ content }) => {
|
||||||
|
return <>{renderHTMLLikeElement(content)}</>
|
||||||
|
}
|
||||||
|
|
||||||
export const CommandItem: React.FC<CommandItemProps> = ({ icon, label, action, payload, shortcut, handleAction }) => (
|
export const CommandItem: React.FC<CommandItemProps> = ({ icon, label, action, payload, shortcut, handleAction }) => (
|
||||||
<Command.Item onSelect={() => handleAction(action, payload)}>
|
<Command.Item onSelect={() => handleAction(action, payload)}>
|
||||||
{icon && <LaIcon name={icon} />}
|
{icon && <LaIcon name={icon} />}
|
||||||
<span>{label}</span>
|
<HTMLLikeRenderer content={label} />
|
||||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useCommandActions } from "./hooks/use-command-actions"
|
|||||||
let graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
let graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||||
|
|
||||||
const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||||
items.filter(item => searchRegex.test(item.label)).slice(0, 6)
|
items.filter(item => searchRegex.test(item.value)).slice(0, 6)
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||||
@@ -81,6 +81,7 @@ export function CommandPalette() {
|
|||||||
heading: "Topics",
|
heading: "Topics",
|
||||||
items: raw_graph_data.map(topic => ({
|
items: raw_graph_data.map(topic => ({
|
||||||
icon: "Circle" as const,
|
icon: "Circle" as const,
|
||||||
|
value: topic?.prettyName || "",
|
||||||
label: topic?.prettyName || "",
|
label: topic?.prettyName || "",
|
||||||
action: () => actions.navigateTo(`/${topic?.name}`)
|
action: () => actions.navigateTo(`/${topic?.name}`)
|
||||||
}))
|
}))
|
||||||
@@ -94,6 +95,7 @@ export function CommandPalette() {
|
|||||||
items:
|
items:
|
||||||
me?.root.personalLinks?.map(link => ({
|
me?.root.personalLinks?.map(link => ({
|
||||||
icon: "Link" as const,
|
icon: "Link" as const,
|
||||||
|
value: link?.title || "Untitled",
|
||||||
label: link?.title || "Untitled",
|
label: link?.title || "Untitled",
|
||||||
action: () => actions.openLinkInNewTab(link?.url || "#")
|
action: () => actions.openLinkInNewTab(link?.url || "#")
|
||||||
})) || []
|
})) || []
|
||||||
@@ -107,6 +109,7 @@ export function CommandPalette() {
|
|||||||
items:
|
items:
|
||||||
me?.root.personalPages?.map(page => ({
|
me?.root.personalPages?.map(page => ({
|
||||||
icon: "FileText" as const,
|
icon: "FileText" as const,
|
||||||
|
value: page?.title || "Untitled",
|
||||||
label: page?.title || "Untitled",
|
label: page?.title || "Untitled",
|
||||||
action: () => actions.navigateTo(`/pages/${page?.id}`)
|
action: () => actions.navigateTo(`/pages/${page?.id}`)
|
||||||
})) || []
|
})) || []
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { Config } from "jest"
|
import type { Config } from "jest"
|
||||||
import nextJest from "next/jest.js"
|
import nextJest from "next/jest.js"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
|
||||||
const createJestConfig = nextJest({
|
const createJestConfig = nextJest({
|
||||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||||
dir: "./"
|
dir: "./"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" })
|
||||||
|
|
||||||
// Add any custom config to be passed to Jest
|
// Add any custom config to be passed to Jest
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
coverageProvider: "v8",
|
coverageProvider: "v8",
|
||||||
@@ -13,7 +16,8 @@ const config: Config = {
|
|||||||
// Add more setup options before each test is run
|
// Add more setup options before each test is run
|
||||||
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
// Automatically clear mock calls, instances, contexts and results before every test
|
// Automatically clear mock calls, instances, contexts and results before every test
|
||||||
clearMocks: true
|
clearMocks: true,
|
||||||
|
moduleDirectories: ["node_modules", __dirname]
|
||||||
}
|
}
|
||||||
|
|
||||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||||
|
|||||||
45
web/lib/utils/htmlLikeElementUtil.test.tsx
Normal file
45
web/lib/utils/htmlLikeElementUtil.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { render } from "@testing-library/react"
|
||||||
|
import { HTMLLikeElement, renderHTMLLikeElement } from "./htmlLikeElementUtil"
|
||||||
|
|
||||||
|
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = ({ content }) => {
|
||||||
|
return <>{renderHTMLLikeElement(content)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HTML-like Element Utility", () => {
|
||||||
|
test("HTMLLikeRenderer renders simple string content", () => {
|
||||||
|
const { getByText } = render(<HTMLLikeRenderer content="Hello, World!" />)
|
||||||
|
expect(getByText("Hello, World!")).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("HTMLLikeRenderer renders HTML-like structure", () => {
|
||||||
|
const content: HTMLLikeElement = {
|
||||||
|
tag: "div",
|
||||||
|
attributes: { className: "test-class" },
|
||||||
|
children: ["Hello, ", { tag: "strong", children: ["World"] }, "!"]
|
||||||
|
}
|
||||||
|
const { container, getByText } = render(<HTMLLikeRenderer content={content} />)
|
||||||
|
expect(container.firstChild).toHaveProperty("className", "test-class")
|
||||||
|
const strongElement = getByText("World")
|
||||||
|
expect(strongElement.tagName.toLowerCase()).toBe("strong")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("HTMLLikeRenderer handles multiple attributes", () => {
|
||||||
|
const content: HTMLLikeElement = {
|
||||||
|
tag: "div",
|
||||||
|
attributes: {
|
||||||
|
className: "test-class",
|
||||||
|
id: "test-id",
|
||||||
|
"data-testid": "custom-element",
|
||||||
|
style: { color: "red", fontSize: "16px" }
|
||||||
|
},
|
||||||
|
children: ["Test Content"]
|
||||||
|
}
|
||||||
|
const { getByTestId } = render(<HTMLLikeRenderer content={content} />)
|
||||||
|
const element = getByTestId("custom-element")
|
||||||
|
expect(element.className).toBe("test-class")
|
||||||
|
expect(element.id).toBe("test-id")
|
||||||
|
expect(element.style.color).toBe("red")
|
||||||
|
expect(element.style.fontSize).toBe("16px")
|
||||||
|
})
|
||||||
|
})
|
||||||
21
web/lib/utils/htmlLikeElementUtil.ts
Normal file
21
web/lib/utils/htmlLikeElementUtil.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type HTMLAttributes = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HTMLLikeElement = {
|
||||||
|
tag: keyof JSX.IntrinsicElements
|
||||||
|
attributes?: HTMLAttributes
|
||||||
|
children?: (HTMLLikeElement | string)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderHTMLLikeElement = (element: HTMLLikeElement | string): React.ReactNode => {
|
||||||
|
if (typeof element === "string") {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tag, attributes = {}, children = [] } = element
|
||||||
|
|
||||||
|
return React.createElement(tag, attributes, ...children.map(child => renderHTMLLikeElement(child)))
|
||||||
|
}
|
||||||
@@ -37,3 +37,4 @@ export function shuffleArray<T>(array: T[]): T[] {
|
|||||||
export * from "./urls"
|
export * from "./urls"
|
||||||
export * from "./slug"
|
export * from "./slug"
|
||||||
export * from "./keyboard"
|
export * from "./keyboard"
|
||||||
|
export * from "./htmlLikeElementUtil"
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jazz-react": "0.7.35-guest-auth.5",
|
|
||||||
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
||||||
|
"jazz-react": "0.7.35-guest-auth.5",
|
||||||
"jazz-react-auth-clerk": "0.7.35-guest-auth.5",
|
"jazz-react-auth-clerk": "0.7.35-guest-auth.5",
|
||||||
"jazz-tools": "0.7.35-guest-auth.5",
|
"jazz-tools": "0.7.35-guest-auth.5",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.9.3",
|
||||||
@@ -91,7 +91,6 @@
|
|||||||
"streaming-markdown": "^0.0.14",
|
"streaming-markdown": "^0.0.14",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zsa": "^0.6.0"
|
"zsa": "^0.6.0"
|
||||||
},
|
},
|
||||||
@@ -102,6 +101,7 @@
|
|||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user