Phase 5: Enhanced search modal with filters and recent searches
- Added search filters (All, Workflow, Setup, Account, Help) - Implemented recent searches (stored in localStorage, max 5) - Enhanced search results with category display - Improved result filtering by type and category - Updated search items with proper categorization - Keyboard shortcut Cmd/Ctrl+K already working ✓
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Search Modal - Global search modal triggered by icon or Cmd+K
|
* Search Modal - Global search modal triggered by icon or Cmd+K
|
||||||
|
* Enhanced with filters and recent searches
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -14,45 +15,85 @@ interface SearchModalProps {
|
|||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'page' | 'action';
|
type: 'workflow' | 'setup' | 'account' | 'help';
|
||||||
|
category: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'workflow' | 'setup' | 'account' | 'help';
|
||||||
|
|
||||||
|
const RECENT_SEARCHES_KEY = 'igny8_recent_searches';
|
||||||
|
const MAX_RECENT_SEARCHES = 5;
|
||||||
|
|
||||||
const SEARCH_ITEMS: SearchResult[] = [
|
const SEARCH_ITEMS: SearchResult[] = [
|
||||||
// Workflow
|
// Workflow
|
||||||
{ title: 'Keywords', path: '/planner/keywords', type: 'page' },
|
{ title: 'Keywords', path: '/planner/keywords', type: 'workflow', category: 'Planner' },
|
||||||
{ title: 'Clusters', path: '/planner/clusters', type: 'page' },
|
{ title: 'Clusters', path: '/planner/clusters', type: 'workflow', category: 'Planner' },
|
||||||
{ title: 'Ideas', path: '/planner/ideas', type: 'page' },
|
{ title: 'Ideas', path: '/planner/ideas', type: 'workflow', category: 'Planner' },
|
||||||
{ title: 'Queue', path: '/writer/tasks', type: 'page' },
|
{ title: 'Queue', path: '/writer/tasks', type: 'workflow', category: 'Writer' },
|
||||||
{ title: 'Drafts', path: '/writer/content', type: 'page' },
|
{ title: 'Drafts', path: '/writer/content', type: 'workflow', category: 'Writer' },
|
||||||
{ title: 'Images', path: '/writer/images', type: 'page' },
|
{ title: 'Images', path: '/writer/images', type: 'workflow', category: 'Writer' },
|
||||||
{ title: 'Review', path: '/writer/review', type: 'page' },
|
{ title: 'Review', path: '/writer/review', type: 'workflow', category: 'Writer' },
|
||||||
{ title: 'Approved', path: '/writer/approved', type: 'page' },
|
{ title: 'Approved', path: '/writer/approved', type: 'workflow', category: 'Writer' },
|
||||||
|
{ title: 'Automation', path: '/automation', type: 'workflow', category: 'Automation' },
|
||||||
|
{ title: 'Content Calendar', path: '/publisher/content-calendar', type: 'workflow', category: 'Publisher' },
|
||||||
// Setup
|
// Setup
|
||||||
{ title: 'Sites', path: '/sites', type: 'page' },
|
{ title: 'Sites', path: '/sites', type: 'setup', category: 'Sites' },
|
||||||
{ title: 'Add Keywords', path: '/add-keywords', type: 'page' },
|
{ title: 'Add Keywords', path: '/setup/add-keywords', type: 'setup', category: 'Setup' },
|
||||||
{ title: 'Content Settings', path: '/account/content-settings', type: 'page' },
|
{ title: 'Content Settings', path: '/account/content-settings', type: 'setup', category: 'Settings' },
|
||||||
{ title: 'Prompts', path: '/thinker/prompts', type: 'page' },
|
{ title: 'Prompts', path: '/thinker/prompts', type: 'setup', category: 'AI' },
|
||||||
{ title: 'Author Profiles', path: '/thinker/author-profiles', type: 'page' },
|
{ title: 'Author Profiles', path: '/thinker/author-profiles', type: 'setup', category: 'AI' },
|
||||||
// Account
|
// Account
|
||||||
{ title: 'Account Settings', path: '/account/settings', type: 'page' },
|
{ title: 'Account Settings', path: '/account/settings', type: 'account', category: 'Account' },
|
||||||
{ title: 'Plans & Billing', path: '/account/plans', type: 'page' },
|
{ title: 'Plans & Billing', path: '/account/plans', type: 'account', category: 'Account' },
|
||||||
{ title: 'Usage Analytics', path: '/account/usage', type: 'page' },
|
{ title: 'Usage Analytics', path: '/account/usage', type: 'account', category: 'Account' },
|
||||||
|
{ title: 'Team Management', path: '/account/settings/team', type: 'account', category: 'Account' },
|
||||||
|
{ title: 'Notifications', path: '/account/notifications', type: 'account', category: 'Account' },
|
||||||
// Help
|
// Help
|
||||||
{ title: 'Help & Support', path: '/help', type: 'page' },
|
{ title: 'Help & Support', path: '/help', type: 'help', category: 'Help' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||||
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Load recent searches from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
setRecentSearches(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
setRecentSearches([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save recent search
|
||||||
|
const addRecentSearch = (path: string) => {
|
||||||
|
const updated = [path, ...recentSearches.filter(p => p !== path)].slice(0, MAX_RECENT_SEARCHES);
|
||||||
|
setRecentSearches(updated);
|
||||||
|
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecentSearchResults = (): SearchResult[] => {
|
||||||
|
return recentSearches
|
||||||
|
.map(path => SEARCH_ITEMS.find(item => item.path === path))
|
||||||
|
.filter((item): item is SearchResult => item !== undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredResults = query.length > 0
|
const filteredResults = query.length > 0
|
||||||
? SEARCH_ITEMS.filter(item =>
|
? SEARCH_ITEMS.filter(item => {
|
||||||
item.title.toLowerCase().includes(query.toLowerCase())
|
const matchesQuery = item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
)
|
item.category.toLowerCase().includes(query.toLowerCase());
|
||||||
: SEARCH_ITEMS.slice(0, 8);
|
const matchesFilter = activeFilter === 'all' || item.type === activeFilter;
|
||||||
|
return matchesQuery && matchesFilter;
|
||||||
|
})
|
||||||
|
: (activeFilter === 'all' ? getRecentSearchResults() : SEARCH_ITEMS.filter(item => item.type === activeFilter));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -80,13 +121,23 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (result: SearchResult) => {
|
const handleSelect = (result: SearchResult) => {
|
||||||
|
addRecentSearch(result.path);
|
||||||
navigate(result.path);
|
navigate(result.path);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterOptions: { value: FilterType; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'workflow', label: 'Workflow' },
|
||||||
|
{ value: 'setup', label: 'Setup' },
|
||||||
|
{ value: 'account', label: 'Account' },
|
||||||
|
{ value: 'help', label: 'Help' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} className="sm:max-w-lg">
|
<Modal isOpen={isOpen} onClose={onClose} className="sm:max-w-2xl">
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
|
{/* Search Input */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 z-10">
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 z-10">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -108,10 +159,39 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||||
|
{filterOptions.map((filter) => (
|
||||||
|
<Button
|
||||||
|
key={filter.value}
|
||||||
|
variant={activeFilter === filter.value ? 'solid' : 'outline'}
|
||||||
|
tone={activeFilter === filter.value ? 'brand' : 'neutral'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilter(filter.value);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Searches Header (only when showing recent) */}
|
||||||
|
{query.length === 0 && activeFilter === 'all' && recentSearches.length > 0 && (
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
Recent Searches
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
<div className="max-h-80 overflow-y-auto py-2">
|
<div className="max-h-80 overflow-y-auto py-2">
|
||||||
{filteredResults.length === 0 ? (
|
{filteredResults.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-gray-500">
|
<div className="px-4 py-8 text-center text-gray-500">
|
||||||
No results found for "{query}"
|
{query.length > 0
|
||||||
|
? `No results found for "${query}"`
|
||||||
|
: 'No recent searches'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredResults.map((result, index) => (
|
filteredResults.map((result, index) => (
|
||||||
@@ -126,10 +206,13 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{result.title}</span>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{result.title}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{result.category}</div>
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user