Search History tab

This commit is contained in:
Sean Edmunds
2025-11-06 15:56:43 -06:00
parent ed804a0182
commit 1103a7401d
11 changed files with 947 additions and 860 deletions

1132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,34 +10,36 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@gfazioli/mantine-text-animate": "^2.3.1", "@gfazioli/mantine-text-animate": "^2.3.3",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/core": "^8.3.5", "@mantine/core": "^8.3.6",
"@mantine/form": "^8.3.5", "@mantine/dates": "^8.3.6",
"@mantine/hooks": "^8.3.5", "@mantine/form": "^8.3.6",
"@mantine/notifications": "^8.3.5", "@mantine/hooks": "^8.3.6",
"@tanstack/react-query": "^5.90.3", "@mantine/notifications": "^8.3.6",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-persist-client": "^5.90.5", "@tanstack/react-query-persist-client": "^5.90.9",
"@tanstack/react-table": "^8.21.3",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"jotai": "^2.15.0", "jotai": "^2.15.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.5",
"recharts": "^3.2.1" "recharts": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.7.2", "@types/node": "^24.10.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.0.7",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^4.0.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.10", "vite": "^7.2.1",
"vite-plugin-dts": "~4.5.4", "vite-plugin-dts": "~4.5.4",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4" "vitest": "^4.0.7"
} }
} }

View File

@@ -17,7 +17,9 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { Loading, MainLayout } from './components'; import { Loading, MainLayout } from './components';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import '@gfazioli/mantine-text-animate/styles.css'; import '@gfazioli/mantine-text-animate/styles.css';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Homepage, Settings } from 'pages'; import { Homepage, Settings } from 'pages';

View File

@@ -0,0 +1,119 @@
import { ActionIcon, Box, Group, Text } from '@mantine/core';
import { createColumnHelper } from '@tanstack/react-table';
import { FaFilter, FaTrash } from 'react-icons/fa';
const columnHelper = createColumnHelper<any>();
export const SearchHistoryColumns = (
setSelectedItem: React.Dispatch<React.SetStateAction<any>>,
deleteItemOpen: () => void,
filtersEnabled: boolean,
setFiltersEnabled: React.Dispatch<React.SetStateAction<boolean>>,
) => [
columnHelper.accessor('id', {
cell: ({ row }: any) => (
<Group gap={0} justify='center'>
<ActionIcon
color={'red'}
c={'black'}
onClick={() => {
setSelectedItem(row.original);
deleteItemOpen();
}}
>
<FaTrash />
</ActionIcon>
</Group>
),
header: () => (
<Group gap={0} justify='center'>
<Box
w={'1.75rem'}
h={'1.75rem'}
style={{
borderRadius: 4,
backgroundColor: 'var(--mantine-color-accent-0)',
alignContent: 'center',
justifyContent: 'center',
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setFiltersEnabled(!filtersEnabled);
}}
>
<FaFilter />
</Box>
</Group>
),
size: 64,
maxSize: 64,
enableSorting: false,
}),
columnHelper.accessor('prefix', {
id: 'prefix',
cell: ({ cell }: any) => (
<Group justify='center'>
<Text>{cell.getValue()}</Text>
</Group>
),
header: () => <Text fw={600}>Prefix</Text>,
meta: {
filterVariant: 'mantineSelect',
},
filterFn: (row, id, value) => {
if (value === null) return true;
return row.getValue(id) === value;
},
}),
columnHelper.accessor('provider', {
id: 'provider',
cell: ({ cell }: any) => <Text mx={8}>{cell.getValue()}</Text>,
header: () => <Text fw={600}>Provider</Text>,
meta: {
filterVariant: 'mantineSelect',
},
}),
columnHelper.accessor('searchTerm', {
id: 'searchTerm',
cell: ({ cell }: any) => (
<Text w={'100%'} mx={8} style={{ justifySelf: 'center' }}>
{cell.getValue()}
</Text>
),
header: () => <Text fw={600}>Search String</Text>,
meta: {
filterVariant: 'text',
},
}),
columnHelper.accessor('dateTimeStamp', {
id: 'dateTimeStamp',
cell: ({ cell }: any) => {
const m = new Date(cell.getValue());
const year = `${m.getUTCFullYear()}`.slice(-2);
const month = `0${m.getUTCMonth() + 1}`.slice(-2);
const day = `0${m.getUTCDate()}`.slice(-2);
const hours = `0${m.getUTCHours()}`.slice(-2);
const minutes = `0${m.getUTCMinutes()}`.slice(-2);
const dateString = `${month}/${day}/${year} ${hours}:${minutes}`;
return (
<Group justify='center'>
<Text mx={8}>{dateString}</Text>
</Group>
);
},
header: () => <Text fw={600}>Time Stamp</Text>,
meta: {
filterVariant: 'range',
},
filterFn: (row, id, value) => {
const startDate = new Date(value[0]);
const endDate = new Date(value[1]);
const cellDate = new Date(row.getValue(id));
if (value[0] && value[1]) {
return cellDate >= startDate && cellDate <= endDate;
}
return true;
},
}),
];

View File

@@ -0,0 +1,165 @@
import { Button, Group } from '@mantine/core';
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFacetedMinMaxValues,
getFilteredRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from '@tanstack/react-table';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { Filter } from './components';
export function DataTable({
data,
columns,
sorting,
setSorting,
filtersEnabled,
globalFilter,
setGlobalFilter,
}: {
data: any[];
columns: any[];
subRow?: any;
sorting: SortingState;
setSorting: Dispatch<SetStateAction<SortingState>>;
filtersEnabled: boolean;
globalFilter: string;
setGlobalFilter: Dispatch<SetStateAction<string>>;
}) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data: data,
columns,
initialState: {
sorting,
},
state: {
sorting,
columnFilters,
globalFilter: globalFilter,
},
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFilteredRowModel(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
onColumnFiltersChange: setColumnFilters,
enableSorting: true,
enableFilters: true,
enableGlobalFilter: true,
globalFilterFn: 'includesString',
onGlobalFilterChange: setGlobalFilter,
});
return (
<table style={{ width: '100%', backgroundColor: 'var(--mantine-color-primary-0)' }}>
<thead
style={{
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: 'var(--mantine-color-primary-0)',
borderTopLeftRadius: '1rem',
borderTopRightRadius: '1rem',
}}
>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<>
<Button
{...{
style: {
cursor: 'pointer',
width: '100%',
borderRadius: '0px',
backgroundColor: 'transparent',
},
onClick: () => {
if (header.column.getCanSort()) {
if (sorting[0].id === header.id) {
setSorting([
{
id: header.id,
desc: !sorting[0].desc,
},
]);
} else {
setSorting([
{
id: header.id,
desc: false,
},
]);
}
}
},
}}
>
<Group align='center' justify='center'>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</Group>
</Button>
{filtersEnabled ? (
<Filter
column={header.column}
options={[...new Set(data.map((item: any) => item[header.id]))]}
/>
) : null}
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, index) => (
<tr
key={row.id}
style={{
color: 'var(--mantine-color-primary-0)',
backgroundColor:
index % 2 === 0
? 'color-mix(in srgb, var(--mantine-color-background-0), var(--mantine-color-primary-0) 10%)'
: 'color-mix(in srgb, var(--mantine-color-background-0), var(--mantine-color-primary-0) 30%)',
}}
onClick={() => {
row.toggleExpanded();
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
<tfoot>
{table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id}>
{footerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.footer, header.getContext())}
</th>
))}
</tr>
))}
</tfoot>
</table>
);
}

View File

@@ -0,0 +1,161 @@
import {
Box,
Checkbox,
type ComboboxItem,
Group,
MultiSelect,
type OptionsFilter,
RangeSlider,
Select,
Stack,
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import type { Column, RowData } from '@tanstack/react-table';
import { DebouncedInput } from 'components';
import { selectClasses } from 'styles';
declare module '@tanstack/react-table' {
//allows us to define custom properties for our columns
// biome-ignore lint/correctness/noUnusedVariables: ignore
interface ColumnMeta<TData extends RowData, TValue> {
filterVariant?:
| 'text'
| 'range'
| 'select'
| 'mantineSelect'
| 'multiSelect'
| 'slider'
| 'checkbox'
| 'none';
}
}
export function Filter({ column, options }: { column: Column<unknown>; options: any }) {
const columnFilterValue = column.getFilterValue();
const { filterVariant } = column.columnDef.meta ?? {};
const optionsFilter: OptionsFilter = ({ options, search }) => {
const splittedSearch = search.toLowerCase().trim().split(' ');
return (options as ComboboxItem[])?.filter((option) => {
const words = option.label?.toLowerCase().trim().split(' ');
return splittedSearch.every((searchWord) =>
words?.some((word) => word.includes(searchWord)),
);
});
};
return filterVariant === 'range' ? (
<div>
<DatePickerInput
type='range'
clearable
style={{
width: '100%',
}}
styles={{
input: {
borderRadius: 0,
border: 'none',
color: 'var(--mantine-color-primary-0)',
},
}}
value={columnFilterValue as [Date, Date]}
onChange={(value) => column.setFilterValue(value)}
/>
</div>
) : filterVariant === 'select' ? (
<select
onChange={(e) => column.setFilterValue(e.target.value)}
value={columnFilterValue?.toString()}
style={{
width: '100%',
padding: '4px',
}}
>
{/* See faceted column filters example for dynamic select options */}
<option value=''>All</option>
{options
.sort((a: any, b: any) => a > b)
?.map((option: any) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : filterVariant === 'mantineSelect' ? (
<Select
className={selectClasses.settingsItem}
classNames={{
input: selectClasses.menuSelectInput,
label: selectClasses.menuSelectLabel,
options: selectClasses.menuSelectOptions,
option: selectClasses.menuSelectOption,
dropdown: selectClasses.dropdown,
}}
data={options.map((option: ComboboxItem) => {
return { value: option, label: option };
})}
onChange={(e: any) => column.setFilterValue(e)}
/>
) : filterVariant === 'multiSelect' ? (
<MultiSelect
data={options
.map((option: ComboboxItem) => {
return {
value: option,
label: option,
};
})
.sort((a: ComboboxItem, b: ComboboxItem) => a.label.localeCompare(b.label))}
value={columnFilterValue as string[]}
onChange={(value) => column.setFilterValue(value)}
style={{
width: '100%',
}}
filter={optionsFilter}
searchable
clearable
/>
) : filterVariant === 'text' ? (
<DebouncedInput
onChange={(value) => column.setFilterValue(value)}
placeholder={'Search...'}
type='text'
value={(columnFilterValue ?? '') as string}
/>
// See faceted column filters example for datalist search suggestions
) : filterVariant === 'slider' ? (
<Stack h={36} justify='center'>
{column.id === 'elevation' ? (
<RangeSlider
value={columnFilterValue as [number, number]}
onChange={(value) => column.setFilterValue(value)}
min={-10}
max={20000}
defaultValue={[0, 20000]}
step={10}
/>
) : (
<RangeSlider
value={columnFilterValue as [number, number]}
onChange={(value) => column.setFilterValue(value)}
min={-180}
max={180}
defaultValue={[-180, 180]}
step={1}
/>
)}
</Stack>
) : filterVariant === 'checkbox' ? (
<Group h={36} align='center' justify='center'>
<Checkbox onChange={(value) => column.setFilterValue(value.target.checked)} />
</Group>
// See faceted column filters example for datalist search suggestions
) : (
<Box
style={{
width: 'min-content',
height: '36px',
padding: '4px',
}}
/>
);
}

View File

@@ -0,0 +1 @@
export { Filter } from './Filter';

View File

@@ -0,0 +1,57 @@
import { ActionIcon, TextInput } from '@mantine/core';
import { useEffect, useState } from 'react';
import { FiX } from 'react-icons/fi';
export function DebouncedInput({
value: initialValue,
onChange,
debounce = 500,
...props
}: {
value: string | number;
onChange: (value: string | number) => void;
debounce?: number;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
const timeout = setTimeout(() => {
onChange(value);
}, debounce);
return () => clearTimeout(timeout);
}, [value, debounce, onChange]);
return (
<TextInput
{...props}
value={value}
size='sm'
p={0}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setValue(e.target.value)}
styles={{
input: {
borderRadius: 0,
border: 'none',
color: 'var(--mantine-color-primary-0)',
},
}}
rightSection={
value && (
<ActionIcon
onClick={() => {
setValue('');
}}
>
<FiX />
</ActionIcon>
)
}
/>
);
}

View File

@@ -1,5 +1,7 @@
export { AddItemModal } from './AddItemModal'; export { AddItemModal } from './AddItemModal';
export { AppItem } from './Applications/AppItem'; export { AppItem } from './Applications/AppItem';
export { DataTable } from './DataTable/DataTable';
export { DebouncedInput } from './DebouncedInput';
export { DeleteItemModal } from './DeleteItemModal'; export { DeleteItemModal } from './DeleteItemModal';
export { HelpModal } from './HelpModal/HelpModal'; export { HelpModal } from './HelpModal/HelpModal';
export { Loading } from './Loading'; export { Loading } from './Loading';

View File

@@ -16,6 +16,7 @@ import Applications from './components/Applications/Applications';
import Bookmarks from './components/Bookmarks/Bookmarks'; import Bookmarks from './components/Bookmarks/Bookmarks';
import Messages from './components/Messages/Messages'; import Messages from './components/Messages/Messages';
import Personalization from './components/Personalization/Personalization'; import Personalization from './components/Personalization/Personalization';
import SearchHistory from './components/Search/SearchHistory';
import SearchProviders from './components/Search/SearchProviders'; import SearchProviders from './components/Search/SearchProviders';
import { ThemePlaylists } from './components/Theming/ThemePlaylists'; import { ThemePlaylists } from './components/Theming/ThemePlaylists';
import { Themes } from './components/Theming/Themes'; import { Themes } from './components/Theming/Themes';
@@ -83,6 +84,9 @@ export function Settings() {
<Tabs.Tab px={isMobile ? '.9rem' : undefined} value='providers'> <Tabs.Tab px={isMobile ? '.9rem' : undefined} value='providers'>
{isMobile ? <FiSearch /> : 'Search Providers'} {isMobile ? <FiSearch /> : 'Search Providers'}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab px={isMobile ? '.9rem' : undefined} value='history'>
{isMobile ? <FiSearch /> : 'Search History'}
</Tabs.Tab>
<Tabs.Tab px={isMobile ? '.9rem' : undefined} value='wolClients'> <Tabs.Tab px={isMobile ? '.9rem' : undefined} value='wolClients'>
{isMobile ? <FiMonitor /> : 'WoL Clients'} {isMobile ? <FiMonitor /> : 'WoL Clients'}
</Tabs.Tab> </Tabs.Tab>
@@ -143,6 +147,14 @@ export function Settings() {
<SearchProviders /> <SearchProviders />
</ScrollArea> </ScrollArea>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value='history'>
<ScrollArea
className={scrollAreaClasses.default}
classNames={scrollAreaClasses}
>
<SearchHistory />
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value='wolClients'> <Tabs.Panel value='wolClients'>
<ScrollArea <ScrollArea
className={scrollAreaClasses.default} className={scrollAreaClasses.default}

View File

@@ -0,0 +1,122 @@
import { Group, Stack, Text } from '@mantine/core';
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
import { DataTable, DeleteItemModal, SettingsHeader } from 'components';
import { SearchHistoryColumns } from 'components/Columns/SearchHistoryColumns';
import { useCommonHooks, useQueryAPI } from 'hooks';
import { useEffect, useState } from 'react';
import { textClasses } from 'styles';
import type { SearchHistoryItem } from 'types';
const SearchHistory = () => {
const searchEndpoint = 'api/searchHistory';
const { data: historyData, isFetched: historyDataIsFetched } = useQueryAPI({
endpoint: searchEndpoint,
});
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [searchValue, setSearchValue] = useState('');
const [searchHistoryItem, setSearchHistoryItem] = useState<SearchHistoryItem>();
const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isLoading, setIsLoading] = useState(false);
const { isMobile, handleDelete } = useCommonHooks();
const [filtersEnabled, setFiltersEnabled] = useState(false);
const [globalFilter, setGlobalFilter] = useState<string>('');
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const handleClose = () => {
closeModal();
};
const [sorting, setSorting] = useState([
{
id: 'dateTimeStamp',
desc: true,
},
]);
useEffect(() => {
if (historyData && historyDataIsFetched) {
setSearchHistory(historyData);
}
}, [historyData, historyDataIsFetched]);
useEffect(() => {
setGlobalFilter(debouncedSearch);
if (debouncedSearch === globalFilter) {
setIsLoading(false);
}
}, [debouncedSearch, globalFilter]);
return (
<Stack w={'100%'}>
<DeleteItemModal
modalOpened={modalOpened}
handleClose={handleClose}
handleDelete={() => handleDelete(searchHistoryItem, 'api/searchHistory')}
title={'Delete Search History Item'}
body={
<Stack gap={0} w={'100%'} h={'150px'} align='center'>
<Text>Are you sure you want to delete this Search History Item?</Text>
<Group>
<Text
className={textClasses.deleteItem}
classNames={textClasses}
>{`${searchHistoryItem?.provider}`}</Text>
<Text
className={textClasses.deleteItem}
classNames={textClasses}
>{`"${searchHistoryItem?.searchTerm}"`}</Text>
</Group>
<Text c='red'>This action cannot be undone!</Text>
</Stack>
}
/>
<SettingsHeader
setDragging={undefined}
openModal={() => {}}
title={'Search History'}
tooltip={{
title:
'All searches are stored in the database and can be found in this table.',
highlightList: ['searches', 'search', 'provider', 'prefix'],
description:
'Searches can be reviewed in the table on this page and can be deleted if necessary.',
}}
searchValue={searchValue}
setSearchValue={setSearchValue}
searchPlaceholder={'Search your Search History...'}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
<Stack
mt={isMobile ? '10rem' : '6rem'}
mb={isMobile ? '6rem' : '1rem'}
align='center'
>
<Stack
h={'calc(100vh - 15rem)'}
w={'100%'}
p={'1rem'}
pt={0}
mt={'1rem'}
style={{ overflow: 'scroll', whiteSpace: 'none' }}
>
<DataTable
data={searchHistory}
columns={SearchHistoryColumns(
setSearchHistoryItem,
openModal,
filtersEnabled,
setFiltersEnabled,
)}
sorting={sorting}
setSorting={setSorting}
filtersEnabled={filtersEnabled}
globalFilter={globalFilter}
setGlobalFilter={setGlobalFilter}
/>
</Stack>
</Stack>
</Stack>
);
};
export default SearchHistory;