github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
16b7924
import {
ActionIcon,
Card,
Center,
Divider,
Drawer,
type DrawerProps,
Group,
ScrollArea,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconChartBar,
IconClock,
IconHistory,
IconPin,
IconPinFilled,
IconSearch,
IconTrash,
IconTrashX,
IconX,
} from "@tabler/icons-react";
import { usePubSub } from "create-pubsub/react";
import { useEffect, useState } from "react";
import { useSearchHistory } from "../../../hooks/useSearchHistory";
import type { SearchEntry } from "../../../modules/history";
import { settingsPubSub } from "../../../modules/pubSub";
import { formatRelativeTime } from "../../../modules/stringFormatters";
import SearchStats from "../../Analytics/SearchStats";
interface HistoryDrawerProps extends Omit<DrawerProps, "children"> {
onSearchSelect?: (entry: SearchEntry) => void;
}
export default function HistoryDrawer({
onSearchSelect,
...drawerProps
}: HistoryDrawerProps) {
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<string | null>("history");
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const [settings] = usePubSub(settingsPubSub);
const {
filteredSearches,
groupedSearches,
togglePin,
deleteEntry,
searchHistory,
} = useSearchHistory({ limit: 100, enableGrouping: true });
useEffect(() => {
searchHistory(searchQuery);
}, [searchQuery, searchHistory]);
const handleSearchSelect = (search: SearchEntry) => {
if (onSearchSelect) {
onSearchSelect(search);
}
};
const handlePin = (searchId: number) => {
togglePin(searchId);
};
const handleDelete = (searchId: number) => {
deleteEntry(searchId);
setPendingDeleteId(null);
};
const handleDeleteClick = (searchId: number) => {
if (pendingDeleteId === searchId) {
handleDelete(searchId);
} else {
setPendingDeleteId(searchId);
}
};
const cancelDelete = () => {
setPendingDeleteId(null);
};
const renderSearchItem = (search: SearchEntry, index: number) => (
<Card
key={search.id || index}
p="sm"
radius="md"
style={{ cursor: "pointer" }}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => handleSearchSelect(search)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Tooltip label={search.query} multiline withArrow>
<Text size="sm" fw={search.isPinned ? 600 : 400} truncate>
{search.query}
</Text>
</Tooltip>
<Text
size="xs"
c="dimmed"
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<IconClock size={12} />
{formatRelativeTime(search.timestamp)}
</Text>
</Stack>
<Group gap="xs" style={{ flexShrink: 0 }}>
<ActionIcon
variant="subtle"
color={search.isPinned ? "blue" : "gray"}
size="sm"
onClick={(e) => {
e.stopPropagation();
if (search.id) handlePin(search.id);
}}
aria-label={search.isPinned ? "Unpin search" : "Pin search"}
>
{search.isPinned ? (
<IconPinFilled size={14} />
) : (
<IconPin size={14} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color={pendingDeleteId === search.id ? "red" : "default"}
size="sm"
onClick={(e) => {
e.stopPropagation();
if (search.id) handleDeleteClick(search.id);
}}
onMouseLeave={() => {
if (pendingDeleteId === search.id) {
cancelDelete();
}
}}
aria-label={
pendingDeleteId === search.id
? "Click again to confirm delete"
: "Delete search"
}
style={{
transition: "all 0.2s ease",
}}
>
{pendingDeleteId === search.id ? (
<IconTrashX size={14} />
) : (
<IconTrash size={14} />
)}
</ActionIcon>
</Group>
</Group>
</Card>
);
const renderGroupedSearches = () => {
const pinnedSearches = filteredSearches.filter((search) => search.isPinned);
const unpinnedSearches = filteredSearches.filter(
(search) => !search.isPinned,
);
const content = [];
if (pinnedSearches.length > 0) {
content.push(
<Stack key="pinned" gap="xs">
<Text size="sm" fw={600} c="blue" tt="uppercase">
Pinned
</Text>
{pinnedSearches.map(renderSearchItem)}
<Divider my="sm" />
</Stack>,
);
}
if (
Object.keys(groupedSearches).length === 0 ||
unpinnedSearches.length === 0
) {
content.push(...unpinnedSearches.map(renderSearchItem));
} else {
const unpinnedGrouped = unpinnedSearches.reduce(
(acc, search) => {
for (const [period, searches] of Object.entries(groupedSearches)) {
if (searches.some((s) => s.id === search.id)) {
if (!acc[period]) acc[period] = [];
acc[period].push(search);
break;
}
}
return acc;
},
{} as Record<string, typeof unpinnedSearches>,
);
Object.entries(unpinnedGrouped).forEach(([period, searches]) => {
if (searches.length > 0) {
content.push(
<Stack key={period} gap="xs">
<Text size="sm" fw={600} c="dimmed" tt="uppercase">
{period}
</Text>
{searches.map(renderSearchItem)}
</Stack>,
);
}
});
}
return content;
};
if (!settings.enableHistory) {
return (
<Drawer {...drawerProps} position="left" size="md" title="Search History">
<Center h={200}>
<Stack align="center" gap="xs">
<Text c="dimmed">Search history is disabled</Text>
<Text size="sm" c="dimmed">
Enable it in settings to see your recent searches
</Text>
</Stack>
</Center>
</Drawer>
);
}
return (
<Drawer
{...drawerProps}
position="left"
size="md"
title={
<Group gap="xs">
<Text fw={600}>Search History & Analytics</Text>
</Group>
}
>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="history" leftSection={<IconHistory size={16} />}>
History
</Tabs.Tab>
<Tabs.Tab value="analytics" leftSection={<IconChartBar size={16} />}>
Analytics
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="history" pt="md">
<Stack gap="md">
<TextInput
placeholder="Filter history..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSection={
searchQuery && (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setSearchQuery("")}
>
<IconX size={14} />
</ActionIcon>
)
}
size="sm"
style={{ flex: 1 }}
/>
<ScrollArea.Autosize
mah="calc(100vh - 190px)"
styles={{ content: { minWidth: 0 } }}
>
{filteredSearches.length === 0 ? (
<Center py="xl">
<Stack align="center" gap="xs">
<IconSearch size={32} color="gray" />
<Text c="dimmed">
{searchQuery
? "No matching searches found"
: "No search history yet"}
</Text>
{!searchQuery && (
<Text size="sm" c="dimmed" ta="center">
Your recent searches will appear here
</Text>
)}
</Stack>
</Center>
) : (
<Stack gap="xs">{renderGroupedSearches()}</Stack>
)}
</ScrollArea.Autosize>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="analytics" pt="md">
<ScrollArea.Autosize mah="calc(100vh - 140px)">
<SearchStats period="all" compact />
</ScrollArea.Autosize>
</Tabs.Panel>
</Tabs>
</Drawer>
);
}