diff --git a/src/components/Search/CommandResults.tsx b/src/components/Search/CommandResults.tsx
new file mode 100644
index 00000000..bbdd6788
--- /dev/null
+++ b/src/components/Search/CommandResults.tsx
@@ -0,0 +1,64 @@
+import { Box, Stack, Typography } from '@mui/material'
+import { Description } from '@mui/icons-material'
+import { COMMANDS } from '@/utils/searchModal'
+
+type CommandKey = (typeof COMMANDS)[number]['key']
+
+type CommandResultsProps = {
+ commands: readonly {
+ key: CommandKey
+ label: string
+ description: string
+ }[]
+ onSelect: (command: CommandKey) => void
+}
+
+export const CommandResults = ({
+ commands,
+ onSelect,
+}: CommandResultsProps) => (
+
+
+
+ Commands
+
+
+
+ {commands.map((command) => (
+ onSelect(command.key)}
+ sx={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 1.5,
+ px: 1.5,
+ py: 1,
+ borderRadius: 1,
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ >
+
+
+
+ {command.label}
+
+
+ {command.description}
+
+
+
+ ))}
+
+)
diff --git a/src/components/Search/DefaultResults.tsx b/src/components/Search/DefaultResults.tsx
new file mode 100644
index 00000000..a225abe3
--- /dev/null
+++ b/src/components/Search/DefaultResults.tsx
@@ -0,0 +1,168 @@
+import { Box, Chip, Divider, Typography } from '@mui/material'
+import { Description, Opacity, Person } from '@mui/icons-material'
+import { GroupType } from '@/constants'
+import { ContactResult, SearchResult, WellResult } from '@/interfaces/ocotillo'
+import { highlight } from '@/utils'
+
+const TypeIcon = ({ group }: { group: GroupType }) => {
+ const sx = { fontSize: 18, color: 'text.secondary', flexShrink: 0, mt: '2px' }
+
+ switch (group) {
+ case GroupType.Wells:
+ case GroupType.Springs:
+ return
+ case GroupType.Contacts:
+ return
+ case GroupType.Assets:
+ return
+ default:
+ return null
+ }
+}
+
+const buildSubtitle = (option: SearchResult): string | null => {
+ if (option.group === GroupType.Wells || option.group === GroupType.Springs) {
+ const properties = (option as WellResult).properties
+ const parts: string[] = []
+
+ if (properties.owner_name) parts.push(`Owner: ${properties.owner_name}`)
+ if (properties.county) parts.push(properties.county)
+ if (properties.site_name) parts.push(properties.site_name)
+ if (properties.thing_type) parts.push(properties.thing_type)
+ if (properties.well_depth)
+ parts.push(`${properties.well_depth.toFixed(0)} ft`)
+ if (properties.hole_depth) {
+ parts.push(`hole ${properties.hole_depth.toFixed(0)} ft`)
+ }
+ if (properties.well_purposes?.length)
+ parts.push(...properties.well_purposes)
+
+ return parts.length ? parts.join(' · ') : null
+ }
+
+ if (option.group === GroupType.Contacts) {
+ const properties = (option as ContactResult).properties
+ const parts: string[] = []
+
+ if (properties.phone?.length) parts.push(properties.phone[0])
+ if (properties.address?.length) parts.push(properties.address[0])
+
+ return parts.length ? parts.join(' · ') : null
+ }
+
+ return null
+}
+
+const RelatedThings = ({
+ things,
+ query,
+}: {
+ things: { id: number; label: string; thing_type: string }[]
+ query: string
+}) => {
+ if (!things?.length) return null
+
+ return (
+
+ {things.slice(0, 4).map((thing) => (
+ }
+ label={highlight(thing.label, query)}
+ variant="outlined"
+ sx={{ fontSize: 11 }}
+ />
+ ))}
+ {things.length > 4 && (
+
+ )}
+
+ )
+}
+
+const ResultRow = ({
+ option,
+ query,
+ onClick,
+}: {
+ option: SearchResult
+ query: string
+ onClick: () => void
+}) => {
+ const subtitle = buildSubtitle(option)
+ const relatedThings =
+ option.group === GroupType.Contacts
+ ? (option as ContactResult).properties.things
+ : option.group === GroupType.Assets
+ ? (option as any).properties?.things
+ : null
+
+ return (
+
+
+
+
+ {highlight(option.label, query)}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ {relatedThings && (
+
+ )}
+
+
+ )
+}
+
+type DefaultResultsProps = {
+ grouped: Map
+ query: string
+ onSelect: (result: SearchResult) => void
+}
+
+export const DefaultResults = ({
+ grouped,
+ query,
+ onSelect,
+}: DefaultResultsProps) => (
+
+ {Array.from(grouped.entries()).map(([group, items], groupIndex) => (
+
+ {groupIndex > 0 && }
+ {items.map((option, index) => (
+ onSelect(option)}
+ />
+ ))}
+
+ ))}
+
+)
diff --git a/src/components/Search/DocsResults.tsx b/src/components/Search/DocsResults.tsx
new file mode 100644
index 00000000..670d8f30
--- /dev/null
+++ b/src/components/Search/DocsResults.tsx
@@ -0,0 +1,48 @@
+import { Box, Typography } from '@mui/material'
+import { Description } from '@mui/icons-material'
+import { DocEntry } from '@/utils/docsSearch'
+import { buildDocExcerpt } from '@/utils/searchModal'
+import { highlight } from '@/utils'
+
+type DocsResultsProps = {
+ docs: DocEntry[]
+ query: string
+ onSelect: (doc: DocEntry) => void
+}
+
+export const DocsResults = ({ docs, query, onSelect }: DocsResultsProps) => (
+
+ {docs.map((doc) => (
+ onSelect(doc)}
+ sx={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 1.5,
+ px: 1.5,
+ py: 1,
+ borderRadius: 1,
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ >
+
+
+
+ {highlight(doc.title, query)}
+
+
+ {highlight(buildDocExcerpt(doc, query), query)}
+
+
+
+ ))}
+
+)
diff --git a/src/components/Search/EmptyState.tsx b/src/components/Search/EmptyState.tsx
new file mode 100644
index 00000000..54624bbc
--- /dev/null
+++ b/src/components/Search/EmptyState.tsx
@@ -0,0 +1,19 @@
+import { Typography } from '@mui/material'
+
+type EmptyStateProps = {
+ color?: 'error' | 'text.secondary'
+ message: string
+}
+
+export const EmptyState = ({
+ color = 'text.secondary',
+ message,
+}: EmptyStateProps) => (
+
+ {message}
+
+)
diff --git a/src/components/Search/GameResults.tsx b/src/components/Search/GameResults.tsx
new file mode 100644
index 00000000..70ff726b
--- /dev/null
+++ b/src/components/Search/GameResults.tsx
@@ -0,0 +1,62 @@
+import { Box, Stack, Typography } from '@mui/material'
+import { ArcadeGame, GAMES } from '@/utils/searchModal'
+
+type GameResultsProps = {
+ games: typeof GAMES
+ term: string
+ onSelect: (game: ArcadeGame) => void
+}
+
+export const GameResults = ({ games, term, onSelect }: GameResultsProps) => (
+
+
+
+ Games
+
+
+
+ {games.map((game) => (
+ onSelect(game.key)}
+ sx={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 1.5,
+ px: 1.5,
+ py: 1,
+ borderRadius: 1,
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ >
+
+
+ {game.label}
+
+
+ {game.description}
+
+
+
+ ))}
+
+ {games.length === 0 && (
+
+ No games found for "{term}".
+
+ )}
+
+)
diff --git a/src/components/Search/RecentSearches.tsx b/src/components/Search/RecentSearches.tsx
new file mode 100644
index 00000000..fcc59a57
--- /dev/null
+++ b/src/components/Search/RecentSearches.tsx
@@ -0,0 +1,61 @@
+import { Box, Stack, Typography } from '@mui/material'
+import { AccessTime } from '@mui/icons-material'
+
+type RecentSearchesProps = {
+ searches: string[]
+ onClear: () => void
+ onSelect: (query: string) => void
+}
+
+export const RecentSearches = ({
+ searches,
+ onClear,
+ onSelect,
+}: RecentSearchesProps) => (
+
+
+
+ Recent searches
+
+
+ Clear history
+
+
+ {searches.map((query) => (
+ onSelect(query)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1.5,
+ px: 1.5,
+ py: 0.75,
+ cursor: 'pointer',
+ borderRadius: 1,
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ >
+
+
+ {query}
+
+
+ ))}
+
+)
diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts
new file mode 100644
index 00000000..438386d0
--- /dev/null
+++ b/src/components/Search/index.ts
@@ -0,0 +1,6 @@
+export * from './CommandResults'
+export * from './DefaultResults'
+export * from './DocsResults'
+export * from './EmptyState'
+export * from './GameResults'
+export * from './RecentSearches'
diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx
index 0d611fe0..df722ec4 100644
--- a/src/components/SearchModal.tsx
+++ b/src/components/SearchModal.tsx
@@ -1,28 +1,21 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
import {
- Box,
- Chip,
Dialog,
- Divider,
- IconButton,
- InputAdornment,
+ Box,
InputBase,
- Stack,
+ InputAdornment,
+ IconButton,
Typography,
} from '@mui/material'
+import { Clear, Search } from '@mui/icons-material'
+import { useSearchModalState } from '@/hooks'
import {
- AccessTime,
- Clear,
- Description,
- Opacity,
- Person,
- Search,
-} from '@mui/icons-material'
-import { useGo } from '@refinedev/core'
-import { useDebounce, useAbortableList, useSearchHistory } from '@/hooks'
-import { GroupType } from '@/constants'
-import { SearchResult, WellResult, ContactResult } from '@/interfaces/ocotillo'
-import { highlight } from '@/utils'
+ CommandResults,
+ DefaultResults,
+ DocsResults,
+ EmptyState,
+ GameResults,
+ RecentSearches,
+} from '@/components/Search'
import {
SnakeGameModal,
AsteroidsGameModal,
@@ -30,134 +23,7 @@ import {
TetrisGameModal,
MinesweeperGameModal,
} from '@/components/Search/EasterEggsGames'
-
-type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper'
-
-// ---- type icon mapping ------------------------------------------------
-
-const TypeIcon = ({ group }: { group: GroupType }) => {
- const sx = { fontSize: 18, color: 'text.secondary', flexShrink: 0, mt: '2px' }
- switch (group) {
- case GroupType.Wells:
- case GroupType.Springs:
- return
- case GroupType.Contacts:
- return
- case GroupType.Assets:
- return
- default:
- return null
- }
-}
-
-// ---- subtitle builder for each result type ----------------------------
-
-const buildSubtitle = (option: SearchResult): string | null => {
- if (option.group === GroupType.Wells || option.group === GroupType.Springs) {
- const p = (option as WellResult).properties
- const parts: string[] = []
- if (p.owner_name) parts.push(`Owner: ${p.owner_name}`)
- if (p.county) parts.push(p.county)
- if (p.site_name) parts.push(p.site_name)
- if (p.thing_type) parts.push(p.thing_type)
- if (p.well_depth) parts.push(`${p.well_depth.toFixed(0)} ft`)
- if (p.hole_depth) parts.push(`hole ${p.hole_depth.toFixed(0)} ft`)
- if (p.well_purposes?.length) parts.push(...p.well_purposes)
- return parts.length ? parts.join(' · ') : null
- }
-
- if (option.group === GroupType.Contacts) {
- const p = (option as ContactResult).properties
- const parts: string[] = []
- if (p.phone?.length) parts.push(p.phone[0])
- if (p.address?.length) parts.push(p.address[0])
- return parts.length ? parts.join(' · ') : null
- }
-
- return null
-}
-
-// ---- related things chips (contacts & assets) -------------------------
-
-const RelatedThings = ({
- things,
- query,
-}: {
- things: { id: number; label: string; thing_type: string }[]
- query: string
-}) => {
- if (!things?.length) return null
- return (
-
- {things.slice(0, 4).map((t) => (
- }
- label={highlight(t.label, query)}
- variant="outlined"
- sx={{ fontSize: 11 }}
- />
- ))}
- {things.length > 4 && (
-
- )}
-
- )
-}
-
-// ---- single result row ------------------------------------------------
-
-const ResultRow = ({
- option,
- query,
- onClick,
-}: {
- option: SearchResult
- query: string
- onClick: () => void
-}) => {
- const subtitle = buildSubtitle(option)
- const relatedThings =
- option.group === GroupType.Contacts
- ? (option as ContactResult).properties.things
- : option.group === GroupType.Assets
- ? (option as any).properties?.things
- : null
-
- return (
-
-
-
-
- {highlight(option.label, query)}
-
- {subtitle && (
-
- {subtitle}
-
- )}
- {relatedThings && (
-
- )}
-
-
- )
-}
-
-// ---- main modal -------------------------------------------------------
+import { GAMES } from '@/utils'
type SearchModalProps = {
open: boolean
@@ -165,182 +31,29 @@ type SearchModalProps = {
}
export const SearchModal = ({ open, onClose }: SearchModalProps) => {
- const go = useGo()
- const history = useSearchHistory()
- const inputRef = useRef(null)
-
- const [query, setQuery] = useState('')
- const [recentSearches, setRecentSearches] = useState([])
- const [activeGame, setActiveGame] = useState(null)
- const [dismissedGame, setDismissedGame] = useState(null)
- const debounced = useDebounce(query, 400)
- const normalizedQuery = query.trim().toLowerCase()
- const normalizedDebounced = debounced.trim().toLowerCase()
- const requestedGame =
- normalizedQuery === 'snake'
- ? 'snake'
- : normalizedQuery === 'asteroids'
- ? 'asteroids'
- : normalizedQuery === 'racecar'
- ? 'racecar'
- : normalizedQuery === 'tetris'
- ? 'tetris'
- : normalizedQuery === 'minesweeper'
- ? 'minesweeper'
- : null
-
- // Reload history each time modal opens
- useEffect(() => {
- if (open) {
- setQuery('')
- setRecentSearches(history.get())
- setDismissedGame(null)
- setActiveGame(null)
- }
- }, [open])
-
- useEffect(() => {
- if (open) {
- inputRef.current?.focus()
- }
- }, [open])
-
- useEffect(() => {
- if (!requestedGame) {
- setDismissedGame(null)
- return
- }
-
- if (open && activeGame !== requestedGame && dismissedGame !== requestedGame) {
- setActiveGame(requestedGame)
- }
- }, [activeGame, dismissedGame, open, requestedGame])
-
- const { query: searchQuery, result } = useAbortableList({
- resource: 'search',
- dataProviderName: 'ocotillo',
- pagination: { pageSize: 100 },
- queryOptions: {
- enabled:
- open &&
- normalizedDebounced.length >= 1 &&
- normalizedDebounced !== 'snake' &&
- normalizedDebounced !== 'asteroids' &&
- normalizedDebounced !== 'racecar' &&
- normalizedDebounced !== 'tetris' &&
- normalizedDebounced !== 'minesweeper',
- staleTime: 120_000,
- refetchOnReconnect: false,
- refetchOnWindowFocus: false,
- },
- meta: { params: { q: debounced } },
- })
-
- const results: SearchResult[] = useMemo(() => {
- if (!query.trim()) return []
- if (searchQuery.isFetching) return []
- if (searchQuery.isError) return []
- const normalized =
- result?.data?.map((r: any) => ({
- label: r.label,
- description: r.description,
- group: r.group as GroupType,
- properties: r.properties,
- })) ?? []
- return dedupeResults(normalized)
- }, [result, query, searchQuery.isFetching, searchQuery.isError])
-
- // Group results by type for section headers
- const grouped = useMemo(() => {
- const map = new Map()
- for (const r of results) {
- if (!map.has(r.group)) map.set(r.group, [])
- map.get(r.group)!.push(r)
- }
- return map
- }, [results])
-
- const navigateToResult = (option: SearchResult) => {
- switch (option.group) {
- case GroupType.Wells:
- case GroupType.Springs: {
- const p = (option as WellResult).properties
- const isWaterWell = p.thing_type === 'water well'
- go({
- to: {
- resource: isWaterWell ? 'ocotillo.thing-well' : 'ocotillo.thing-spring',
- action: 'show',
- id: p.id,
- },
- })
- break
- }
- case GroupType.Contacts:
- go({
- to: { resource: 'ocotillo.contact', action: 'show', id: (option as ContactResult).properties.id },
- })
- break
- case GroupType.Assets:
- go({
- to: { resource: 'ocotillo.asset', action: 'show', id: (option as any).properties.id },
- })
- break
- }
- }
-
- const handleSelect = (option: SearchResult) => {
- navigateToResult(option)
- handleClose()
- }
-
- const handleRecentClick = (q: string) => {
- setQuery(q)
- inputRef.current?.focus()
- }
-
- const handleClearHistory = () => {
- history.clear()
- setRecentSearches([])
- }
-
- const handleClose = () => {
- if (query.trim()) history.add(query)
- setQuery('')
- setActiveGame(null)
- setDismissedGame(null)
- onClose()
- }
-
- const handleGameClose = () => {
- setDismissedGame(activeGame)
- setActiveGame(null)
- }
-
- const showRecent = !query.trim() && recentSearches.length > 0
- const showEmpty =
- query.trim() &&
- !requestedGame &&
- !searchQuery.isFetching &&
- !searchQuery.isError &&
- results.length === 0
- const showError = query.trim() && !requestedGame && !searchQuery.isFetching && searchQuery.isError
+ const state = useSearchModalState({ open, onClose })
return (
<>
-
-
-
-
-
+
+
+
+
+
>
)
}
-// ---- deduplication (same logic as SearchBar) --------------------------
-
-const dedupeResults = (items: SearchResult[]): SearchResult[] => {
- const seen = new Set()
- return items.filter((item) => {
- if (item.group === GroupType.Messages) return true
- const id = (item as any).properties?.id
- if (!id) return true
- const key = `${item.group}-${id}`
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
-}
-
export default SearchModal
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index c93e62dc..e90d7578 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -17,3 +17,4 @@ export * from './useSensorDeploymentRows'
export * from './useThingLayers'
export * from './useUSGSSiteInfo'
export * from './useViewportBbox'
+export * from './useSearchModalState'
diff --git a/src/hooks/useSearchModalState.ts b/src/hooks/useSearchModalState.ts
new file mode 100644
index 00000000..ee54f4fa
--- /dev/null
+++ b/src/hooks/useSearchModalState.ts
@@ -0,0 +1,256 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useGo } from '@refinedev/core'
+import { GroupType } from '@/constants'
+import { useAbortableList } from './useAbortableList'
+import { useDebounce } from './useDebounce'
+import { useSearchHistory } from './useSearchHistory'
+import { ContactResult, SearchResult, WellResult } from '@/interfaces/ocotillo'
+import { DocEntry, searchDocs } from '@/utils/docsSearch'
+import {
+ ArcadeGame,
+ dedupeResults,
+ filterCommands,
+ filterGames,
+ getRequestedGame,
+ groupSearchResults,
+ parseSearchQuery,
+} from '@/utils/searchModal'
+
+type UseSearchModalStateParams = {
+ open: boolean
+ onClose: () => void
+}
+
+export const useSearchModalState = ({
+ open,
+ onClose,
+}: UseSearchModalStateParams) => {
+ const go = useGo()
+ const history = useSearchHistory()
+ const inputRef = useRef(null)
+
+ const [query, setQuery] = useState('')
+ const [recentSearches, setRecentSearches] = useState([])
+ const [activeGame, setActiveGame] = useState(null)
+ const [dismissedGame, setDismissedGame] = useState(null)
+ const debounced = useDebounce(query, 400)
+
+ const normalizedQuery = query.trim().toLowerCase()
+ const normalizedDebounced = debounced.trim().toLowerCase()
+
+ const parsed = useMemo(() => parseSearchQuery(query), [query])
+
+ const requestedGame = useMemo(
+ () => getRequestedGame(parsed, normalizedQuery),
+ [normalizedQuery, parsed]
+ )
+
+ useEffect(() => {
+ if (open) {
+ setQuery('')
+ setRecentSearches(history.get())
+ setDismissedGame(null)
+ setActiveGame(null)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (open) {
+ inputRef.current?.focus()
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!requestedGame) {
+ setDismissedGame(null)
+ return
+ }
+
+ if (
+ open &&
+ activeGame !== requestedGame &&
+ dismissedGame !== requestedGame
+ ) {
+ setActiveGame(requestedGame)
+ }
+ }, [activeGame, dismissedGame, open, requestedGame])
+
+ const { query: searchQuery, result } = useAbortableList({
+ resource: 'search',
+ dataProviderName: 'ocotillo',
+ pagination: { pageSize: 100 },
+ queryOptions: {
+ enabled:
+ open && parsed.mode === 'default' && normalizedDebounced.length >= 1,
+ staleTime: 120_000,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ },
+ meta: { params: { q: debounced } },
+ })
+
+ const results: SearchResult[] = useMemo(() => {
+ if (parsed.mode !== 'default') return []
+ if (!query.trim()) return []
+ if (searchQuery.isFetching) return []
+ if (searchQuery.isError) return []
+
+ const normalized =
+ result?.data?.map((r: any) => ({
+ label: r.label,
+ description: r.description,
+ group: r.group as GroupType,
+ properties: r.properties,
+ })) ?? []
+
+ return dedupeResults(normalized)
+ }, [parsed.mode, query, result, searchQuery.isError, searchQuery.isFetching])
+
+ const grouped = useMemo(() => groupSearchResults(results), [results])
+ const filteredCommands = useMemo(() => filterCommands(parsed), [parsed])
+ const filteredGames = useMemo(() => filterGames(parsed), [parsed])
+ const docsResults = useMemo(() => {
+ if (parsed.mode !== 'docs') return []
+
+ return searchDocs(parsed.term)
+ }, [parsed])
+
+ const navigateToResult = (option: SearchResult) => {
+ switch (option.group) {
+ case GroupType.Wells:
+ case GroupType.Springs: {
+ const properties = (option as WellResult).properties
+ const isWaterWell = properties.thing_type === 'water well'
+ go({
+ to: {
+ resource: isWaterWell
+ ? 'ocotillo.thing-well'
+ : 'ocotillo.thing-spring',
+ action: 'show',
+ id: properties.id,
+ },
+ })
+ break
+ }
+ case GroupType.Contacts:
+ go({
+ to: {
+ resource: 'ocotillo.contact',
+ action: 'show',
+ id: (option as ContactResult).properties.id,
+ },
+ })
+ break
+ case GroupType.Assets:
+ go({
+ to: {
+ resource: 'ocotillo.asset',
+ action: 'show',
+ id: (option as any).properties.id,
+ },
+ })
+ break
+ }
+ }
+
+ const handleClose = () => {
+ if (query.trim()) history.add(query)
+ setQuery('')
+ setActiveGame(null)
+ setDismissedGame(null)
+ onClose()
+ }
+
+ const handleCommandSelect = (command: 'games' | 'docs') => {
+ setQuery(`!${command} `)
+ inputRef.current?.focus()
+ }
+
+ const handleDocSelect = (doc: DocEntry) => {
+ go({ to: doc.route, type: 'push' })
+ handleClose()
+ }
+
+ const handleGameSelect = (game: ArcadeGame) => {
+ setActiveGame(game)
+ }
+
+ const handleResultSelect = (option: SearchResult) => {
+ navigateToResult(option)
+ handleClose()
+ }
+
+ const handleRecentClick = (value: string) => {
+ setQuery(value)
+ inputRef.current?.focus()
+ }
+
+ const handleClearHistory = () => {
+ history.clear()
+ setRecentSearches([])
+ }
+
+ const handleGameClose = () => {
+ setDismissedGame(activeGame)
+ setActiveGame(null)
+ }
+
+ const handleEnter = () => {
+ if (parsed.mode === 'command-root' && filteredCommands.length > 0) {
+ handleCommandSelect(filteredCommands[0].key)
+ return
+ }
+
+ if (parsed.mode === 'games' && filteredGames.length > 0) {
+ handleGameSelect(filteredGames[0].key)
+ return
+ }
+
+ if (parsed.mode === 'docs' && docsResults.length > 0) {
+ handleDocSelect(docsResults[0])
+ return
+ }
+
+ if (parsed.mode === 'default' && results.length > 0) {
+ handleResultSelect(results[0])
+ }
+ }
+
+ return {
+ activeGame,
+ docsResults,
+ filteredCommands,
+ filteredGames,
+ grouped,
+ handleClearHistory,
+ handleClose,
+ handleCommandSelect,
+ handleDocSelect,
+ handleEnter,
+ handleGameClose,
+ handleGameSelect,
+ handleRecentClick,
+ handleResultSelect,
+ inputRef,
+ parsed,
+ query,
+ recentSearches,
+ requestedGame,
+ results,
+ searchQuery,
+ setQuery,
+ showDefaultEmpty:
+ parsed.mode === 'default' &&
+ query.trim() &&
+ !searchQuery.isFetching &&
+ !searchQuery.isError &&
+ results.length === 0,
+ showDocsEmpty: parsed.mode === 'docs' && docsResults.length === 0,
+ showError:
+ parsed.mode === 'default' &&
+ query.trim() &&
+ !searchQuery.isFetching &&
+ searchQuery.isError,
+ showRecent: !query.trim() && recentSearches.length > 0,
+ }
+}
diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx
index 4ef25066..ff296266 100644
--- a/src/pages/content/index.tsx
+++ b/src/pages/content/index.tsx
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'
import { Box, CircularProgress, Divider, Typography } from '@mui/material'
import { Components } from 'react-markdown'
-type FrontMatter = {
+export type FrontMatter = {
title?: string
deck?: string
date?: string
@@ -13,7 +13,10 @@ type ContentPageProps = {
src: string
}
-function parseFrontmatter(text: string): { data: FrontMatter; content: string } {
+export function parseFrontmatter(text: string): {
+ data: FrontMatter
+ content: string
+} {
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!match) return { data: {}, content: text }
@@ -34,7 +37,7 @@ function parseFrontmatter(text: string): { data: FrontMatter; content: string }
return { data, content }
}
-const markdownComponents: Components = {
+export const markdownComponents: Components = {
h2: ({ children }) => (
{children}
@@ -81,6 +84,66 @@ const markdownComponents: Components = {
hr: () => ,
}
+type MarkdownPageProps = {
+ frontmatter: FrontMatter
+ body: string
+}
+
+export const MarkdownPage: React.FC = ({
+ frontmatter,
+ body,
+}) => {
+ return (
+
+
+ {frontmatter.title && (
+
+ {frontmatter.title}
+
+ )}
+ {frontmatter.deck && (
+
+ {frontmatter.deck}
+
+ )}
+ {frontmatter.date && (
+
+ {new Date(frontmatter.date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+ )}
+
+
+ {body}
+
+
+ )
+}
+
export const ContentPage: React.FC = ({ src }) => {
const [frontmatter, setFrontmatter] = useState({})
const [body, setBody] = useState('')
@@ -106,7 +169,16 @@ export const ContentPage: React.FC = ({ src }) => {
if (loading) {
return (
-
+
)
@@ -120,32 +192,5 @@ export const ContentPage: React.FC = ({ src }) => {
)
}
- return (
-
-
- {frontmatter.title && (
-
- {frontmatter.title}
-
- )}
- {frontmatter.deck && (
-
- {frontmatter.deck}
-
- )}
- {frontmatter.date && (
-
- {new Date(frontmatter.date).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })}
-
- )}
-
-
- {body}
-
-
- )
+ return
}
diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx
index 7677a69a..1d3b5335 100644
--- a/src/test/components/SearchModal.test.tsx
+++ b/src/test/components/SearchModal.test.tsx
@@ -4,27 +4,46 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SearchModal } from '@/components/SearchModal'
-const { useAbortableListMock, useSearchHistoryMock } = vi.hoisted(() => ({
+const {
+ goMock,
+ searchDocsMock,
+ useAbortableListMock,
+ useSearchHistoryMock,
+} = vi.hoisted(() => ({
+ goMock: vi.fn(),
+ searchDocsMock: vi.fn(),
useAbortableListMock: vi.fn(),
useSearchHistoryMock: vi.fn(),
}))
vi.mock('@refinedev/core', async () => {
return {
- useGo: () => vi.fn(),
+ useGo: () => goMock,
}
})
-vi.mock('@/hooks', () => {
+vi.mock('@/hooks/useDebounce', () => ({
+ useDebounce: (value: string) => value,
+}))
+
+vi.mock('@/hooks/useAbortableList', () => ({
+ useAbortableList: (...args: unknown[]) => useAbortableListMock(...args),
+}))
+
+vi.mock('@/hooks/useSearchHistory', () => ({
+ useSearchHistory: () => useSearchHistoryMock(),
+}))
+
+vi.mock('@/utils/docsSearch', () => {
return {
- useDebounce: (value: string) => value,
- useAbortableList: (...args: unknown[]) => useAbortableListMock(...args),
- useSearchHistory: () => useSearchHistoryMock(),
+ searchDocs: (...args: unknown[]) => searchDocsMock(...args),
}
})
describe('SearchModal arcade easter eggs', () => {
beforeEach(() => {
+ goMock.mockReset()
+ searchDocsMock.mockReset()
useAbortableListMock.mockReset()
useSearchHistoryMock.mockReset()
useSearchHistoryMock.mockReturnValue({
@@ -32,6 +51,7 @@ describe('SearchModal arcade easter eggs', () => {
add: vi.fn(),
clear: vi.fn(),
})
+ searchDocsMock.mockReturnValue([])
useAbortableListMock.mockReturnValue({
query: {
isFetching: false,
@@ -116,4 +136,51 @@ describe('SearchModal arcade easter eggs', () => {
expect(screen.getByText('Mines left: 10')).toBeTruthy()
expect(screen.getByRole('grid', { name: 'Minesweeper game board' })).toBeTruthy()
})
+
+ it('filters the command list as partial shebang commands are typed', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ const input = screen.getByRole('textbox', { name: 'Search' })
+
+ await user.type(input, '!')
+ expect(screen.getByText('!games')).toBeTruthy()
+ expect(screen.getByText('!docs')).toBeTruthy()
+
+ await user.type(input, 'd')
+ expect(screen.queryByText('!games')).toBeNull()
+ expect(screen.getByText('!docs')).toBeTruthy()
+ })
+
+ it('searches local docs results for !docs queries and navigates to the selected page', async () => {
+ const user = userEvent.setup()
+
+ searchDocsMock.mockReturnValue([
+ {
+ id: 'about',
+ title: 'About Ocotillo',
+ path: 'about.md',
+ slug: 'about',
+ route: '/about',
+ content: 'A data management portal',
+ },
+ ])
+
+ render()
+
+ const input = screen.getByRole('textbox', { name: 'Search' })
+ await user.type(input, '!docs content')
+
+ expect(searchDocsMock).toHaveBeenLastCalledWith('content')
+ expect(screen.getByText('About Ocotillo')).toBeTruthy()
+ expect(screen.getByText(/about\.md/i)).toBeTruthy()
+
+ const lastCall = useAbortableListMock.mock.calls.at(-1)?.[0]
+ expect(lastCall?.queryOptions?.enabled).toBe(false)
+
+ await user.keyboard('{Enter}')
+
+ expect(goMock).toHaveBeenCalledWith({ to: '/about', type: 'push' })
+ })
})
diff --git a/src/utils/docsSearch.ts b/src/utils/docsSearch.ts
new file mode 100644
index 00000000..0adb90f8
--- /dev/null
+++ b/src/utils/docsSearch.ts
@@ -0,0 +1,83 @@
+import { FrontMatter, parseFrontmatter } from '@/pages/content'
+
+export type DocEntry = {
+ id: string
+ title: string
+ path: string
+ slug: string
+ route: string
+ content: string
+ frontmatter: FrontMatter
+}
+
+const docModules = import.meta.glob('../../public/content/**/*.md', {
+ query: '?raw',
+ import: 'default',
+ eager: true,
+}) as Record
+
+const startCase = (value: string) =>
+ value
+ .replace(/[-_]+/g, ' ')
+ .replace(/\b\w/g, (char) => char.toUpperCase())
+
+const normalizeDocPath = (modulePath: string) =>
+ modulePath.replace(/^.*\/public\/content\//, '')
+
+const toSlug = (docPath: string) => docPath.replace(/\.md$/i, '')
+
+export const DOC_ENTRIES: DocEntry[] = Object.entries(docModules)
+ .map(([modulePath, rawContent]) => {
+ const path = normalizeDocPath(modulePath)
+ const slug = toSlug(path)
+ const parsed = parseFrontmatter(rawContent)
+ const title = parsed.data.title?.trim() || startCase(slug.split('/').at(-1) || slug)
+
+ return {
+ id: slug,
+ title,
+ path,
+ slug,
+ route: `/${slug}`,
+ content: parsed.content,
+ frontmatter: {
+ ...parsed.data,
+ title,
+ },
+ }
+ })
+ .sort((a, b) => a.title.localeCompare(b.title))
+
+const scoreDoc = (doc: DocEntry, normalizedTerm: string) => {
+ if (!normalizedTerm) return 0
+
+ const title = doc.title.toLowerCase()
+ const path = doc.path.toLowerCase()
+ const content = doc.content.toLowerCase()
+
+ if (title === normalizedTerm) return 500
+ if (title.startsWith(normalizedTerm)) return 400
+ if (title.includes(normalizedTerm)) return 300
+ if (path.includes(normalizedTerm)) return 200
+ if (content.includes(normalizedTerm)) return 100
+
+ return -1
+}
+
+export const searchDocs = (term: string): DocEntry[] => {
+ const normalizedTerm = term.trim().toLowerCase()
+
+ if (!normalizedTerm) return DOC_ENTRIES
+
+ return DOC_ENTRIES
+ .map((doc) => ({
+ doc,
+ score: scoreDoc(doc, normalizedTerm),
+ }))
+ .filter((entry) => entry.score >= 0)
+ .sort((a, b) => b.score - a.score || a.doc.title.localeCompare(b.doc.title))
+ .map((entry) => entry.doc)
+}
+
+export const findDocBySlug = (slug: string) =>
+ DOC_ENTRIES.find((doc) => doc.slug === slug)
diff --git a/src/utils/index.ts b/src/utils/index.ts
index a804db82..7abe4f11 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -25,3 +25,5 @@ export * from './Transform'
export * from './UpdateMapView'
export * from './UtmToLonLat'
export * from './WellBatchExport'
+export * from './docsSearch'
+export * from './searchModal'
diff --git a/src/utils/searchModal.ts b/src/utils/searchModal.ts
new file mode 100644
index 00000000..a296a409
--- /dev/null
+++ b/src/utils/searchModal.ts
@@ -0,0 +1,161 @@
+import { GroupType } from '@/constants'
+import { SearchResult } from '@/interfaces/ocotillo'
+import { DocEntry } from '@/utils/docsSearch'
+
+export type ArcadeGame =
+ | 'snake'
+ | 'asteroids'
+ | 'racecar'
+ | 'tetris'
+ | 'minesweeper'
+
+export type SearchMode =
+ | 'default'
+ | 'command-root'
+ | 'games'
+ | 'docs'
+ | 'unknown-command'
+
+export type ParsedSearch =
+ | { mode: 'default'; term: string }
+ | { mode: 'command-root'; term: string }
+ | { mode: 'games'; term: string }
+ | { mode: 'docs'; term: string }
+ | { mode: 'unknown-command'; command: string; term: string }
+
+export const COMMANDS = [
+ {
+ key: 'games',
+ label: '!games',
+ description: 'Browse and launch games',
+ },
+ {
+ key: 'docs',
+ label: '!docs',
+ description: 'Search docs by title or content',
+ },
+] as const
+
+export const GAMES: {
+ key: ArcadeGame
+ label: string
+ description: string
+}[] = [
+ { key: 'snake', label: 'Snake', description: 'Classic snake game' },
+ { key: 'asteroids', label: 'Asteroids', description: 'Arcade space shooter' },
+ { key: 'racecar', label: 'Race Car', description: 'Driving game' },
+ { key: 'tetris', label: 'Tetris', description: 'Block puzzle game' },
+ { key: 'minesweeper', label: 'Minesweeper', description: 'Find all mines' },
+]
+
+export const parseSearchQuery = (query: string): ParsedSearch => {
+ const trimmed = query.trim()
+
+ if (!trimmed.startsWith('!')) {
+ return { mode: 'default', term: trimmed }
+ }
+
+ const withoutBang = trimmed.slice(1).trimStart()
+
+ if (!withoutBang.trim()) {
+ return { mode: 'command-root', term: '' }
+ }
+
+ const [command, ...rest] = withoutBang.split(/\s+/)
+ const commandTerm = rest.join(' ').trim()
+ const normalizedCommand = command.toLowerCase()
+
+ if (normalizedCommand === 'games') {
+ return { mode: 'games', term: commandTerm }
+ }
+
+ if (normalizedCommand === 'docs') {
+ return { mode: 'docs', term: commandTerm }
+ }
+
+ const partialMatches = COMMANDS.filter((item) =>
+ item.key.startsWith(normalizedCommand)
+ )
+
+ if (partialMatches.length > 0 && !commandTerm) {
+ return { mode: 'command-root', term: normalizedCommand }
+ }
+
+ return {
+ mode: 'unknown-command',
+ command,
+ term: commandTerm,
+ }
+}
+
+export const filterCommands = (parsed: ParsedSearch) => {
+ if (parsed.mode !== 'command-root') return []
+
+ const term = parsed.term.trim().toLowerCase()
+ if (!term) return COMMANDS
+
+ return COMMANDS.filter((command) => command.key.startsWith(term))
+}
+
+export const filterGames = (parsed: ParsedSearch) => {
+ if (parsed.mode !== 'games') return []
+
+ const term = parsed.term.trim().toLowerCase()
+ if (!term) return GAMES
+
+ return GAMES.filter(
+ (game) => game.key.includes(term) || game.label.toLowerCase().includes(term)
+ )
+}
+
+export const getRequestedGame = (
+ parsed: ParsedSearch,
+ normalizedQuery: string
+): ArcadeGame | null => {
+ const normalizedTerm =
+ parsed.mode === 'games' ? parsed.term.trim().toLowerCase() : normalizedQuery
+
+ if (!normalizedTerm) return null
+
+ const exactMatch = GAMES.find((game) => game.key === normalizedTerm)
+ return exactMatch?.key ?? null
+}
+
+export const buildDocExcerpt = (doc: DocEntry, query: string) => {
+ const trimmedQuery = query.trim().toLowerCase()
+ if (!trimmedQuery) return doc.path
+
+ const normalizedContent = doc.content.toLowerCase()
+ const matchIndex = normalizedContent.indexOf(trimmedQuery)
+ if (matchIndex === -1) return doc.path
+
+ const start = Math.max(0, matchIndex - 40)
+ const end = Math.min(doc.content.length, matchIndex + trimmedQuery.length + 80)
+ const excerpt = doc.content.slice(start, end).replace(/\s+/g, ' ').trim()
+
+ return `${doc.path} · ${excerpt}${end < doc.content.length ? '...' : ''}`
+}
+
+export const dedupeResults = (items: SearchResult[]): SearchResult[] => {
+ const seen = new Set()
+ return items.filter((item) => {
+ if (item.group === GroupType.Messages) return true
+ const id = (item as any).properties?.id
+ if (!id) return true
+ const key = `${item.group}-${id}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+export const groupSearchResults = (results: SearchResult[]) => {
+ const map = new Map()
+
+ for (const result of results) {
+ if (!map.has(result.group)) map.set(result.group, [])
+ map.get(result.group)!.push(result)
+ }
+
+ return map
+}