diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 8df2fcf..dce97a7 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -20,6 +20,7 @@ "logout": "Logout", "version": "Version", "navigation": { + "search": "Search", "allTasks": "All Tasks", "archived": "Archived", "things": "Things", diff --git a/src/App.jsx b/src/App.jsx index ec3684a..6f01c10 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,7 @@ import useOnboardingGate from './hooks/useOnboardingGate' import useStatusBar from './hooks/useStatusBar' import { useSyncOnReconnect } from './hooks/useSyncOnReconnect' import { useResource } from './queries/ResourceQueries' +import { GlobalSearchProvider } from './search/GlobalSearchContext' import { recordRoute } from './service/DiagnosticsSession' import { useNotification } from './service/NotificationProvider' import NetworkBanner from './views/components/NetworkBanner' @@ -145,7 +146,9 @@ function App() { - + + + diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index bb5c8dd..190e31e 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -13,6 +13,7 @@ import SettingsOverview from '@/views/Settings/SettingsOverview' import SettingsRoutes from '@/views/Settings/SettingsRoutes' import ThemeSettings from '@/views/Settings/ThemeSettings' +import GlobalSearchPage from '../search/GlobalSearchPage' import AuthenticationLoading from '../views/Authorization/Authenticating' import ForgotPasswordView from '../views/Authorization/ForgotPasswordView' import LoginSettings from '../views/Authorization/LoginSettings' @@ -131,6 +132,10 @@ const Router = createBrowserRouter([ path: '/chores', element: , }, + { + path: '/search', + element: , + }, { path: '/archived', element: , diff --git a/src/search/GlobalSearchContext.jsx b/src/search/GlobalSearchContext.jsx new file mode 100644 index 0000000..3ebae49 --- /dev/null +++ b/src/search/GlobalSearchContext.jsx @@ -0,0 +1,192 @@ +import useMediaQuery from '@mui/material/useMediaQuery' +import { useQueryClient } from '@tanstack/react-query' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { offlineDB } from '../utils/OfflineDB' +import { isParentUser } from '../utils/UserHelpers' +import GlobalSearchPalette from './GlobalSearchPalette' +import { getSearchProviders } from './searchProviders' + +const GlobalSearchContext = createContext(null) +const BLOCKED_ROUTES = [ + '/login', + '/signup', + '/welcome', + '/onboarding', + '/get-started', + '/ready', +] + +const unwrap = value => (Array.isArray(value) ? value : value?.res || []) +const uniqueBy = (items, getId) => [ + ...new Map( + items.filter(Boolean).map(item => [String(getId(item)), item]), + ).values(), +] + +export const GlobalSearchProvider = ({ children }) => { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + const isMobile = useMediaQuery('(max-width:768px)') + const [isOpen, setIsOpen] = useState(false) + const [initialQuery, setInitialQuery] = useState('') + const [documents, setDocuments] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const loadDocuments = useCallback(async () => { + setIsLoading(true) + try { + const cachedChores = queryClient + .getQueriesData({ queryKey: ['chores'] }) + .flatMap(([, data]) => unwrap(data)) + const cachedHistory = [ + ...queryClient.getQueriesData({ queryKey: ['choresHistory'] }), + ...queryClient.getQueriesData({ queryKey: ['choreHistory'] }), + ].flatMap(([, data]) => unwrap(data)) + + const [ + offlineChores, + offlineHistory, + offlineProjects, + offlineLabels, + offlineMembers, + offlineProfile, + ] = await Promise.all([ + offlineDB.getChores(true).catch(() => []), + offlineDB.getHistoryByDays(365).catch(() => []), + offlineDB.getKV('projects').catch(() => []), + offlineDB.getKV('labels').catch(() => []), + offlineDB.getKV('circle_members').catch(() => []), + offlineDB.getKV('user_profile').catch(() => null), + ]) + + const projects = uniqueBy( + [ + ...unwrap(queryClient.getQueryData(['projects'])), + ...unwrap(offlineProjects), + ], + item => item.id, + ) + const labels = uniqueBy( + [ + ...unwrap(queryClient.getQueryData(['labels'])), + ...unwrap(offlineLabels), + ], + item => item.id, + ) + const members = uniqueBy( + [ + ...unwrap(queryClient.getQueryData(['allCircleMembers'])), + ...unwrap(offlineMembers), + ], + item => item.userId, + ) + const chores = uniqueBy( + [...cachedChores, ...unwrap(offlineChores)], + item => item.id, + ) + const history = uniqueBy( + [...cachedHistory, ...unwrap(offlineHistory)], + item => item.id, + ) + const profile = + queryClient + .getQueriesData({ queryKey: ['userProfile'] }) + .find(([, data]) => data)?.[1] || offlineProfile + + const sources = { + chores, + history, + projects, + labels, + members, + isParent: isParentUser(profile), + choresById: new Map(chores.map(item => [String(item.id), item])), + projectsById: new Map(projects.map(item => [String(item.id), item])), + membersById: new Map(members.map(item => [String(item.userId), item])), + } + + const nextDocuments = getSearchProviders().flatMap(provider => { + try { + return provider.getDocuments(sources) || [] + } catch (error) { + console.warn(`Search provider ${provider.id} failed`, error) + return [] + } + }) + setDocuments(nextDocuments) + } finally { + setIsLoading(false) + } + }, [queryClient]) + + const openSearch = useCallback( + (query = '') => { + if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route))) + return + if (isMobile) { + loadDocuments() + navigate('/search', { state: { initialQuery: query } }) + return + } + setInitialQuery(query) + setIsOpen(true) + loadDocuments() + }, + [isMobile, loadDocuments, location.pathname, navigate], + ) + + const closeSearch = useCallback(() => setIsOpen(false), []) + + useEffect(() => { + const onKeyDown = event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { + event.preventDefault() + isOpen ? closeSearch() : openSearch() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [closeSearch, isOpen, openSearch]) + + const value = useMemo( + () => ({ + closeSearch, + documents, + isLoading, + loadDocuments, + openSearch, + }), + [closeSearch, documents, isLoading, loadDocuments, openSearch], + ) + + return ( + + {children} + {isOpen && ( + + )} + + ) +} + +export const useGlobalSearch = () => { + const context = useContext(GlobalSearchContext) + if (!context) + throw new Error('useGlobalSearch must be used inside GlobalSearchProvider') + return context +} diff --git a/src/search/GlobalSearchPage.jsx b/src/search/GlobalSearchPage.jsx new file mode 100644 index 0000000..5c1b48b --- /dev/null +++ b/src/search/GlobalSearchPage.jsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { useGlobalSearch } from './GlobalSearchContext' +import GlobalSearchPalette from './GlobalSearchPalette' + +const GlobalSearchPage = () => { + const location = useLocation() + const navigate = useNavigate() + const { documents, isLoading, loadDocuments } = useGlobalSearch() + + useEffect(() => { + loadDocuments() + }, [loadDocuments]) + + const handleClose = () => { + if (window.history.state?.idx > 0) navigate(-1) + else navigate('/chores', { replace: true }) + } + + return ( + + ) +} + +export default GlobalSearchPage diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx new file mode 100644 index 0000000..d903b0b --- /dev/null +++ b/src/search/GlobalSearchPalette.jsx @@ -0,0 +1,463 @@ +import { + AddRounded, + CheckCircleOutline, + FolderOutlined, + HistoryRounded, + InboxOutlined, + LabelOutlined, + PersonOutline, + SearchRounded, + SettingsOutlined, +} from '@mui/icons-material' +import { + Box, + Chip, + CircularProgress, + Divider, + Input, + List, + ListItemButton, + ListItemContent, + ListItemDecorator, + Typography, +} from '@mui/joy' +import Fuse from 'fuse.js' +import PropTypes from 'prop-types' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import AppModal from '../components/common/AppModal' + +const RECENTS_KEY = 'donetick.globalSearch.recents' +const GROUPS = [ + 'tasks', + 'history', + 'projects', + 'labels', + 'people', + 'settings', + 'actions', +] +const GROUP_LABELS = { + tasks: 'Tasks', + history: 'Notes & activity', + projects: 'Projects', + labels: 'Labels', + people: 'People', + settings: 'Settings', + actions: 'Quick actions', +} + +const ICONS = { + tasks: , + history: , + projects: , + labels: , + people: , + settings: , + actions: , +} + +const QUICK_ACTIONS = [ + { + id: 'action:create', + provider: 'actions', + title: 'Create a task', + subtitle: 'Quick action', + route: '/chores/create', + }, + { + id: 'action:tasks', + provider: 'actions', + title: 'View all tasks', + subtitle: 'Navigation', + route: '/chores', + }, + { + id: 'action:archived', + provider: 'actions', + title: 'View archived tasks', + subtitle: 'Navigation', + route: '/archived', + }, + { + id: 'action:settings', + provider: 'actions', + title: 'Open settings', + subtitle: 'Navigation', + route: '/settings', + }, +] + +const readRecents = () => { + try { + return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [] + } catch { + return [] + } +} + +const saveRecent = result => { + if (result.provider === 'actions') return + const recent = { + id: result.id, + provider: result.provider, + route: result.route, + title: result.title, + subtitle: result.subtitle, + } + localStorage.setItem( + RECENTS_KEY, + JSON.stringify( + [recent, ...readRecents().filter(item => item.id !== result.id)].slice( + 0, + 6, + ), + ), + ) +} + +const Highlight = ({ query, text }) => { + if (!text || !query.trim()) return text || null + const words = query.trim().split(/\s+/).filter(Boolean) + const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + if (!escaped.length) return text + const pattern = new RegExp(`(${escaped.join('|')})`, 'ig') + const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i') + return String(text) + .split(pattern) + .map((part, index) => + isMatch.test(part) ? ( + + {part} + + ) : ( + part + ), + ) +} + +const SearchContainer = ({ children, onClose, presentation }) => { + if (presentation === 'page') { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) +} + +const GlobalSearchPalette = ({ + documents, + initialQuery, + isLoading, + onClose, + presentation = 'modal', +}) => { + const navigate = useNavigate() + const focusInputRef = useCallback(node => { + if (node) requestAnimationFrame(() => node.focus()) + }, []) + const [query, setQuery] = useState(initialQuery || '') + const [selectedIndex, setSelectedIndex] = useState(0) + const [recents] = useState(readRecents) + const selectedResultRef = useRef(null) + + const fuse = useMemo( + () => + new Fuse(documents, { + threshold: 0.38, + distance: 120, + ignoreLocation: true, + includeScore: true, + keys: [ + { name: 'title', weight: 0.5 }, + { name: 'keywords', weight: 0.25 }, + { name: 'body', weight: 0.17 }, + { name: 'subtitle', weight: 0.08 }, + ], + }), + [documents], + ) + + const results = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase() + if (!normalized) { + const currentById = new Map(documents.map(item => [item.id, item])) + const recentResults = recents.map( + item => currentById.get(item.id) || item, + ) + return [...recentResults, ...QUICK_ACTIONS] + } + + const matches = fuse + .search(normalized, { limit: 60 }) + .map(match => { + const title = match.item.title?.toLocaleLowerCase() ?? '' + let score = match.score ?? 1 + + if (title === normalized) { + score -= 1 + } else if (title.startsWith(normalized)) { + score -= 0.15 + } else if (title.includes(normalized)) { + score -= 0.08 + } + + return { + ...match.item, + score, + } + }) + .sort((a, b) => a.score - b.score) + + const byGroup = new Map(GROUPS.map(group => [group, []])) + matches.forEach(item => { + const group = byGroup.get(item.provider) + if (group && group.length < 7) group.push(item) + }) + const grouped = GROUPS.flatMap(group => byGroup.get(group)) + grouped.push({ + id: 'action:filter-tasks', + provider: 'actions', + title: `Show tasks matching “${query.trim()}”`, + subtitle: 'Filter the task list', + route: `/chores?search=${encodeURIComponent(query.trim())}`, + }) + return grouped + }, [documents, fuse, query, recents]) + + useEffect(() => { + selectedResultRef.current?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }) + }, [selectedIndex, results]) + + const selectResult = result => { + saveRecent(result) + navigate(result.route) + if (presentation === 'modal') onClose() + } + + const onInputKeyDown = event => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex(index => Math.min(index + 1, results.length - 1)) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex(index => Math.max(index - 1, 0)) + } else if (event.key === 'Enter' && results[selectedIndex]) { + event.preventDefault() + selectResult(results[selectedIndex]) + } else if (event.key === 'Escape') { + event.preventDefault() + onClose() + } + } + + return ( + + + { + setQuery(event.target.value) + setSelectedIndex(0) + }} + onKeyDown={onInputKeyDown} + placeholder='Search Donetick' + startDecorator={} + endDecorator={ + isLoading ? ( + + ) : presentation === 'modal' ? ( + + Esc + + ) : null + } + sx={{ + '--Input-minHeight': '48px', + fontSize: 'md', + borderRadius: 'lg', + }} + /> + + Searching content available on this device + + + + + + {!isLoading && query.trim() && results.length === 1 && ( + + + No direct matches + + You can still filter the task list with this search. + + + )} + + + {results.map((result, index) => { + const hasQuery = Boolean(query.trim()) + const showHeading = hasQuery + ? index === 0 || result.provider !== results[index - 1].provider + : index === 0 || + (result.provider === 'actions' && + results[index - 1].provider !== 'actions') + return ( + + {showHeading && ( + + {!query.trim() && result.provider !== 'actions' + ? 'Recent' + : GROUP_LABELS[result.provider]} + + )} + setSelectedIndex(index)} + onClick={() => selectResult(result)} + sx={{ + borderRadius: 'md', + py: 1.1, + alignItems: 'flex-start', + }} + > + + {ICONS[result.provider]} + + + + + + + {[result.subtitle, result.body] + .filter(Boolean) + .join(' · ')} + + + + + ) + })} + + + + + ↑↓ Navigate + ↵ Open + + {query.trim() + ? `${Math.max(0, results.length - 1)} results` + : 'Type to search'} + + + + ) +} + +SearchContainer.propTypes = { + children: PropTypes.node.isRequired, + onClose: PropTypes.func.isRequired, + presentation: PropTypes.oneOf(['modal', 'page']).isRequired, +} + +Highlight.propTypes = { + query: PropTypes.string.isRequired, + text: PropTypes.string, +} + +GlobalSearchPalette.propTypes = { + documents: PropTypes.arrayOf(PropTypes.object).isRequired, + initialQuery: PropTypes.string, + isLoading: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + presentation: PropTypes.oneOf(['modal', 'page']), +} + +export default GlobalSearchPalette diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js new file mode 100644 index 0000000..0eb20f6 --- /dev/null +++ b/src/search/searchProviders.js @@ -0,0 +1,178 @@ +const stripHtml = value => { + if (!value) return '' + if (typeof globalThis.document === 'undefined') + return String(value).replace(/<[^>]*>/g, ' ') + const element = globalThis.document.createElement('div') + element.innerHTML = String(value) + return element.textContent || element.innerText || '' +} + +const HISTORY_STATUS = { + 0: 'in progress', + 1: 'completed', + 2: 'skipped', + 3: 'pending approval', + 4: 'rejected', + 5: 'missed', + 6: 'rescheduled', +} + +const SETTINGS = [ + ['profile', 'Profile', 'Name, avatar and personal details'], + ['circle', 'Circle', 'Members and household settings', true], + ['account', 'Account', 'Subscription and account management', true], + ['subaccounts', 'Subaccounts', 'Manage child accounts'], + ['notifications', 'Notifications', 'Reminders and notification preferences'], + ['mfa', 'Multi-factor authentication', 'Secure your account', true], + ['apitokens', 'API tokens', 'Manage integrations and access tokens', true], + ['storage', 'Storage', 'Files, backups and device storage'], + ['sidepanel', 'Side panel', 'Customize navigation'], + ['theme', 'Appearance', 'Theme, dark mode and colors'], + ['localization', 'Language and region', 'Language, dates and time formats'], + [ + 'advanced', + 'Advanced settings', + 'Offline support, webhooks and application behavior', + ], + ['developer', 'Developer settings', 'Diagnostics and experimental tools'], +] + +const providers = [] + +export const registerSearchProvider = provider => { + if (!provider?.id || typeof provider.getDocuments !== 'function') { + throw new Error('A search provider needs an id and getDocuments function') + } + const existing = providers.findIndex(item => item.id === provider.id) + if (existing >= 0) providers.splice(existing, 1, provider) + else providers.push(provider) + return () => { + const index = providers.indexOf(provider) + if (index >= 0) providers.splice(index, 1) + } +} + +export const getSearchProviders = () => [...providers] + +const document = (provider, item) => ({ provider, ...item }) + +registerSearchProvider({ + id: 'tasks', + getDocuments: ({ chores, membersById, projectsById }) => + chores.map(chore => { + const labels = + chore.labelsV2?.map(label => label.name).filter(Boolean) || [] + const project = projectsById.get(String(chore.projectId)) + const assignees = (chore.assignees || []) + .map(assignee => membersById.get(String(assignee.userId))?.displayName) + .filter(Boolean) + const description = stripHtml(chore.description) + return document('tasks', { + id: `task:${chore.id}`, + entityId: chore.id, + title: chore.name || 'Untitled task', + subtitle: + [project?.name, ...labels].filter(Boolean).join(' · ') || 'Task', + body: description, + keywords: [...labels, project?.name, ...assignees] + .filter(Boolean) + .join(' '), + route: `/chores/${chore.id}`, + updatedAt: chore.updatedAt || chore.createdAt, + }) + }), +}) + +registerSearchProvider({ + id: 'history', + getDocuments: ({ choresById, history, membersById }) => + history.map(entry => { + const chore = choresById.get(String(entry.choreId)) + const member = membersById.get(String(entry.completedBy)) + const note = stripHtml(entry.notes) + return document('history', { + id: `history:${entry.id}`, + entityId: entry.id, + title: chore?.name || entry.choreName || 'Task activity', + subtitle: [ + member?.displayName, + entry.performedAt + ? new Date(entry.performedAt).toLocaleDateString() + : null, + ] + .filter(Boolean) + .join(' · '), + body: note, + keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`, + route: entry.choreId + ? `/chores/${entry.choreId}/history` + : '/activities', + updatedAt: entry.performedAt || entry.updatedAt, + }) + }), +}) + +registerSearchProvider({ + id: 'projects', + getDocuments: ({ projects }) => + projects.map(project => + document('projects', { + id: `project:${project.id}`, + entityId: project.id, + title: project.name || 'Untitled project', + subtitle: 'Project', + body: stripHtml(project.description), + keywords: 'folder project', + route: `/chores?project=${encodeURIComponent(project.id)}`, + updatedAt: project.updatedAt, + }), + ), +}) + +registerSearchProvider({ + id: 'labels', + getDocuments: ({ labels }) => + labels.map(label => + document('labels', { + id: `label:${label.id}`, + entityId: label.id, + title: label.name || 'Untitled label', + subtitle: 'Label', + keywords: 'tag label', + route: '/labels', + color: label.color, + }), + ), +}) + +registerSearchProvider({ + id: 'people', + getDocuments: ({ members }) => + members.map(member => + document('people', { + id: `person:${member.userId}`, + entityId: member.userId, + title: member.displayName || member.username || 'Circle member', + subtitle: 'Circle member', + keywords: `${member.username || ''} person member assignee`, + route: '/chores', + }), + ), +}) + +registerSearchProvider({ + id: 'settings', + getDocuments: ({ isParent }) => + SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map( + ([id, title, description]) => + document('settings', { + id: `setting:${id}`, + entityId: id, + title, + subtitle: 'Settings', + body: description, + keywords: `preferences configuration ${id}`, + route: `/settings/${id}`, + }), + ), +}) diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bf6ec29..7d893e8 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -411,14 +411,29 @@ const MyChores = () => { } }, [searchInputFocus]) + // A global-search result can hand a query back to the task list as a scoped filter. + useEffect(() => { + const query = searchParams.get('search') + if (query !== null) { + setSearchTerm(query.toLowerCase()) + setSelectedCalendarDate(null) + clearActiveFilter() + clearQuickFilters() + } + // The setters above are intentionally applied only when URL search params change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) + // Read and apply project from URL parameters useEffect(() => { if (!projects.length) return const projectIdFromUrl = searchParams.get('project') - if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) { - const project = projectsWithDefault.find(p => p.id === projectIdFromUrl) + if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) { + const project = projectsWithDefault.find( + p => String(p.id) === projectIdFromUrl, + ) if (project) { setSelectedProjectWithCache(project) } @@ -759,6 +774,11 @@ const MyChores = () => { setFilteredChores(selectedProject ? projectFilteredChores : chores) setSearchInputFocus(0) setSelectedCalendarDate(null) + if (searchParams.has('search')) { + const params = new URLSearchParams(searchParams) + params.delete('search') + setSearchParams(params, { replace: true }) + } } const setSelectedChoreSectionWithCache = value => { diff --git a/src/views/Chores/components/SearchBar.jsx b/src/views/Chores/components/SearchBar.jsx index afced4d..0ccce0e 100644 --- a/src/views/Chores/components/SearchBar.jsx +++ b/src/views/Chores/components/SearchBar.jsx @@ -1,21 +1,33 @@ -import { CancelRounded } from '@mui/icons-material' +import { CancelRounded, SearchRounded } from '@mui/icons-material' import { Box, Input } from '@mui/joy' + import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' +import { useGlobalSearch } from '../../../search/GlobalSearchContext' const SearchBar = ({ - value, + inputRef, onChange, onClose, onFocus, showKeyboardShortcuts, - inputRef, + value, }) => { + const { openSearch } = useGlobalSearch() + const handleOpen = () => { + onFocus?.() + openSearch(value) + } + return ( { + event.preventDefault() + handleOpen() + }} fullWidth sx={{ mt: 1, @@ -24,17 +36,29 @@ const SearchBar = ({ height: 24, borderColor: 'text.disabled', padding: 1, + cursor: 'pointer', + '& input': { cursor: 'pointer' }, }} onChange={onChange} startDecorator={ - + + + + } endDecorator={ {value && ( <> - + event.stopPropagation()} + onClick={event => { + event.stopPropagation() + onClose() + }} + /> )} diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index c33e2c0..bb42ebc 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -9,6 +9,7 @@ import { ListAlt, Logout, MenuRounded, + SearchRounded, SettingsOutlined, Toll, Widgets, @@ -23,30 +24,36 @@ import { ListItemDecorator, Typography, } from '@mui/joy' - import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' + import { version } from '../../../package.json' import UserProfileAvatar from '../../components/UserProfileAvatar' +import Z_INDEX from '../../constants/zIndex' import { useLocalization } from '../../contexts/LocalizationContext' +import { useResource } from '../../queries/ResourceQueries' +import { useGlobalSearch } from '../../search/GlobalSearchContext' +import { apiClient } from '../../utils/ApiClient' import NavBarLink from './NavBarLink' import SyncStatusIndicator from './SyncStatusIndicator' -import Z_INDEX from '../../constants/zIndex' -import { useResource } from '../../queries/ResourceQueries' -import { apiClient } from '../../utils/ApiClient' - const publicPages = ['/landing', '/privacy', '/terms'] const NavBar = () => { const { t } = useTranslation('common') const { isRTL } = useLocalization() const { data: resource } = useResource() + const { openSearch } = useGlobalSearch() const navigate = useNavigate() const [drawerOpen, setDrawerOpen] = useState(false) const links = [ + { + label: t('navigation.search'), + icon: , + onClick: () => openSearch(), + }, { to: '/chores', label: t('navigation.allTasks'), @@ -105,6 +112,22 @@ const NavBar = () => { ) + if (location.pathname === '/search') { + return ( + { + if (window.history.state?.idx > 0) navigate(-1) + else navigate('/chores', { replace: true }) + }} + aria-label='Back from search' + title={t('back')} + > + + + ) + } if (!Capacitor.isNativePlatform()) { return menuRounded } diff --git a/src/views/components/NavBarLink.jsx b/src/views/components/NavBarLink.jsx index 3fe10d2..62dd64f 100644 --- a/src/views/components/NavBarLink.jsx +++ b/src/views/components/NavBarLink.jsx @@ -7,13 +7,12 @@ import { import { Link } from 'react-router-dom' const NavBarLink = ({ link }) => { - const { to, icon, label } = link + const { to, icon, label, onClick } = link return (