Skip to content

Commit f2f7abb

Browse files
committed
feat(web): add search history navigation (task-3)
- Created useSearchHistory hook with IndexedDB persistence - Added SearchHistoryDropdown component with history menu - Integrated search history into SearchInterface - Tracks up to 50 search queries with FIFO eviction - Added idb package for IndexedDB helper - Extended SPECIAL_LIST_IDS with SEARCH_HISTORY constant
1 parent 0b0e391 commit f2f7abb

File tree

5 files changed

+305
-3
lines changed

5 files changed

+305
-3
lines changed

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@
5050
"@vanilla-extract/recipes": "0.5.7",
5151
"@vanilla-extract/sprinkles": "1.6.5",
5252
"@xyflow/react": "12.10.0",
53+
"clsx": "2.1.1",
5354
"date-fns": "4.1.0",
5455
"dexie": "4.2.1",
56+
"idb": "8.0.3",
5557
"posthog-js": "1.318.1",
5658
"qrcode": "1.5.4",
5759
"r3f-forcegraph": "1.1.1",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Search History Dropdown Component
3+
*
4+
* Displays search history in a dropdown menu.
5+
* Shows recent searches with ability to re-run or remove them.
6+
*/
7+
8+
import { ActionIcon, Box, Group, Menu, Stack, Text, Tooltip, UnstyledButton } from '@mantine/core';
9+
import { IconClock, IconHistory, IconTrash, IconX } from '@tabler/icons-react';
10+
11+
import { BORDER_STYLE_GRAY_3, ICON_SIZE } from '@/config/style-constants';
12+
import { useSearchHistory } from '@/hooks/useSearchHistory';
13+
14+
interface SearchHistoryDropdownProps {
15+
onSearchQuerySelect: (query: string) => void;
16+
}
17+
18+
const MAX_DISPLAY_ITEMS = 10;
19+
20+
export const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
21+
onSearchQuerySelect,
22+
}) => {
23+
const { searchHistory, removeSearchQuery, clearSearchHistory } = useSearchHistory();
24+
const displayHistory = searchHistory.slice(0, MAX_DISPLAY_ITEMS);
25+
26+
const handleRemoveQuery = async (id: string, event: React.MouseEvent) => {
27+
event.stopPropagation();
28+
await removeSearchQuery(id);
29+
};
30+
31+
const handleClearAll = async () => {
32+
await clearSearchHistory();
33+
};
34+
35+
if (searchHistory.length === 0) {
36+
return null;
37+
}
38+
39+
return (
40+
<Menu position="bottom-end" shadow="md" width={300} withinPortal>
41+
<Menu.Target>
42+
<Tooltip label="Search history" withinPortal>
43+
<ActionIcon variant="subtle" size="input-lg">
44+
<IconHistory size={ICON_SIZE.MD} />
45+
</ActionIcon>
46+
</Tooltip>
47+
</Menu.Target>
48+
49+
<Menu.Dropdown>
50+
<Stack gap="xs">
51+
{/* Header */}
52+
<Group justify="space-between" align="center" px="xs">
53+
<Group gap="xs" align="center">
54+
<IconClock size={ICON_SIZE.SM} />
55+
<Text size="sm" fw={500}>
56+
Recent Searches
57+
</Text>
58+
</Group>
59+
60+
{searchHistory.length > 0 && (
61+
<Tooltip label="Clear all history" withinPortal>
62+
<ActionIcon
63+
variant="subtle"
64+
color="red"
65+
size="sm"
66+
onClick={handleClearAll}
67+
>
68+
<IconTrash size={ICON_SIZE.XS} />
69+
</ActionIcon>
70+
</Tooltip>
71+
)}
72+
</Group>
73+
74+
{/* History Items */}
75+
{displayHistory.length === 0 ? (
76+
<Text size="sm" c="dimmed" ta="center" py="md">
77+
No search history yet
78+
</Text>
79+
) : (
80+
<Stack gap={2}>
81+
{displayHistory.map((entry) => (
82+
<UnstyledButton
83+
key={entry.id}
84+
onClick={() => onSearchQuerySelect(entry.query)}
85+
py="xs"
86+
px="sm"
87+
style={{
88+
borderRadius: '4px',
89+
transition: 'background-color 150ms ease',
90+
}}
91+
onMouseEnter={(e) => {
92+
e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8))';
93+
}}
94+
onMouseLeave={(e) => {
95+
e.currentTarget.style.backgroundColor = 'transparent';
96+
}}
97+
>
98+
<Group justify="space-between" align="center" wrap="nowrap">
99+
<Text
100+
size="sm"
101+
style={{
102+
overflow: 'hidden',
103+
textOverflow: 'ellipsis',
104+
whiteSpace: 'nowrap',
105+
flex: 1,
106+
}}
107+
>
108+
{entry.query}
109+
</Text>
110+
111+
<ActionIcon
112+
variant="transparent"
113+
color="gray"
114+
size="xs"
115+
onClick={(e) => void handleRemoveQuery(entry.id, e)}
116+
>
117+
<IconX size={ICON_SIZE.XS} />
118+
</ActionIcon>
119+
</Group>
120+
</UnstyledButton>
121+
))}
122+
</Stack>
123+
)}
124+
125+
{/* Footer */}
126+
{searchHistory.length > MAX_DISPLAY_ITEMS && (
127+
<Text size="xs" c="dimmed" ta="center" py="xs">
128+
Showing {MAX_DISPLAY_ITEMS} of {searchHistory.length} searches
129+
</Text>
130+
)}
131+
</Stack>
132+
</Menu.Dropdown>
133+
</Menu>
134+
);
135+
};

apps/web/src/components/search/SearchInterface.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
66

77
import { BORDER_STYLE_GRAY_3, ICON_SIZE } from "@/config/style-constants";
88
import { useSearchHotkeys } from "@/hooks/use-hotkeys";
9+
import { useSearchHistory } from "@/hooks/useSearchHistory";
910
import { announceToScreenReader } from "@/utils/accessibility";
1011

12+
import { SearchHistoryDropdown } from "./SearchHistoryDropdown";
1113
import { AdvancedSearchFilters,SearchFilters } from "./SearchFilters";
1214

1315
interface SearchFilters {
@@ -35,6 +37,7 @@ export const SearchInterface = ({
3537
const [searchTip, setSearchTip] = useState("");
3638
const [advancedFilters, setAdvancedFilters] = useState<AdvancedSearchFilters>({});
3739
const [showFilters, setShowFilters] = useState(false);
40+
const { addSearchQuery } = useSearchHistory();
3841

3942
// Set up keyboard shortcuts for search
4043
useSearchHotkeys(
@@ -48,13 +51,14 @@ export const SearchInterface = ({
4851
advanced: showFilters && Object.keys(advancedFilters).length > 0 ? advancedFilters : undefined,
4952
};
5053

51-
// Announce search to screen readers
54+
// Add to search history
5255
if (filters.query) {
56+
void addSearchQuery(filters.query);
5357
announceToScreenReader(`Searching for: ${filters.query}`);
5458
}
5559

5660
onSearch(filters);
57-
}, [query, advancedFilters, showFilters, onSearch]);
61+
}, [query, advancedFilters, showFilters, onSearch, addSearchQuery]);
5862

5963
const handleQueryChange = useCallback((value: string) => {
6064
setQuery(value);
@@ -65,10 +69,20 @@ export const SearchInterface = ({
6569
query: normalizeSearchQuery(value),
6670
advanced: showFilters && Object.keys(advancedFilters).length > 0 ? advancedFilters : undefined,
6771
};
72+
// Add to search history for debounced searches too
73+
void addSearchQuery(filters.query);
6874
onSearch(filters);
6975
}, value);
7076
}
71-
}, [onSearch, advancedFilters, showFilters]);
77+
}, [onSearch, advancedFilters, showFilters, addSearchQuery]);
78+
79+
const handleHistoryQuerySelect = useCallback((selectedQuery: string) => {
80+
setQuery(selectedQuery);
81+
handleQueryChange(selectedQuery);
82+
if (searchInputRef.current) {
83+
searchInputRef.current.focus();
84+
}
85+
}, [handleQueryChange]);
7286

7387
const handleFiltersChange = useCallback((filters: AdvancedSearchFilters) => {
7488
setAdvancedFilters(filters);
@@ -251,6 +265,7 @@ export const SearchInterface = ({
251265
>
252266
Search
253267
</Button>
268+
<SearchHistoryDropdown onSearchQuerySelect={handleHistoryQuerySelect} />
254269
</Group>
255270

256271
{/* Search Status */}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Search History Hook
3+
*
4+
* Manages search query history with IndexedDB persistence.
5+
* Stores up to 50 search queries with FIFO eviction.
6+
*/
7+
8+
import { openDB } from 'idb';
9+
import { useCallback, useEffect, useState } from 'react';
10+
11+
import { logger } from '@bibgraph/utils';
12+
13+
const SEARCH_HISTORY_DB_NAME = 'bibgraph-search-history';
14+
const SEARCH_HISTORY_STORE_NAME = 'queries';
15+
const SEARCH_HISTORY_VERSION = 1;
16+
const MAX_SEARCH_HISTORY = 50;
17+
18+
interface SearchHistoryEntry {
19+
id: string;
20+
query: string;
21+
timestamp: Date;
22+
}
23+
24+
interface SearchHistoryDB {
25+
[SEARCH_HISTORY_STORE_NAME]: {
26+
key: string;
27+
value: SearchHistoryEntry;
28+
};
29+
}
30+
31+
const initializeDB = async () => {
32+
return openDB<SearchHistoryDB>(SEARCH_HISTORY_DB_NAME, SEARCH_HISTORY_VERSION, {
33+
upgrade(db) {
34+
if (!db.objectStoreNames.contains(SEARCH_HISTORY_STORE_NAME)) {
35+
const store = db.createObjectStore(SEARCH_HISTORY_STORE_NAME, {
36+
keyPath: 'id',
37+
});
38+
store.createIndex('timestamp', 'timestamp');
39+
}
40+
},
41+
});
42+
};
43+
44+
export const useSearchHistory = () => {
45+
const [searchHistory, setSearchHistory] = useState<SearchHistoryEntry[]>([]);
46+
const [isInitialized, setIsInitialized] = useState(false);
47+
48+
// Initialize IndexedDB and load history
49+
useEffect(() => {
50+
const init = async () => {
51+
try {
52+
const db = await initializeDB();
53+
const allEntries = await db.getAll(SEARCH_HISTORY_STORE_NAME);
54+
const sortedEntries = allEntries
55+
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
56+
.slice(0, MAX_SEARCH_HISTORY);
57+
setSearchHistory(sortedEntries);
58+
setIsInitialized(true);
59+
} catch (error) {
60+
logger.error('search-history', 'Failed to initialize search history', { error });
61+
setIsInitialized(true);
62+
}
63+
};
64+
65+
void init();
66+
}, []);
67+
68+
const addSearchQuery = useCallback(
69+
async (query: string) => {
70+
if (!query.trim()) return;
71+
72+
try {
73+
const db = await initializeDB();
74+
75+
// Create new entry
76+
const entry: SearchHistoryEntry = {
77+
id: crypto.randomUUID(),
78+
query: query.trim(),
79+
timestamp: new Date(),
80+
};
81+
82+
// Add to database
83+
await db.put(SEARCH_HISTORY_STORE_NAME, entry);
84+
85+
// Get all entries and enforce FIFO eviction
86+
const allEntries = await db.getAll(SEARCH_HISTORY_STORE_NAME);
87+
if (allEntries.length > MAX_SEARCH_HISTORY) {
88+
// Sort by timestamp (oldest first)
89+
const sortedEntries = [...allEntries].sort(
90+
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
91+
);
92+
93+
// Remove oldest entries
94+
const entriesToRemove = sortedEntries.slice(0, allEntries.length - MAX_SEARCH_HISTORY);
95+
for (const entry of entriesToRemove) {
96+
await db.delete(SEARCH_HISTORY_STORE_NAME, entry.id);
97+
}
98+
}
99+
100+
// Update state
101+
const updatedHistory = await db.getAll(SEARCH_HISTORY_STORE_NAME);
102+
const sortedHistory = updatedHistory
103+
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
104+
.slice(0, MAX_SEARCH_HISTORY);
105+
setSearchHistory(sortedHistory);
106+
107+
logger.debug('search-history', 'Added search query to history', { query });
108+
} catch (error) {
109+
logger.error('search-history', 'Failed to add search query to history', { error, query });
110+
}
111+
},
112+
[],
113+
);
114+
115+
const removeSearchQuery = useCallback(async (id: string) => {
116+
try {
117+
const db = await initializeDB();
118+
await db.delete(SEARCH_HISTORY_STORE_NAME, id);
119+
120+
// Update state
121+
const updatedHistory = searchHistory.filter((entry) => entry.id !== id);
122+
setSearchHistory(updatedHistory);
123+
124+
logger.debug('search-history', 'Removed search query from history', { id });
125+
} catch (error) {
126+
logger.error('search-history', 'Failed to remove search query from history', { error, id });
127+
}
128+
}, [searchHistory]);
129+
130+
const clearSearchHistory = useCallback(async () => {
131+
try {
132+
const db = await initializeDB();
133+
await db.clear(SEARCH_HISTORY_STORE_NAME);
134+
setSearchHistory([]);
135+
136+
logger.debug('search-history', 'Cleared search history');
137+
} catch (error) {
138+
logger.error('search-history', 'Failed to clear search history', { error });
139+
}
140+
}, []);
141+
142+
return {
143+
searchHistory: isInitialized ? searchHistory : [],
144+
addSearchQuery,
145+
removeSearchQuery,
146+
clearSearchHistory,
147+
isInitialized,
148+
};
149+
};

packages/utils/src/storage/catalogue-db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const SPECIAL_LIST_IDS = {
6161
BOOKMARKS: "bookmarks-list",
6262
HISTORY: "history-list",
6363
GRAPH: "graph-list",
64+
SEARCH_HISTORY: "search-history",
6465
} as const;
6566

6667
export const SPECIAL_LIST_TYPES = {

0 commit comments

Comments
 (0)