Refactor global search components

This commit is contained in:
2026-01-18 00:09:35 +00:00
parent 7b9610369d
commit 9a84d99614
6 changed files with 785 additions and 598 deletions

View File

@@ -1,60 +1,19 @@
import { useState, useMemo, useEffect } from 'react'
import { useKV } from '@/hooks/use-kv'
import { CommandDialog, CommandInput, CommandList } from '@/components/ui/command'
import { EmptyState } from '@/components/global-search/EmptyState'
import { RecentSearches } from '@/components/global-search/RecentSearches'
import { SearchResults } from '@/components/global-search/SearchResults'
import { useGlobalSearchData } from '@/components/global-search/useGlobalSearchData'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Code,
Database,
Tree,
PaintBrush,
Flask,
Play,
BookOpen,
Cube,
FlowArrow,
Wrench,
FileText,
Gear,
DeviceMobile,
Faders,
ChartBar,
Image,
File,
Folder,
MagnifyingGlass,
ClockCounterClockwise,
X,
Lightbulb,
} from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ComponentTree, Workflow, Lambda, PlaywrightTest, StorybookStory, UnitTest } from '@/types/project'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}
ComponentNode,
ComponentTree,
Lambda,
PlaywrightTest,
PrismaModel,
ProjectFile,
StorybookStory,
UnitTest,
Workflow,
} from '@/types/project'
interface GlobalSearchProps {
open: boolean
@@ -87,361 +46,18 @@ export function GlobalSearch({
onNavigate,
onFileSelect,
}: GlobalSearchProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchHistory, setSearchHistory] = useKV<SearchHistoryItem[]>('search-history', [])
useEffect(() => {
if (!open) {
setSearchQuery('')
}
}, [open])
const addToHistory = (query: string, result?: SearchResult) => {
if (!query.trim()) return
const historyItem: SearchHistoryItem = {
id: `history-${Date.now()}`,
query: query.trim(),
timestamp: Date.now(),
resultId: result?.id,
resultTitle: result?.title,
resultCategory: result?.category,
}
setSearchHistory((currentHistory) => {
const filtered = (currentHistory || []).filter(
(item) => item.query.toLowerCase() !== query.toLowerCase()
)
return [historyItem, ...filtered].slice(0, 20)
})
}
const clearHistory = () => {
setSearchHistory([])
}
const removeHistoryItem = (id: string) => {
setSearchHistory((currentHistory) =>
(currentHistory || []).filter((item) => item.id !== id)
)
}
const allResults = useMemo<SearchResult[]>(() => {
const results: SearchResult[] = []
results.push({
id: 'nav-dashboard',
title: 'Dashboard',
subtitle: 'View project overview and statistics',
category: 'Navigation',
icon: <ChartBar size={18} weight="duotone" />,
action: () => onNavigate('dashboard'),
tags: ['home', 'overview', 'stats', 'metrics'],
})
results.push({
id: 'nav-code',
title: 'Code Editor',
subtitle: 'Edit project files with Monaco',
category: 'Navigation',
icon: <Code size={18} weight="duotone" />,
action: () => onNavigate('code'),
tags: ['editor', 'monaco', 'typescript', 'javascript'],
})
results.push({
id: 'nav-models',
title: 'Models',
subtitle: 'Design Prisma database models',
category: 'Navigation',
icon: <Database size={18} weight="duotone" />,
action: () => onNavigate('models'),
tags: ['prisma', 'database', 'schema', 'orm'],
})
results.push({
id: 'nav-components',
title: 'Components',
subtitle: 'Build React components',
category: 'Navigation',
icon: <Tree size={18} weight="duotone" />,
action: () => onNavigate('components'),
tags: ['react', 'mui', 'ui', 'design'],
})
results.push({
id: 'nav-component-trees',
title: 'Component Trees',
subtitle: 'Manage component hierarchies',
category: 'Navigation',
icon: <Tree size={18} weight="duotone" />,
action: () => onNavigate('component-trees'),
tags: ['hierarchy', 'structure', 'layout'],
})
results.push({
id: 'nav-workflows',
title: 'Workflows',
subtitle: 'Design n8n-style workflows',
category: 'Navigation',
icon: <FlowArrow size={18} weight="duotone" />,
action: () => onNavigate('workflows'),
tags: ['automation', 'n8n', 'flow', 'pipeline'],
})
results.push({
id: 'nav-lambdas',
title: 'Lambdas',
subtitle: 'Create serverless functions',
category: 'Navigation',
icon: <Code size={18} weight="duotone" />,
action: () => onNavigate('lambdas'),
tags: ['serverless', 'functions', 'api'],
})
results.push({
id: 'nav-styling',
title: 'Styling',
subtitle: 'Design themes and colors',
category: 'Navigation',
icon: <PaintBrush size={18} weight="duotone" />,
action: () => onNavigate('styling'),
tags: ['theme', 'colors', 'css', 'design'],
})
results.push({
id: 'nav-flask',
title: 'Flask API',
subtitle: 'Configure Flask backend',
category: 'Navigation',
icon: <Flask size={18} weight="duotone" />,
action: () => onNavigate('flask'),
tags: ['python', 'backend', 'api', 'rest'],
})
results.push({
id: 'nav-playwright',
title: 'Playwright Tests',
subtitle: 'E2E testing configuration',
category: 'Navigation',
icon: <Play size={18} weight="duotone" />,
action: () => onNavigate('playwright'),
tags: ['testing', 'e2e', 'automation'],
})
results.push({
id: 'nav-storybook',
title: 'Storybook',
subtitle: 'Component documentation',
category: 'Navigation',
icon: <BookOpen size={18} weight="duotone" />,
action: () => onNavigate('storybook'),
tags: ['documentation', 'components', 'stories'],
})
results.push({
id: 'nav-unit-tests',
title: 'Unit Tests',
subtitle: 'Configure unit testing',
category: 'Navigation',
icon: <Cube size={18} weight="duotone" />,
action: () => onNavigate('unit-tests'),
tags: ['testing', 'jest', 'vitest'],
})
results.push({
id: 'nav-errors',
title: 'Error Repair',
subtitle: 'Auto-detect and fix errors',
category: 'Navigation',
icon: <Wrench size={18} weight="duotone" />,
action: () => onNavigate('errors'),
tags: ['debugging', 'errors', 'fixes'],
})
results.push({
id: 'nav-docs',
title: 'Documentation',
subtitle: 'View project documentation',
category: 'Navigation',
icon: <FileText size={18} weight="duotone" />,
action: () => onNavigate('docs'),
tags: ['readme', 'guide', 'help'],
})
results.push({
id: 'nav-sass',
title: 'Sass Styles',
subtitle: 'Custom Sass styling',
category: 'Navigation',
icon: <PaintBrush size={18} weight="duotone" />,
action: () => onNavigate('sass'),
tags: ['sass', 'scss', 'styles', 'css'],
})
results.push({
id: 'nav-favicon',
title: 'Favicon Designer',
subtitle: 'Design app icons',
category: 'Navigation',
icon: <Image size={18} weight="duotone" />,
action: () => onNavigate('favicon'),
tags: ['icon', 'logo', 'design'],
})
results.push({
id: 'nav-settings',
title: 'Settings',
subtitle: 'Configure Next.js and npm',
category: 'Navigation',
icon: <Gear size={18} weight="duotone" />,
action: () => onNavigate('settings'),
tags: ['config', 'nextjs', 'npm', 'packages'],
})
results.push({
id: 'nav-pwa',
title: 'PWA Settings',
subtitle: 'Progressive Web App config',
category: 'Navigation',
icon: <DeviceMobile size={18} weight="duotone" />,
action: () => onNavigate('pwa'),
tags: ['mobile', 'install', 'offline'],
})
results.push({
id: 'nav-features',
title: 'Feature Toggles',
subtitle: 'Enable or disable features',
category: 'Navigation',
icon: <Faders size={18} weight="duotone" />,
action: () => onNavigate('features'),
tags: ['settings', 'toggles', 'enable'],
})
results.push({
id: 'nav-ideas',
title: 'Feature Ideas',
subtitle: 'Brainstorm and organize ideas',
category: 'Navigation',
icon: <Lightbulb size={18} weight="duotone" />,
action: () => onNavigate('ideas'),
tags: ['brainstorm', 'ideas', 'planning'],
})
files.forEach((file) => {
results.push({
id: `file-${file.id}`,
title: file.name,
subtitle: file.path,
category: 'Files',
icon: <File size={18} weight="duotone" />,
action: () => {
onNavigate('code')
onFileSelect(file.id)
},
tags: [file.language, file.path, 'code', 'file'],
})
})
models.forEach((model) => {
results.push({
id: `model-${model.id}`,
title: model.name,
subtitle: `${model.fields.length} fields`,
category: 'Models',
icon: <Database size={18} weight="duotone" />,
action: () => onNavigate('models', model.id),
tags: ['prisma', 'database', 'schema', model.name.toLowerCase()],
})
})
components.forEach((component) => {
results.push({
id: `component-${component.id}`,
title: component.name,
subtitle: component.type,
category: 'Components',
icon: <Tree size={18} weight="duotone" />,
action: () => onNavigate('components', component.id),
tags: ['react', 'component', component.type.toLowerCase(), component.name.toLowerCase()],
})
})
componentTrees.forEach((tree) => {
results.push({
id: `tree-${tree.id}`,
title: tree.name,
subtitle: tree.description || `${tree.rootNodes.length} root nodes`,
category: 'Component Trees',
icon: <Folder size={18} weight="duotone" />,
action: () => onNavigate('component-trees', tree.id),
tags: ['hierarchy', 'structure', tree.name.toLowerCase()],
})
})
workflows.forEach((workflow) => {
results.push({
id: `workflow-${workflow.id}`,
title: workflow.name,
subtitle: workflow.description || `${workflow.nodes.length} nodes`,
category: 'Workflows',
icon: <FlowArrow size={18} weight="duotone" />,
action: () => onNavigate('workflows', workflow.id),
tags: ['automation', 'flow', workflow.name.toLowerCase()],
})
})
lambdas.forEach((lambda) => {
results.push({
id: `lambda-${lambda.id}`,
title: lambda.name,
subtitle: lambda.description || lambda.runtime,
category: 'Lambdas',
icon: <Code size={18} weight="duotone" />,
action: () => onNavigate('lambdas', lambda.id),
tags: ['serverless', 'function', lambda.runtime, lambda.name.toLowerCase()],
})
})
playwrightTests.forEach((test) => {
results.push({
id: `playwright-${test.id}`,
title: test.name,
subtitle: test.description,
category: 'Playwright Tests',
icon: <Play size={18} weight="duotone" />,
action: () => onNavigate('playwright', test.id),
tags: ['testing', 'e2e', test.name.toLowerCase()],
})
})
storybookStories.forEach((story) => {
results.push({
id: `storybook-${story.id}`,
title: story.storyName,
subtitle: story.componentName,
category: 'Storybook Stories',
icon: <BookOpen size={18} weight="duotone" />,
action: () => onNavigate('storybook', story.id),
tags: ['documentation', 'story', story.componentName.toLowerCase()],
})
})
unitTests.forEach((test) => {
results.push({
id: `unit-test-${test.id}`,
title: test.name,
subtitle: test.description,
category: 'Unit Tests',
icon: <Cube size={18} weight="duotone" />,
action: () => onNavigate('unit-tests', test.id),
tags: ['testing', 'unit', test.name.toLowerCase()],
})
})
return results
}, [
const {
searchQuery,
setSearchQuery,
recentSearches,
groupedResults,
clearHistory,
removeHistoryItem,
handleSelect,
handleHistorySelect,
} = useGlobalSearchData({
open,
onOpenChange,
files,
models,
components,
@@ -453,201 +69,26 @@ export function GlobalSearch({
unitTests,
onNavigate,
onFileSelect,
])
const filteredResults = useMemo(() => {
if (!searchQuery.trim()) {
return []
}
const query = searchQuery.toLowerCase().trim()
const queryWords = query.split(/\s+/)
return allResults
.map((result) => {
let score = 0
const titleLower = result.title.toLowerCase()
const subtitleLower = result.subtitle?.toLowerCase() || ''
const categoryLower = result.category.toLowerCase()
const tagsLower = result.tags?.map(t => t.toLowerCase()) || []
if (titleLower === query) score += 100
else if (titleLower.startsWith(query)) score += 50
else if (titleLower.includes(query)) score += 30
if (subtitleLower.includes(query)) score += 20
if (categoryLower.includes(query)) score += 15
tagsLower.forEach(tag => {
if (tag === query) score += 40
else if (tag.includes(query)) score += 10
})
queryWords.forEach(word => {
if (titleLower.includes(word)) score += 5
if (subtitleLower.includes(word)) score += 3
if (tagsLower.some(tag => tag.includes(word))) score += 2
})
return { result, score }
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 50)
.map(({ result }) => result)
}, [allResults, searchQuery])
const recentSearches = useMemo(() => {
const safeHistory = searchHistory || []
return safeHistory
.slice(0, 10)
.map((item) => {
const result = allResults.find((r) => r.id === item.resultId)
return {
historyItem: item,
result,
}
})
}, [searchHistory, allResults])
const groupedResults = useMemo(() => {
const groups: Record<string, SearchResult[]> = {}
filteredResults.forEach((result) => {
if (!groups[result.category]) {
groups[result.category] = []
}
groups[result.category].push(result)
})
return groups
}, [filteredResults])
const handleSelect = (result: SearchResult) => {
addToHistory(searchQuery, result)
result.action()
onOpenChange(false)
}
const handleHistorySelect = (historyItem: SearchHistoryItem, result?: SearchResult) => {
if (result) {
result.action()
onOpenChange(false)
} else {
setSearchQuery(historyItem.query)
}
}
})
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput
placeholder="Search features, files, models, components..."
<CommandInput
placeholder="Search features, files, models, components..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-6">
<MagnifyingGlass size={48} weight="duotone" className="text-muted-foreground" />
<p className="text-sm text-muted-foreground">No results found</p>
</div>
</CommandEmpty>
{!searchQuery.trim() && recentSearches.length > 0 && (
<>
<CommandGroup
heading={
<div className="flex items-center justify-between w-full pr-2">
<span>Recent Searches</span>
<Button
variant="ghost"
size="sm"
onClick={clearHistory}
className="h-6 px-2 text-xs"
>
Clear All
</Button>
</div>
}
>
{recentSearches.map(({ historyItem, result }) => (
<CommandItem
key={historyItem.id}
value={historyItem.id}
onSelect={() => handleHistorySelect(historyItem, result)}
className="flex items-center gap-3 px-4 py-3 cursor-pointer group"
>
<div className="flex-shrink-0 text-muted-foreground">
<ClockCounterClockwise size={18} weight="duotone" />
</div>
<div className="flex-1 min-w-0">
{result ? (
<>
<div className="font-medium truncate">{result.title}</div>
<div className="text-xs text-muted-foreground truncate">
Searched: {historyItem.query}
</div>
</>
) : (
<>
<div className="font-medium truncate">{historyItem.query}</div>
<div className="text-xs text-muted-foreground">
Search again
</div>
</>
)}
</div>
{result && (
<Badge variant="outline" className="flex-shrink-0 text-xs">
{result.category}
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
removeHistoryItem(historyItem.id)
}}
>
<X size={14} />
</Button>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
<EmptyState />
{!searchQuery.trim() && (
<RecentSearches
recentSearches={recentSearches}
onClear={clearHistory}
onSelect={handleHistorySelect}
onRemove={removeHistoryItem}
/>
)}
{Object.entries(groupedResults).map(([category, results], index) => (
<div key={category}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={category}>
{results.map((result) => (
<CommandItem
key={result.id}
value={result.id}
onSelect={() => handleSelect(result)}
className="flex items-center gap-3 px-4 py-3 cursor-pointer"
>
<div className="flex-shrink-0 text-muted-foreground">
{result.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
{result.subtitle && (
<div className="text-xs text-muted-foreground truncate">
{result.subtitle}
</div>
)}
</div>
<Badge variant="outline" className="flex-shrink-0 text-xs">
{category}
</Badge>
</CommandItem>
))}
</CommandGroup>
</div>
))}
<SearchResults groupedResults={groupedResults} onSelect={handleSelect} />
</CommandList>
</CommandDialog>
)

View File

@@ -0,0 +1,13 @@
import { MagnifyingGlass } from '@phosphor-icons/react'
import { CommandEmpty } from '@/components/ui/command'
export function EmptyState() {
return (
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-6">
<MagnifyingGlass size={48} weight="duotone" className="text-muted-foreground" />
<p className="text-sm text-muted-foreground">No results found</p>
</div>
</CommandEmpty>
)
}

View File

@@ -0,0 +1,106 @@
import { ClockCounterClockwise, X } from '@phosphor-icons/react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}
interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
interface RecentSearchesProps {
recentSearches: Array<{ historyItem: SearchHistoryItem; result?: SearchResult }>
onClear: () => void
onSelect: (historyItem: SearchHistoryItem, result?: SearchResult) => void
onRemove: (id: string) => void
}
export function RecentSearches({
recentSearches,
onClear,
onSelect,
onRemove,
}: RecentSearchesProps) {
if (recentSearches.length === 0) {
return null
}
return (
<>
<CommandGroup
heading={
<div className="flex items-center justify-between w-full pr-2">
<span>Recent Searches</span>
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-6 px-2 text-xs"
>
Clear All
</Button>
</div>
}
>
{recentSearches.map(({ historyItem, result }) => (
<CommandItem
key={historyItem.id}
value={historyItem.id}
onSelect={() => onSelect(historyItem, result)}
className="flex items-center gap-3 px-4 py-3 cursor-pointer group"
>
<div className="flex-shrink-0 text-muted-foreground">
<ClockCounterClockwise size={18} weight="duotone" />
</div>
<div className="flex-1 min-w-0">
{result ? (
<>
<div className="font-medium truncate">{result.title}</div>
<div className="text-xs text-muted-foreground truncate">
Searched: {historyItem.query}
</div>
</>
) : (
<>
<div className="font-medium truncate">{historyItem.query}</div>
<div className="text-xs text-muted-foreground">Search again</div>
</>
)}
</div>
{result && (
<Badge variant="outline" className="flex-shrink-0 text-xs">
{result.category}
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 flex-shrink-0"
onClick={(event) => {
event.stopPropagation()
onRemove(historyItem.id)
}}
>
<X size={14} />
</Button>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)
}

View File

@@ -0,0 +1,54 @@
import { Badge } from '@/components/ui/badge'
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
interface SearchResultsProps {
groupedResults: Record<string, SearchResult[]>
onSelect: (result: SearchResult) => void
}
export function SearchResults({ groupedResults, onSelect }: SearchResultsProps) {
return (
<>
{Object.entries(groupedResults).map(([category, results], index) => (
<div key={category}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={category}>
{results.map((result) => (
<CommandItem
key={result.id}
value={result.id}
onSelect={() => onSelect(result)}
className="flex items-center gap-3 px-4 py-3 cursor-pointer"
>
<div className="flex-shrink-0 text-muted-foreground">
{result.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
{result.subtitle && (
<div className="text-xs text-muted-foreground truncate">
{result.subtitle}
</div>
)}
</div>
<Badge variant="outline" className="flex-shrink-0 text-xs">
{category}
</Badge>
</CommandItem>
))}
</CommandGroup>
</div>
))}
</>
)
}

View File

@@ -0,0 +1,391 @@
import { useEffect, useMemo, useState } from 'react'
import { useKV } from '@/hooks/use-kv'
import {
BookOpen,
ChartBar,
Code,
Cube,
Database,
DeviceMobile,
Faders,
File,
FileText,
Flask,
FlowArrow,
Folder,
Gear,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
} from '@phosphor-icons/react'
import {
ComponentNode,
ComponentTree,
Lambda,
PlaywrightTest,
PrismaModel,
ProjectFile,
StorybookStory,
UnitTest,
Workflow,
} from '@/types/project'
import navigationData from '@/data/global-search.json'
export interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
export interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}
const navigationIconMap = {
BookOpen,
ChartBar,
Code,
Cube,
Database,
DeviceMobile,
Faders,
FileText,
Flask,
FlowArrow,
Gear,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
}
type NavigationIconName = keyof typeof navigationIconMap
interface NavigationMeta {
id: string
title: string
subtitle: string
category: string
icon: NavigationIconName
tab: string
tags: string[]
}
interface UseGlobalSearchDataProps {
open: boolean
onOpenChange: (open: boolean) => void
files: ProjectFile[]
models: PrismaModel[]
components: ComponentNode[]
componentTrees: ComponentTree[]
workflows: Workflow[]
lambdas: Lambda[]
playwrightTests: PlaywrightTest[]
storybookStories: StorybookStory[]
unitTests: UnitTest[]
onNavigate: (tab: string, itemId?: string) => void
onFileSelect: (fileId: string) => void
}
export function useGlobalSearchData({
open,
onOpenChange,
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
onNavigate,
onFileSelect,
}: UseGlobalSearchDataProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchHistory, setSearchHistory] = useKV<SearchHistoryItem[]>('search-history', [])
useEffect(() => {
if (!open) {
setSearchQuery('')
}
}, [open])
const addToHistory = (query: string, result?: SearchResult) => {
if (!query.trim()) return
const historyItem: SearchHistoryItem = {
id: `history-${Date.now()}`,
query: query.trim(),
timestamp: Date.now(),
resultId: result?.id,
resultTitle: result?.title,
resultCategory: result?.category,
}
setSearchHistory((currentHistory) => {
const filtered = (currentHistory || []).filter(
(item) => item.query.toLowerCase() !== query.toLowerCase()
)
return [historyItem, ...filtered].slice(0, 20)
})
}
const clearHistory = () => {
setSearchHistory([])
}
const removeHistoryItem = (id: string) => {
setSearchHistory((currentHistory) =>
(currentHistory || []).filter((item) => item.id !== id)
)
}
const allResults = useMemo<SearchResult[]>(() => {
const results: SearchResult[] = []
const navigationResults = (navigationData as NavigationMeta[]).map((item) => {
const Icon = navigationIconMap[item.icon]
return {
id: item.id,
title: item.title,
subtitle: item.subtitle,
category: item.category,
icon: <Icon size={18} weight="duotone" />,
action: () => onNavigate(item.tab),
tags: item.tags,
}
})
results.push(...navigationResults)
files.forEach((file) => {
results.push({
id: `file-${file.id}`,
title: file.name,
subtitle: file.path,
category: 'Files',
icon: <File size={18} weight="duotone" />,
action: () => {
onNavigate('code')
onFileSelect(file.id)
},
tags: [file.language, file.path, 'code', 'file'],
})
})
models.forEach((model) => {
results.push({
id: `model-${model.id}`,
title: model.name,
subtitle: `${model.fields.length} fields`,
category: 'Models',
icon: <Database size={18} weight="duotone" />,
action: () => onNavigate('models', model.id),
tags: ['prisma', 'database', 'schema', model.name.toLowerCase()],
})
})
components.forEach((component) => {
results.push({
id: `component-${component.id}`,
title: component.name,
subtitle: component.type,
category: 'Components',
icon: <Tree size={18} weight="duotone" />,
action: () => onNavigate('components', component.id),
tags: ['react', 'component', component.type.toLowerCase(), component.name.toLowerCase()],
})
})
componentTrees.forEach((tree) => {
results.push({
id: `tree-${tree.id}`,
title: tree.name,
subtitle: tree.description || `${tree.rootNodes.length} root nodes`,
category: 'Component Trees',
icon: <Folder size={18} weight="duotone" />,
action: () => onNavigate('component-trees', tree.id),
tags: ['hierarchy', 'structure', tree.name.toLowerCase()],
})
})
workflows.forEach((workflow) => {
results.push({
id: `workflow-${workflow.id}`,
title: workflow.name,
subtitle: workflow.description || `${workflow.nodes.length} nodes`,
category: 'Workflows',
icon: <FlowArrow size={18} weight="duotone" />,
action: () => onNavigate('workflows', workflow.id),
tags: ['automation', 'flow', workflow.name.toLowerCase()],
})
})
lambdas.forEach((lambda) => {
results.push({
id: `lambda-${lambda.id}`,
title: lambda.name,
subtitle: lambda.description || lambda.runtime,
category: 'Lambdas',
icon: <Code size={18} weight="duotone" />,
action: () => onNavigate('lambdas', lambda.id),
tags: ['serverless', 'function', lambda.runtime, lambda.name.toLowerCase()],
})
})
playwrightTests.forEach((test) => {
results.push({
id: `playwright-${test.id}`,
title: test.name,
subtitle: test.description,
category: 'Playwright Tests',
icon: <Play size={18} weight="duotone" />,
action: () => onNavigate('playwright', test.id),
tags: ['testing', 'e2e', test.name.toLowerCase()],
})
})
storybookStories.forEach((story) => {
results.push({
id: `storybook-${story.id}`,
title: story.storyName,
subtitle: story.componentName,
category: 'Storybook Stories',
icon: <BookOpen size={18} weight="duotone" />,
action: () => onNavigate('storybook', story.id),
tags: ['documentation', 'story', story.componentName.toLowerCase()],
})
})
unitTests.forEach((test) => {
results.push({
id: `unit-test-${test.id}`,
title: test.name,
subtitle: test.description,
category: 'Unit Tests',
icon: <Cube size={18} weight="duotone" />,
action: () => onNavigate('unit-tests', test.id),
tags: ['testing', 'unit', test.name.toLowerCase()],
})
})
return results
}, [
files,
models,
components,
componentTrees,
workflows,
lambdas,
playwrightTests,
storybookStories,
unitTests,
onNavigate,
onFileSelect,
])
const filteredResults = useMemo(() => {
if (!searchQuery.trim()) {
return []
}
const query = searchQuery.toLowerCase().trim()
const queryWords = query.split(/\s+/)
return allResults
.map((result) => {
let score = 0
const titleLower = result.title.toLowerCase()
const subtitleLower = result.subtitle?.toLowerCase() || ''
const categoryLower = result.category.toLowerCase()
const tagsLower = result.tags?.map((tag) => tag.toLowerCase()) || []
if (titleLower === query) score += 100
else if (titleLower.startsWith(query)) score += 50
else if (titleLower.includes(query)) score += 30
if (subtitleLower.includes(query)) score += 20
if (categoryLower.includes(query)) score += 15
tagsLower.forEach((tag) => {
if (tag === query) score += 40
else if (tag.includes(query)) score += 10
})
queryWords.forEach((word) => {
if (titleLower.includes(word)) score += 5
if (subtitleLower.includes(word)) score += 3
if (tagsLower.some((tag) => tag.includes(word))) score += 2
})
return { result, score }
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 50)
.map(({ result }) => result)
}, [allResults, searchQuery])
const recentSearches = useMemo(() => {
const safeHistory = searchHistory || []
return safeHistory.slice(0, 10).map((item) => {
const result = allResults.find((searchResult) => searchResult.id === item.resultId)
return {
historyItem: item,
result,
}
})
}, [searchHistory, allResults])
const groupedResults = useMemo(() => {
const groups: Record<string, SearchResult[]> = {}
filteredResults.forEach((result) => {
if (!groups[result.category]) {
groups[result.category] = []
}
groups[result.category].push(result)
})
return groups
}, [filteredResults])
const handleSelect = (result: SearchResult) => {
addToHistory(searchQuery, result)
result.action()
onOpenChange(false)
}
const handleHistorySelect = (historyItem: SearchHistoryItem, result?: SearchResult) => {
if (result) {
result.action()
onOpenChange(false)
} else {
setSearchQuery(historyItem.query)
}
}
return {
searchQuery,
setSearchQuery,
recentSearches,
groupedResults,
clearHistory,
removeHistoryItem,
handleSelect,
handleHistorySelect,
}
}

182
src/data/global-search.json Normal file
View File

@@ -0,0 +1,182 @@
[
{
"id": "nav-dashboard",
"title": "Dashboard",
"subtitle": "View project overview and statistics",
"category": "Navigation",
"icon": "ChartBar",
"tab": "dashboard",
"tags": ["home", "overview", "stats", "metrics"]
},
{
"id": "nav-code",
"title": "Code Editor",
"subtitle": "Edit project files with Monaco",
"category": "Navigation",
"icon": "Code",
"tab": "code",
"tags": ["editor", "monaco", "typescript", "javascript"]
},
{
"id": "nav-models",
"title": "Models",
"subtitle": "Design Prisma database models",
"category": "Navigation",
"icon": "Database",
"tab": "models",
"tags": ["prisma", "database", "schema", "orm"]
},
{
"id": "nav-components",
"title": "Components",
"subtitle": "Build React components",
"category": "Navigation",
"icon": "Tree",
"tab": "components",
"tags": ["react", "mui", "ui", "design"]
},
{
"id": "nav-component-trees",
"title": "Component Trees",
"subtitle": "Manage component hierarchies",
"category": "Navigation",
"icon": "Tree",
"tab": "component-trees",
"tags": ["hierarchy", "structure", "layout"]
},
{
"id": "nav-workflows",
"title": "Workflows",
"subtitle": "Design n8n-style workflows",
"category": "Navigation",
"icon": "FlowArrow",
"tab": "workflows",
"tags": ["automation", "n8n", "flow", "pipeline"]
},
{
"id": "nav-lambdas",
"title": "Lambdas",
"subtitle": "Create serverless functions",
"category": "Navigation",
"icon": "Code",
"tab": "lambdas",
"tags": ["serverless", "functions", "api"]
},
{
"id": "nav-styling",
"title": "Styling",
"subtitle": "Design themes and colors",
"category": "Navigation",
"icon": "PaintBrush",
"tab": "styling",
"tags": ["theme", "colors", "css", "design"]
},
{
"id": "nav-flask",
"title": "Flask API",
"subtitle": "Configure Flask backend",
"category": "Navigation",
"icon": "Flask",
"tab": "flask",
"tags": ["python", "backend", "api", "rest"]
},
{
"id": "nav-playwright",
"title": "Playwright Tests",
"subtitle": "E2E testing configuration",
"category": "Navigation",
"icon": "Play",
"tab": "playwright",
"tags": ["testing", "e2e", "automation"]
},
{
"id": "nav-storybook",
"title": "Storybook",
"subtitle": "Component documentation",
"category": "Navigation",
"icon": "BookOpen",
"tab": "storybook",
"tags": ["documentation", "components", "stories"]
},
{
"id": "nav-unit-tests",
"title": "Unit Tests",
"subtitle": "Configure unit testing",
"category": "Navigation",
"icon": "Cube",
"tab": "unit-tests",
"tags": ["testing", "jest", "vitest"]
},
{
"id": "nav-errors",
"title": "Error Repair",
"subtitle": "Auto-detect and fix errors",
"category": "Navigation",
"icon": "Wrench",
"tab": "errors",
"tags": ["debugging", "errors", "fixes"]
},
{
"id": "nav-docs",
"title": "Documentation",
"subtitle": "View project documentation",
"category": "Navigation",
"icon": "FileText",
"tab": "docs",
"tags": ["readme", "guide", "help"]
},
{
"id": "nav-sass",
"title": "Sass Styles",
"subtitle": "Custom Sass styling",
"category": "Navigation",
"icon": "PaintBrush",
"tab": "sass",
"tags": ["sass", "scss", "styles", "css"]
},
{
"id": "nav-favicon",
"title": "Favicon Designer",
"subtitle": "Design app icons",
"category": "Navigation",
"icon": "Image",
"tab": "favicon",
"tags": ["icon", "logo", "design"]
},
{
"id": "nav-settings",
"title": "Settings",
"subtitle": "Configure Next.js and npm",
"category": "Navigation",
"icon": "Gear",
"tab": "settings",
"tags": ["config", "nextjs", "npm", "packages"]
},
{
"id": "nav-pwa",
"title": "PWA Settings",
"subtitle": "Progressive Web App config",
"category": "Navigation",
"icon": "DeviceMobile",
"tab": "pwa",
"tags": ["mobile", "install", "offline"]
},
{
"id": "nav-features",
"title": "Feature Toggles",
"subtitle": "Enable or disable features",
"category": "Navigation",
"icon": "Faders",
"tab": "features",
"tags": ["settings", "toggles", "enable"]
},
{
"id": "nav-ideas",
"title": "Feature Ideas",
"subtitle": "Brainstorm and organize ideas",
"category": "Navigation",
"icon": "Lightbulb",
"tab": "ideas",
"tags": ["brainstorm", "ideas", "planning"]
}
]