On mobile devices, clicking "Save as Smart View" button caused the search input to lose focus, triggering the onBlur handler that closes the entire search menu. The existing onMouseDown preventDefault() only worked for mouse events, not touch events on mobile. Added onTouchStart handler alongside onMouseDown to properly prevent input blur on mobile devices when interacting with the search menu. Fixes #994
717 lines
30 KiB
TypeScript
717 lines
30 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
InformationCircleIcon,
|
|
BookmarkIcon,
|
|
ChevronDownIcon,
|
|
ChevronUpIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import FilterBadge from './FilterBadge';
|
|
import SearchResults from './SearchResults';
|
|
import { useToast } from '../Shared/ToastContext';
|
|
import { getApiPath } from '../../config/paths';
|
|
import { getCsrfToken } from '../../utils/csrfService';
|
|
|
|
interface SearchMenuProps {
|
|
searchQuery: string;
|
|
selectedFilters: string[];
|
|
onFilterToggle: (filter: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const filterTypes = [
|
|
{
|
|
value: 'Task',
|
|
labelKey: 'search.entityTypes.task',
|
|
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
|
},
|
|
{
|
|
value: 'Project',
|
|
labelKey: 'search.entityTypes.project',
|
|
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
|
},
|
|
{
|
|
value: 'Area',
|
|
labelKey: 'search.entityTypes.area',
|
|
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
},
|
|
{
|
|
value: 'Note',
|
|
labelKey: 'search.entityTypes.note',
|
|
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
},
|
|
];
|
|
|
|
const priorityOptions = [
|
|
{ value: 'high', labelKey: 'priority.high' },
|
|
{ value: 'medium', labelKey: 'priority.medium' },
|
|
{ value: 'low', labelKey: 'priority.low' },
|
|
];
|
|
|
|
const dueOptions = [
|
|
{ value: 'today', labelKey: 'dateIndicators.today' },
|
|
{ value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' },
|
|
{ value: 'next_week', labelKey: 'dateIndicators.nextWeek' },
|
|
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
|
];
|
|
|
|
const deferOptions = [
|
|
{ value: 'today', labelKey: 'dateIndicators.today' },
|
|
{ value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' },
|
|
{ value: 'next_week', labelKey: 'dateIndicators.nextWeek' },
|
|
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
|
];
|
|
|
|
const extrasOptions = [
|
|
{ value: 'recurring', labelKey: 'search.extrasFilter.isRecurring' },
|
|
{ value: 'overdue', labelKey: 'search.extrasFilter.isOverdue' },
|
|
{ value: 'has_content', labelKey: 'search.extrasFilter.hasContent' },
|
|
{ value: 'deferred', labelKey: 'search.extrasFilter.isDeferred' },
|
|
{ value: 'has_tags', labelKey: 'search.extrasFilter.hasTags' },
|
|
{
|
|
value: 'assigned_to_project',
|
|
labelKey: 'search.extrasFilter.isAssignedToProject',
|
|
},
|
|
];
|
|
|
|
const SearchMenu: React.FC<SearchMenuProps> = ({
|
|
searchQuery,
|
|
selectedFilters,
|
|
onFilterToggle,
|
|
onClose,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const { showSuccessToast, showErrorToast } = useToast();
|
|
const [selectedPriority, setSelectedPriority] = useState<string | null>(
|
|
null
|
|
);
|
|
const [selectedDue, setSelectedDue] = useState<string | null>(null);
|
|
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
|
|
const [availableTags, setAvailableTags] = useState<
|
|
Array<{ id: number; name: string }>
|
|
>([]);
|
|
const [showSaveForm, setShowSaveForm] = useState(false);
|
|
const [viewName, setViewName] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState('');
|
|
const [showCriteria, setShowCriteria] = useState(false);
|
|
|
|
// Fetch available tags
|
|
useEffect(() => {
|
|
const fetchTags = async () => {
|
|
try {
|
|
const response = await fetch(getApiPath('tags'), {
|
|
credentials: 'include',
|
|
});
|
|
if (response.ok) {
|
|
const tags = await response.json();
|
|
setAvailableTags(tags);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching tags:', error);
|
|
}
|
|
};
|
|
fetchTags();
|
|
}, []);
|
|
|
|
const handlePriorityToggle = (priority: string) => {
|
|
setSelectedPriority(selectedPriority === priority ? null : priority);
|
|
};
|
|
|
|
const handleDueToggle = (due: string) => {
|
|
setSelectedDue(selectedDue === due ? null : due);
|
|
};
|
|
|
|
const handleDeferToggle = (defer: string) => {
|
|
setSelectedDefer(selectedDefer === defer ? null : defer);
|
|
};
|
|
|
|
const handleTagToggle = (tagName: string) => {
|
|
setSelectedTags((prev) =>
|
|
prev.includes(tagName)
|
|
? prev.filter((t) => t !== tagName)
|
|
: [...prev, tagName]
|
|
);
|
|
};
|
|
|
|
const handleExtrasToggle = (extra: string) => {
|
|
setSelectedExtras((prev) =>
|
|
prev.includes(extra)
|
|
? prev.filter((e) => e !== extra)
|
|
: [...prev, extra]
|
|
);
|
|
};
|
|
|
|
const handleSaveView = async () => {
|
|
if (!viewName.trim()) {
|
|
setSaveError(t('search.viewNameRequired'));
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setSaveError('');
|
|
|
|
try {
|
|
const response = await fetch(getApiPath('views'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-csrf-token': await getCsrfToken(),
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
name: viewName.trim(),
|
|
search_query: searchQuery || null,
|
|
filters: selectedFilters,
|
|
priority: selectedPriority || null,
|
|
due: selectedDue || null,
|
|
defer: selectedDefer || null,
|
|
tags: selectedTags.length > 0 ? selectedTags : null,
|
|
extras: selectedExtras.length > 0 ? selectedExtras : null,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to save view');
|
|
}
|
|
|
|
const savedView = await response.json();
|
|
|
|
// Reset form
|
|
setViewName('');
|
|
setShowSaveForm(false);
|
|
setSaveError('');
|
|
|
|
// Show success toast with link to the view
|
|
showSuccessToast(
|
|
<div className="flex items-center gap-2">
|
|
<span>View saved successfully!</span>
|
|
<Link
|
|
to={`/views/${savedView.uid}`}
|
|
className="underline font-semibold hover:text-green-100"
|
|
onClick={onClose}
|
|
>
|
|
View now
|
|
</Link>
|
|
</div>
|
|
);
|
|
|
|
// Notify sidebar to refresh
|
|
window.dispatchEvent(new CustomEvent('viewUpdated'));
|
|
} catch (err) {
|
|
setSaveError(t('search.failedToSave'));
|
|
showErrorToast('Failed to save view. Please try again.');
|
|
console.error('Error saving view:', err);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelSave = () => {
|
|
setShowSaveForm(false);
|
|
setViewName('');
|
|
setSaveError('');
|
|
};
|
|
|
|
const buildSearchDescription = () => {
|
|
const parts: React.ReactNode[] = [];
|
|
|
|
// Build entity types part
|
|
if (selectedFilters.length > 0) {
|
|
const entities = selectedFilters.map((f) => (
|
|
<span key={f} style={{ fontWeight: 800, fontStyle: 'normal' }}>
|
|
{f.toLowerCase()}s
|
|
</span>
|
|
));
|
|
const entitiesWithSeparators: React.ReactNode[] = [];
|
|
entities.forEach((entity, index) => {
|
|
if (index > 0) {
|
|
entitiesWithSeparators.push(
|
|
<span key={`sep-entity-${index}`}>
|
|
{' ' + t('search.and') + ' '}
|
|
</span>
|
|
);
|
|
}
|
|
entitiesWithSeparators.push(entity);
|
|
});
|
|
parts.push(...entitiesWithSeparators);
|
|
} else {
|
|
// If no specific entities selected, show "all items"
|
|
parts.push(
|
|
<span
|
|
key="all"
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{t('search.allItems')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Add search query
|
|
if (searchQuery.trim()) {
|
|
parts.push(
|
|
<span key="query-label">
|
|
{t('search.containingText') + ' '}
|
|
</span>
|
|
);
|
|
parts.push(
|
|
<span
|
|
key="query"
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
"{searchQuery.trim()}"
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Add priority filter
|
|
if (selectedPriority) {
|
|
parts.push(
|
|
<span key="priority-label">
|
|
{t('search.withPriority') + ' '}
|
|
</span>
|
|
);
|
|
parts.push(
|
|
<span
|
|
key="priority"
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{selectedPriority}
|
|
</span>
|
|
);
|
|
parts.push(
|
|
<span key="priority-suffix">{' ' + t('search.priority')}</span>
|
|
);
|
|
}
|
|
|
|
// Add due date filter
|
|
if (selectedDue) {
|
|
const dueOption = dueOptions.find(
|
|
(opt) => opt.value === selectedDue
|
|
);
|
|
const dueLabel = dueOption ? t(dueOption.labelKey) : selectedDue;
|
|
parts.push(<span key="due-label">{t('search.due') + ' '}</span>);
|
|
parts.push(
|
|
<span
|
|
key="due"
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{dueLabel}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Add defer until filter
|
|
if (selectedDefer) {
|
|
const deferOption = deferOptions.find(
|
|
(opt) => opt.value === selectedDefer
|
|
);
|
|
const deferLabel = deferOption
|
|
? t(deferOption.labelKey)
|
|
: selectedDefer;
|
|
parts.push(
|
|
<span key="defer-label">{t('search.deferUntil') + ' '}</span>
|
|
);
|
|
parts.push(
|
|
<span
|
|
key="defer"
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{deferLabel}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Add tags filter
|
|
if (selectedTags.length > 0) {
|
|
parts.push(
|
|
<span key="tags-label">{t('search.taggedWith') + ' '}</span>
|
|
);
|
|
const tagElements = selectedTags.map((tag) => (
|
|
<span
|
|
key={`tag-${tag}`}
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{tag}
|
|
</span>
|
|
));
|
|
const tagsWithSeparators: React.ReactNode[] = [];
|
|
tagElements.forEach((tagEl, index) => {
|
|
if (index > 0) {
|
|
if (index === tagElements.length - 1) {
|
|
tagsWithSeparators.push(
|
|
<span key={`sep-tag-and-${index}`}>
|
|
{' ' + t('search.and') + ' '}
|
|
</span>
|
|
);
|
|
} else {
|
|
tagsWithSeparators.push(
|
|
<span key={`sep-tag-comma-${index}`}>{', '}</span>
|
|
);
|
|
}
|
|
}
|
|
tagsWithSeparators.push(tagEl);
|
|
});
|
|
parts.push(...tagsWithSeparators);
|
|
}
|
|
|
|
// Add extras filters
|
|
if (selectedExtras.length > 0) {
|
|
parts.push(
|
|
<span key="extras-label">{t('search.thatAre') + ' '}</span>
|
|
);
|
|
const extrasElements = selectedExtras.map((extra) => {
|
|
const extraOption = extrasOptions.find(
|
|
(opt) => opt.value === extra
|
|
);
|
|
const extraLabel = extraOption
|
|
? t(extraOption.labelKey)
|
|
: extra;
|
|
return (
|
|
<span
|
|
key={`extra-${extra}`}
|
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
|
>
|
|
{extraLabel}
|
|
</span>
|
|
);
|
|
});
|
|
const extrasWithSeparators: React.ReactNode[] = [];
|
|
extrasElements.forEach((extraEl, index) => {
|
|
if (index > 0) {
|
|
if (index === extrasElements.length - 1) {
|
|
extrasWithSeparators.push(
|
|
<span key={`sep-extra-and-${index}`}>
|
|
{' ' + t('search.and') + ' '}
|
|
</span>
|
|
);
|
|
} else {
|
|
extrasWithSeparators.push(
|
|
<span key={`sep-extra-comma-${index}`}>{', '}</span>
|
|
);
|
|
}
|
|
}
|
|
extrasWithSeparators.push(extraEl);
|
|
});
|
|
parts.push(...extrasWithSeparators);
|
|
}
|
|
|
|
if (parts.length === 0) return null;
|
|
|
|
// Construct the sentence
|
|
return (
|
|
<>
|
|
{t('search.searchingFor')} {parts}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const searchDescription = buildSearchDescription();
|
|
const hasActiveFilters =
|
|
selectedFilters.length > 0 ||
|
|
searchQuery.trim() ||
|
|
selectedPriority ||
|
|
selectedDue ||
|
|
selectedDefer ||
|
|
selectedTags.length > 0 ||
|
|
selectedExtras.length > 0;
|
|
|
|
return (
|
|
<div
|
|
className="fixed left-1/2 transform -translate-x-1/2 top-32 md:top-20 w-[95vw] md:w-[90vw] max-w-full md:max-w-4xl h-[75vh] md:h-[80vh] max-h-[600px] md:max-h-[700px] bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 z-50 flex flex-col"
|
|
onMouseDown={(e) => {
|
|
// Prevent input blur on mobile when clicking inside the search menu
|
|
e.preventDefault();
|
|
}}
|
|
onTouchStart={(e) => {
|
|
// Prevent input blur on mobile when touching inside the search menu
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
{/* Filter Badges Section */}
|
|
<div className="border-b border-gray-200 dark:border-gray-700 overflow-y-auto max-h-[40vh] md:max-h-none">
|
|
{/* Search Description */}
|
|
{hasActiveFilters && searchDescription && (
|
|
<>
|
|
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
|
|
<InformationCircleIcon className="h-6 w-6 text-black/30 dark:text-white/30 flex-shrink-0" />
|
|
<p
|
|
className="text-xl text-black/40 dark:text-white/40 flex-1"
|
|
style={{
|
|
fontFamily: "'Lora', Georgia, serif",
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
{searchDescription}
|
|
</p>
|
|
</div>
|
|
<div className="border-t border-gray-300 dark:border-gray-600"></div>
|
|
</>
|
|
)}
|
|
|
|
{/* Toggle Criteria Button */}
|
|
<div className="px-4 py-3">
|
|
<button
|
|
onClick={() => setShowCriteria(!showCriteria)}
|
|
className="flex items-center justify-between w-full text-left text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
>
|
|
<span>{t('search.criteria')}</span>
|
|
{showCriteria ? (
|
|
<ChevronUpIcon className="h-5 w-5" />
|
|
) : (
|
|
<ChevronDownIcon className="h-5 w-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Collapsible Criteria Section */}
|
|
{showCriteria && (
|
|
<div className="px-4 pb-4">
|
|
{/* Entity Type Badges */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{filterTypes.map((filter) => {
|
|
const isSelected = selectedFilters.includes(
|
|
filter.value
|
|
);
|
|
return (
|
|
<FilterBadge
|
|
key={filter.value}
|
|
name={t(filter.labelKey)}
|
|
color={filter.color}
|
|
isSelected={isSelected}
|
|
onToggle={() =>
|
|
onFilterToggle(filter.value)
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="my-4 border-t border-gray-300 dark:border-gray-600"></div>
|
|
|
|
{/* Metadata Filters */}
|
|
<div className="space-y-3">
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
|
|
{t('search.metadataFilters')}
|
|
</div>
|
|
|
|
{/* Priority Filters */}
|
|
<div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
|
{t('search.priorityFilter')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{priorityOptions.map((option) => (
|
|
<FilterBadge
|
|
key={option.value}
|
|
name={t(option.labelKey)}
|
|
isSelected={
|
|
selectedPriority ===
|
|
option.value
|
|
}
|
|
onToggle={() =>
|
|
handlePriorityToggle(
|
|
option.value
|
|
)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Due Date Filters */}
|
|
<div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
|
{t('search.dueFilter')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{dueOptions.map((option) => (
|
|
<FilterBadge
|
|
key={option.value}
|
|
name={t(option.labelKey)}
|
|
isSelected={
|
|
selectedDue === option.value
|
|
}
|
|
onToggle={() =>
|
|
handleDueToggle(option.value)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Defer Until Filters */}
|
|
<div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
|
{t('search.deferUntilFilter')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{deferOptions.map((option) => (
|
|
<FilterBadge
|
|
key={option.value}
|
|
name={t(option.labelKey)}
|
|
isSelected={
|
|
selectedDefer === option.value
|
|
}
|
|
onToggle={() =>
|
|
handleDeferToggle(option.value)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tags Filters */}
|
|
{availableTags.length > 0 && (
|
|
<div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
|
{t('search.tagsFilter')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableTags.map((tag) => (
|
|
<FilterBadge
|
|
key={tag.id}
|
|
name={tag.name}
|
|
color="bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200"
|
|
isSelected={selectedTags.includes(
|
|
tag.name
|
|
)}
|
|
onToggle={() =>
|
|
handleTagToggle(tag.name)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="my-4 border-t border-gray-300 dark:border-gray-600"></div>
|
|
|
|
{/* Extras Section */}
|
|
<div className="space-y-3">
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
|
|
{t('search.extras')}
|
|
</div>
|
|
|
|
{/* Extras Filters */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{extrasOptions.map((option) => (
|
|
<FilterBadge
|
|
key={option.value}
|
|
name={t(option.labelKey)}
|
|
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
|
isSelected={selectedExtras.includes(
|
|
option.value
|
|
)}
|
|
onToggle={() =>
|
|
handleExtrasToggle(option.value)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save as Smart View Section */}
|
|
{hasActiveFilters && (
|
|
<div className="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
|
|
{!showSaveForm ? (
|
|
<button
|
|
onClick={() => setShowSaveForm(true)}
|
|
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
|
>
|
|
<BookmarkIcon className="h-4 w-4" />
|
|
<span>
|
|
{t('search.saveAsSmartView')}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label
|
|
htmlFor="viewName"
|
|
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
>
|
|
{t('search.viewName')}{' '}
|
|
<span className="text-red-500">
|
|
*
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="viewName"
|
|
value={viewName}
|
|
onChange={(e) => {
|
|
setViewName(e.target.value);
|
|
setSaveError('');
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleSaveView();
|
|
} else if (
|
|
e.key === 'Escape'
|
|
) {
|
|
handleCancelSave();
|
|
}
|
|
}}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder={t(
|
|
'search.viewNamePlaceholder'
|
|
)}
|
|
autoFocus
|
|
/>
|
|
{saveError && (
|
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
{saveError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={handleCancelSave}
|
|
className="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
|
>
|
|
{t('search.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveView}
|
|
disabled={isSaving}
|
|
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded-md transition-colors"
|
|
>
|
|
{isSaving
|
|
? t('search.saving')
|
|
: t('search.saveView')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search Results */}
|
|
<SearchResults
|
|
searchQuery={searchQuery}
|
|
selectedFilters={selectedFilters}
|
|
selectedPriority={selectedPriority}
|
|
selectedDue={selectedDue}
|
|
selectedDefer={selectedDefer}
|
|
selectedTags={selectedTags}
|
|
selectedExtras={selectedExtras}
|
|
onClose={onClose}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SearchMenu;
|