mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge pull request #20 from johndoe6345789/codex/split-globalsearch-into-subcomponents
Refactor global search into modular components
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
13
src/components/global-search/EmptyState.tsx
Normal file
13
src/components/global-search/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/components/global-search/RecentSearches.tsx
Normal file
106
src/components/global-search/RecentSearches.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
src/components/global-search/SearchResults.tsx
Normal file
54
src/components/global-search/SearchResults.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
391
src/components/global-search/useGlobalSearchData.tsx
Normal file
391
src/components/global-search/useGlobalSearchData.tsx
Normal 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
182
src/data/global-search.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user