tududi/frontend/components/UniversalSearch/SearchMenu.tsx
Chris b0b613f7bd
Reorder elements (#687)
* Reorder elements

* Enhance global search
2025-12-09 10:51:51 +02:00

711 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';
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',
},
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' }}
>
&quot;{searchQuery.trim()}&quot;
</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();
}}
>
{/* 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;