mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
Merge branch 'main' of https://github.com/spacedriveapp/spacedrive
This commit is contained in:
@@ -167,6 +167,7 @@ export const QuickPreview = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [explorer.selectedItemHashes, explorerView.updateActiveItem]);
|
||||
|
||||
// TODO: look here - jam
|
||||
const handleMoveBetweenItems = (step: number) => {
|
||||
const nextPreviewItem = items[itemIndex + step];
|
||||
if (nextPreviewItem) {
|
||||
@@ -557,6 +558,7 @@ export const QuickPreview = () => {
|
||||
{(items) => (
|
||||
<DropdownMenu.SubMenu
|
||||
label={t('more_actions')}
|
||||
// @ts-expect-error
|
||||
icon={Plus}
|
||||
>
|
||||
{items}
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
import {
|
||||
CircleDashed,
|
||||
Cube,
|
||||
Folder,
|
||||
Heart,
|
||||
Icon,
|
||||
SelectionSlash,
|
||||
Textbox
|
||||
} from '@phosphor-icons/react';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Input } from '@sd/ui';
|
||||
import i18n from '~/app/I18n';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { SearchOptionItem, SearchOptionSubMenu } from '.';
|
||||
import { translateKindName } from '../Explorer/util';
|
||||
import { AllKeys, FilterOption, getKey } from './store';
|
||||
import { UseSearch } from './useSearch';
|
||||
import { FilterTypeCondition, filterTypeCondition } from './util';
|
||||
|
||||
export interface SearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any
|
||||
> {
|
||||
name: string;
|
||||
icon: Icon;
|
||||
conditions: TConditions;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
export interface SearchFilterCRUD<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
T = any
|
||||
> extends SearchFilter<TConditions> {
|
||||
getCondition: (args: T) => AllKeys<TConditions>;
|
||||
setCondition: (args: T, condition: keyof TConditions) => void;
|
||||
applyAdd: (args: T, option: FilterOption) => void;
|
||||
applyRemove: (args: T, option: FilterOption) => T | undefined;
|
||||
argsToOptions: (args: T, options: Map<string, FilterOption[]>) => FilterOption[];
|
||||
extract: (arg: SearchFilterArgs) => T | undefined;
|
||||
create: (data: any) => SearchFilterArgs;
|
||||
merge: (left: T, right: T) => T;
|
||||
}
|
||||
|
||||
export interface RenderSearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
T = any
|
||||
> extends SearchFilterCRUD<TConditions, T> {
|
||||
// Render is responsible for fetching the filter options and rendering them
|
||||
Render: (props: {
|
||||
filter: SearchFilterCRUD<TConditions>;
|
||||
options: (FilterOption & { type: string })[];
|
||||
search: UseSearch<any>;
|
||||
}) => JSX.Element;
|
||||
// Apply is responsible for applying the filter to the search args
|
||||
useOptions: (props: { search: string }) => FilterOption[];
|
||||
}
|
||||
|
||||
export function useToggleOptionSelected({ search }: { search: UseSearch<any> }) {
|
||||
return ({
|
||||
filter,
|
||||
option,
|
||||
select
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
option: FilterOption;
|
||||
select: boolean;
|
||||
}) => {
|
||||
search.setFilters?.((filters = []) => {
|
||||
const rawArg = filters.find((arg) => filter.extract(arg));
|
||||
|
||||
if (!rawArg) {
|
||||
const arg = filter.create(option.value);
|
||||
filters.push(arg);
|
||||
} else {
|
||||
const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!;
|
||||
|
||||
const arg = filter.extract(rawArg)!;
|
||||
|
||||
if (select) {
|
||||
if (rawArg) filter.applyAdd(arg, option);
|
||||
} else {
|
||||
if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const FilterOptionList = ({
|
||||
filter,
|
||||
options,
|
||||
search,
|
||||
empty
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
options: FilterOption[];
|
||||
search: UseSearch<any>;
|
||||
empty?: () => JSX.Element;
|
||||
}) => {
|
||||
const { allFiltersKeys } = search;
|
||||
|
||||
const toggleOptionSelected = useToggleOptionSelected({ search });
|
||||
|
||||
return (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
{empty?.() && options.length === 0
|
||||
? empty()
|
||||
: options?.map((option) => {
|
||||
const optionKey = getKey({
|
||||
...option,
|
||||
type: filter.name
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
selected={allFiltersKeys.has(optionKey)}
|
||||
setSelected={(value) => {
|
||||
toggleOptionSelected({
|
||||
filter,
|
||||
option,
|
||||
select: value
|
||||
});
|
||||
}}
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
>
|
||||
{option.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
})}
|
||||
</SearchOptionSubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterOptionText = ({
|
||||
filter,
|
||||
search
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
search: UseSearch<any>;
|
||||
}) => {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const { allFiltersKeys } = search;
|
||||
const key = getKey({
|
||||
type: filter.name,
|
||||
name: value,
|
||||
value
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<SearchOptionSubMenu className="!p-1.5" name={filter.name} icon={filter.icon}>
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
search.setFilters?.((filters) => {
|
||||
if (allFiltersKeys.has(key)) return filters;
|
||||
|
||||
const arg = filter.create(value);
|
||||
filters?.push(arg);
|
||||
setValue('');
|
||||
|
||||
return filters;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Input className="w-3/4" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<Button
|
||||
disabled={value.length === 0 || allFiltersKeys.has(key)}
|
||||
variant="accent"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
</form>
|
||||
</SearchOptionSubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterOptionBoolean = ({
|
||||
filter,
|
||||
search
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
search: UseSearch<any>;
|
||||
}) => {
|
||||
const { allFiltersKeys } = search;
|
||||
|
||||
const key = getKey({
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value: true
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
icon={filter.icon}
|
||||
selected={allFiltersKeys?.has(key)}
|
||||
setSelected={() => {
|
||||
search.setFilters?.((filters = []) => {
|
||||
const index = filters.findIndex((f) => filter.extract(f) !== undefined);
|
||||
|
||||
if (index !== -1) {
|
||||
filters.splice(index, 1);
|
||||
} else {
|
||||
const arg = filter.create(true);
|
||||
filters.push(arg);
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
};
|
||||
|
||||
function createFilter<TConditions extends FilterTypeCondition[keyof FilterTypeCondition], T>(
|
||||
filter: RenderSearchFilter<TConditions, T>
|
||||
) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
function createInOrNotInFilter<T extends string | number>(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, InOrNotIn<T>>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: InOrNotIn<T>): SearchFilterArgs;
|
||||
argsToOptions(values: T[], options: Map<string, FilterOption[]>): FilterOption[];
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['inOrNotIn'], InOrNotIn<T>>> {
|
||||
return {
|
||||
...filter,
|
||||
create: (data) => {
|
||||
if (typeof data === 'number' || typeof data === 'string')
|
||||
return filter.create({
|
||||
in: [data as any]
|
||||
});
|
||||
else if (data) return filter.create(data);
|
||||
else return filter.create({ in: [] });
|
||||
},
|
||||
conditions: filterTypeCondition.inOrNotIn,
|
||||
getCondition: (data) => {
|
||||
if ('in' in data) return 'in';
|
||||
else return 'notIn';
|
||||
},
|
||||
setCondition: (data, condition) => {
|
||||
const contents = 'in' in data ? data.in : data.notIn;
|
||||
|
||||
return condition === 'in' ? { in: contents } : { notIn: contents };
|
||||
},
|
||||
argsToOptions: (data, options) => {
|
||||
let values: T[];
|
||||
|
||||
if ('in' in data) values = data.in;
|
||||
else values = data.notIn;
|
||||
|
||||
return filter.argsToOptions(values, options);
|
||||
},
|
||||
applyAdd: (data, option) => {
|
||||
if ('in' in data) data.in = [...new Set([...data.in, option.value])];
|
||||
else data.notIn = [...new Set([...data.notIn, option.value])];
|
||||
|
||||
return data;
|
||||
},
|
||||
applyRemove: (data, option) => {
|
||||
if ('in' in data) {
|
||||
data.in = data.in.filter((id) => id !== option.value);
|
||||
|
||||
if (data.in.length === 0) return;
|
||||
} else {
|
||||
data.notIn = data.notIn.filter((id) => id !== option.value);
|
||||
|
||||
if (data.notIn.length === 0) return;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
merge: (left, right) => {
|
||||
if ('in' in left && 'in' in right) {
|
||||
return {
|
||||
in: [...new Set([...left.in, ...right.in])]
|
||||
};
|
||||
} else if ('notIn' in left && 'notIn' in right) {
|
||||
return {
|
||||
notIn: [...new Set([...left.notIn, ...right.notIn])]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Cannot merge InOrNotIns with different conditions');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTextMatchFilter(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, TextMatch>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: TextMatch): SearchFilterArgs;
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['textMatch'], TextMatch>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.textMatch,
|
||||
create: (contains) => filter.create({ contains }),
|
||||
getCondition: (data) => {
|
||||
if ('contains' in data) return 'contains';
|
||||
else if ('startsWith' in data) return 'startsWith';
|
||||
else if ('endsWith' in data) return 'endsWith';
|
||||
else return 'equals';
|
||||
},
|
||||
setCondition: (data, condition) => {
|
||||
let value: string;
|
||||
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return {
|
||||
[condition]: value
|
||||
};
|
||||
},
|
||||
argsToOptions: (data) => {
|
||||
let value: string;
|
||||
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: value,
|
||||
value
|
||||
}
|
||||
];
|
||||
},
|
||||
applyAdd: (data, { value }) => {
|
||||
if ('contains' in data) return { contains: value };
|
||||
else if ('startsWith' in data) return { startsWith: value };
|
||||
else if ('endsWith' in data) return { endsWith: value };
|
||||
else if ('equals' in data) return { equals: value };
|
||||
},
|
||||
applyRemove: () => undefined,
|
||||
merge: (left) => left
|
||||
};
|
||||
}
|
||||
|
||||
function createBooleanFilter(
|
||||
filter: Omit<
|
||||
ReturnType<typeof createFilter<any, boolean>>,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
> & {
|
||||
create(value: boolean): SearchFilterArgs;
|
||||
}
|
||||
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['trueOrFalse'], boolean>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.trueOrFalse,
|
||||
create: () => filter.create(true),
|
||||
getCondition: (data) => (data ? 'true' : 'false'),
|
||||
setCondition: (_, condition) => condition === 'true',
|
||||
argsToOptions: (value) => {
|
||||
if (!value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value
|
||||
}
|
||||
];
|
||||
},
|
||||
applyAdd: (_, { value }) => value,
|
||||
applyRemove: () => undefined,
|
||||
merge: (left) => left
|
||||
};
|
||||
}
|
||||
|
||||
export const filterRegistry = [
|
||||
createInOrNotInFilter({
|
||||
name: i18n.t('location'),
|
||||
translationKey: 'location',
|
||||
icon: Folder, // Phosphor folder icon
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
|
||||
},
|
||||
create: (locations) => ({ filePath: { locations } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['locations.list'], {
|
||||
placeholderData: keepPreviousData
|
||||
});
|
||||
const locations = query.data;
|
||||
|
||||
return (locations ?? []).map((location) => ({
|
||||
name: location.name!,
|
||||
value: location.id,
|
||||
icon: 'Folder' // Spacedrive folder icon
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options, search }) => (
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
)
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: i18n.t('tags'),
|
||||
translationKey: 'tag',
|
||||
icon: CircleDashed,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
|
||||
},
|
||||
create: (tags) => ({ object: { tags } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['tags.list']);
|
||||
const tags = query.data;
|
||||
return (tags ?? []).map((tag) => ({
|
||||
name: tag.name!,
|
||||
value: tag.id,
|
||||
icon: tag.color || 'CircleDashed'
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options, search }) => {
|
||||
return (
|
||||
<FilterOptionList
|
||||
empty={() => (
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<SDIcon name="Tags" size={32} />
|
||||
<p className="w-4/5 text-center text-xs text-ink-dull">
|
||||
{i18n.t('no_tags')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
filter={filter}
|
||||
options={options}
|
||||
search={search}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: i18n.t('kind'),
|
||||
translationKey: 'kind',
|
||||
icon: Cube,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
|
||||
},
|
||||
create: (kind) => ({ object: { kind } }),
|
||||
argsToOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () =>
|
||||
Object.keys(ObjectKind)
|
||||
.filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined)
|
||||
.map((key) => {
|
||||
const kind = ObjectKind[Number(key)] as string;
|
||||
return {
|
||||
name: translateKindName(kind),
|
||||
value: Number(key),
|
||||
icon: kind + '20'
|
||||
};
|
||||
}),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
)
|
||||
}),
|
||||
createTextMatchFilter({
|
||||
name: i18n.t('name'),
|
||||
translationKey: 'name',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
|
||||
},
|
||||
create: (name) => ({ filePath: { name } }),
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: i18n.t('extension'),
|
||||
translationKey: 'extension',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
|
||||
},
|
||||
create: (extension) => ({ filePath: { extension } }),
|
||||
argsToOptions(values) {
|
||||
return values.map((value) => ({
|
||||
type: this.name,
|
||||
name: value,
|
||||
value
|
||||
}));
|
||||
},
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
}),
|
||||
createBooleanFilter({
|
||||
name: i18n.t('hidden'),
|
||||
translationKey: 'hidden',
|
||||
icon: SelectionSlash,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
|
||||
},
|
||||
create: (hidden) => ({ filePath: { hidden } }),
|
||||
useOptions: () => {
|
||||
return [
|
||||
{
|
||||
name: 'Hidden',
|
||||
value: true,
|
||||
icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
}
|
||||
];
|
||||
},
|
||||
Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
}),
|
||||
createBooleanFilter({
|
||||
name: i18n.t('favorite'),
|
||||
translationKey: 'favorite',
|
||||
icon: Heart,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite;
|
||||
},
|
||||
create: (favorite) => ({ object: { favorite } }),
|
||||
useOptions: () => {
|
||||
return [
|
||||
{
|
||||
name: 'Favorite',
|
||||
value: true,
|
||||
icon: 'Heart' // Spacedrive folder icon
|
||||
}
|
||||
];
|
||||
},
|
||||
Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
})
|
||||
// createInOrNotInFilter({
|
||||
// name: i18n.t('label'),
|
||||
// icon: Tag,
|
||||
// extract: (arg) => {
|
||||
// if ('object' in arg && 'labels' in arg.object) return arg.object.labels;
|
||||
// },
|
||||
// create: (labels) => ({ object: { labels } }),
|
||||
// argsToOptions(values, options) {
|
||||
// return values
|
||||
// .map((value) => {
|
||||
// const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
// if (!option) return;
|
||||
|
||||
// return {
|
||||
// ...option,
|
||||
// type: this.name
|
||||
// };
|
||||
// })
|
||||
// .filter(Boolean) as any;
|
||||
// },
|
||||
// useOptions: () => {
|
||||
// const query = useLibraryQuery(['labels.list']);
|
||||
|
||||
// return (query.data ?? []).map((label) => ({
|
||||
// name: label.name!,
|
||||
// value: label.id
|
||||
// }));
|
||||
// },
|
||||
// Render: ({ filter, options, search }) => (
|
||||
// <FilterOptionList filter={filter} options={options} search={search} />
|
||||
// )
|
||||
// })
|
||||
// idk how to handle this rn since include_descendants is part of 'path' now
|
||||
//
|
||||
// createFilter({
|
||||
// name: i18n.t('with_descendants'),
|
||||
// icon: SelectionSlash,
|
||||
// conditions: filterTypeCondition.trueOrFalse,
|
||||
// setCondition: (args, condition: 'true' | 'false') => {
|
||||
// const filePath = (args.filePath ??= {});
|
||||
|
||||
// filePath.withDescendants = condition === 'true';
|
||||
// },
|
||||
// applyAdd: () => {},
|
||||
// applyRemove: (args) => {
|
||||
// delete args.filePath?.withDescendants;
|
||||
// },
|
||||
// useOptions: () => {
|
||||
// return [
|
||||
// {
|
||||
// name: 'With Descendants',
|
||||
// value: true,
|
||||
// icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// Render: ({ filter }) => {
|
||||
// return <FilterOptionBoolean filter={filter} />;
|
||||
// },
|
||||
// apply(filter, args) {
|
||||
// (args.filePath ??= {}).withDescendants = filter.condition;
|
||||
// }
|
||||
// })
|
||||
] as const satisfies ReadonlyArray<RenderSearchFilter<any>>;
|
||||
|
||||
export type FilterType = (typeof filterRegistry)[number]['name'];
|
||||
31
interface/app/$libraryId/search/Filters/FilterRegistry.tsx
Normal file
31
interface/app/$libraryId/search/Filters/FilterRegistry.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { RenderSearchFilter } from '.';
|
||||
import { favoriteFilter, hiddenFilter } from './registry/BooleanFilters';
|
||||
import {
|
||||
filePathDateCreated,
|
||||
filePathDateIndexed,
|
||||
filePathDateModified,
|
||||
mediaDateTaken,
|
||||
objectDateAccessed
|
||||
} from './registry/DateFilters';
|
||||
import { kindFilter } from './registry/KindFilter';
|
||||
import { locationFilter } from './registry/LocationFilter';
|
||||
import { tagsFilter } from './registry/TagsFilter';
|
||||
import { extensionFilter, nameFilter } from './registry/TextFilters';
|
||||
|
||||
export const filterRegistry: ReadonlyArray<RenderSearchFilter<any>> = [
|
||||
// Put filters here
|
||||
locationFilter,
|
||||
tagsFilter,
|
||||
kindFilter,
|
||||
nameFilter,
|
||||
extensionFilter,
|
||||
filePathDateCreated,
|
||||
filePathDateModified,
|
||||
objectDateAccessed,
|
||||
filePathDateIndexed,
|
||||
mediaDateTaken,
|
||||
favoriteFilter,
|
||||
hiddenFilter
|
||||
] as const;
|
||||
|
||||
export type FilterType = (typeof filterRegistry)[number]['name'];
|
||||
@@ -1,14 +1,16 @@
|
||||
import { MagnifyingGlass, X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef } from 'react';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
import { Dropdown, DropdownMenu, tw } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { useSearchContext } from '.';
|
||||
import HorizontalScroll from '../overview/Layout/HorizontalScroll';
|
||||
import { filterRegistry } from './Filters';
|
||||
import { useSearchStore } from './store';
|
||||
import { RenderIcon } from './util';
|
||||
import { SearchOptionItem, useSearchContext } from '../..';
|
||||
import HorizontalScroll from '../../../overview/Layout/HorizontalScroll';
|
||||
import { filterRegistry } from '../../Filters/index';
|
||||
import { RenderIcon } from '../../util';
|
||||
import { useFilterOptionStore } from '../store';
|
||||
import { FilterOptionList } from './FilterOptionList';
|
||||
|
||||
export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden shrink-0 h-6`;
|
||||
|
||||
@@ -30,6 +32,8 @@ export const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ o
|
||||
);
|
||||
});
|
||||
|
||||
const MENU_STYLES = `!rounded-md border !border-app-line !bg-app-box`;
|
||||
|
||||
export const AppliedFilters = () => {
|
||||
const search = useSearchContext();
|
||||
|
||||
@@ -75,17 +79,22 @@ export const AppliedFilters = () => {
|
||||
};
|
||||
|
||||
export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: () => void }) {
|
||||
const searchStore = useSearchStore();
|
||||
const search = useSearchContext();
|
||||
|
||||
const filterStore = useFilterOptionStore();
|
||||
const { t } = useLocale();
|
||||
|
||||
const filter = filterRegistry.find((f) => f.extract(arg));
|
||||
if (!filter) return;
|
||||
|
||||
const activeOptions = filter.argsToOptions(
|
||||
const activeOptions = filter.argsToFilterOptions(
|
||||
filter.extract(arg)! as any,
|
||||
searchStore.filterOptions
|
||||
filterStore.filterOptions
|
||||
);
|
||||
|
||||
// get all options for this filter
|
||||
const options = filterStore.filterOptions.get(filter.name) || [];
|
||||
|
||||
function isFilterDescriptionDisplayed() {
|
||||
if (filter?.translationKey === 'hidden' || filter?.translationKey === 'favorite') {
|
||||
return false;
|
||||
@@ -102,44 +111,63 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?:
|
||||
</StaticSection>
|
||||
{isFilterDescriptionDisplayed() && (
|
||||
<>
|
||||
<InteractiveSection className="border-l">
|
||||
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
|
||||
<div key={value}>{displayName}</div>
|
||||
))} */}
|
||||
{
|
||||
(filter.conditions as any)[
|
||||
filter.getCondition(filter.extract(arg) as any) as any
|
||||
]
|
||||
<DropdownMenu.Root
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={clsx(MENU_STYLES, 'explorer-scroll max-w-fit')}
|
||||
trigger={
|
||||
<InteractiveSection className="border-l hover:bg-app-lightBox/30">
|
||||
{
|
||||
(filter.conditions as any)[
|
||||
filter.getCondition(filter.extract(arg) as any) as any
|
||||
]
|
||||
}
|
||||
</InteractiveSection>
|
||||
}
|
||||
</InteractiveSection>
|
||||
|
||||
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
|
||||
{activeOptions && (
|
||||
<>
|
||||
{activeOptions.length === 1 ? (
|
||||
<RenderIcon className="size-4" icon={activeOptions[0]!.icon} />
|
||||
) : (
|
||||
<div className="relative flex gap-0.5 self-center">
|
||||
{activeOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
zIndex: activeOptions.length - index
|
||||
}}
|
||||
>
|
||||
<RenderIcon className="size-4" icon={option.icon} />
|
||||
>
|
||||
<SearchOptionItem>Is</SearchOptionItem>
|
||||
<SearchOptionItem>Is Not</SearchOptionItem>
|
||||
</DropdownMenu.Root>
|
||||
<DropdownMenu.Root
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={clsx(MENU_STYLES, 'explorer-scroll max-w-fit')}
|
||||
trigger={
|
||||
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm hover:bg-app-lightBox/30">
|
||||
{activeOptions && (
|
||||
<>
|
||||
{activeOptions.length === 1 ? (
|
||||
<RenderIcon
|
||||
className="size-4"
|
||||
icon={activeOptions[0]!.icon}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex gap-0.5 self-center">
|
||||
{activeOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
zIndex: activeOptions.length - index
|
||||
}}
|
||||
>
|
||||
<RenderIcon
|
||||
className="size-4"
|
||||
icon={option.icon}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeOptions.length > 1
|
||||
? `${activeOptions.length} ${t(`${filter.translationKey}`, { count: activeOptions.length })}`
|
||||
: activeOptions[0]?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeOptions.length > 1
|
||||
? `${activeOptions.length} ${t(`${filter.translationKey}`, { count: activeOptions.length })}`
|
||||
: activeOptions[0]?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</InteractiveSection>
|
||||
</InteractiveSection>
|
||||
}
|
||||
>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</DropdownMenu.Root>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SearchFilterCRUD } from '..';
|
||||
import { SearchOptionItem } from '../../SearchOptions';
|
||||
import { UseSearch } from '../../useSearch';
|
||||
import { getKey } from '../store';
|
||||
|
||||
export const FilterOptionBoolean = ({
|
||||
filter,
|
||||
search
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
search: UseSearch<any>;
|
||||
}) => {
|
||||
const { allFiltersKeys } = search;
|
||||
|
||||
const key = getKey({
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value: true
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
icon={filter.icon}
|
||||
selected={allFiltersKeys?.has(key)}
|
||||
setSelected={() => {
|
||||
search.setFilters?.((filters = []) => {
|
||||
const index = filters.findIndex((f) => filter.extract(f) !== undefined);
|
||||
|
||||
if (index !== -1) {
|
||||
filters.splice(index, 1);
|
||||
} else {
|
||||
const arg = filter.create(true);
|
||||
filters.push(arg);
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
// import { Range } from '@sd/client';
|
||||
|
||||
// import { SearchFilterCRUD } from '..';
|
||||
// import { SearchOptionItem } from '../../SearchOptions';
|
||||
// import { UseSearch } from '../../useSearch';
|
||||
// import { getKey } from '../store';
|
||||
|
||||
// export const FilterOptionDateRange = ({
|
||||
// filter,
|
||||
// search
|
||||
// }: {
|
||||
// filter: SearchFilterCRUD;
|
||||
// search: UseSearch<any>;
|
||||
// }) => {
|
||||
// const { allFiltersKeys } = search;
|
||||
|
||||
// const key = getKey({
|
||||
// type: filter.name,
|
||||
// name: filter.name,
|
||||
// value: { start: new Date(), end: new Date() } // Example default range
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <SearchOptionItem
|
||||
// icon={filter.icon}
|
||||
// selected={allFiltersKeys?.has(key)}
|
||||
// setSelected={() => {
|
||||
// search.setFilters?.((filters = []) => {
|
||||
// const index = filters.findIndex((f) => filter.extract(f) !== undefined);
|
||||
|
||||
// if (index !== -1) {
|
||||
// filters.splice(index, 1);
|
||||
// } else {
|
||||
// const arg = filter.create({ start: new Date(), end: new Date() }); // Example default range
|
||||
// filters.push(arg);
|
||||
// }
|
||||
|
||||
// return filters;
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// {filter.name}
|
||||
// </SearchOptionItem>
|
||||
// );
|
||||
// };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SearchFilterCRUD } from '..';
|
||||
import { SearchOptionItem, SearchOptionSubMenu } from '../../SearchOptions';
|
||||
import { UseSearch } from '../../useSearch';
|
||||
import { useToggleOptionSelected } from '../hooks/useToggleOptionSelected';
|
||||
import { FilterOption, getKey } from '../store';
|
||||
|
||||
export const FilterOptionList = ({
|
||||
filter,
|
||||
options,
|
||||
search,
|
||||
empty
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
options: FilterOption[];
|
||||
search: UseSearch<any>;
|
||||
empty?: () => JSX.Element;
|
||||
}) => {
|
||||
const { allFiltersKeys } = search;
|
||||
|
||||
const toggleOptionSelected = useToggleOptionSelected({ search });
|
||||
|
||||
return (
|
||||
<>
|
||||
{empty?.() && options.length === 0
|
||||
? empty()
|
||||
: options?.map((option) => {
|
||||
const optionKey = getKey({
|
||||
...option,
|
||||
type: filter.name
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchOptionItem
|
||||
selected={allFiltersKeys.has(optionKey)}
|
||||
setSelected={(value) => {
|
||||
toggleOptionSelected({
|
||||
filter,
|
||||
option,
|
||||
select: value
|
||||
});
|
||||
}}
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
>
|
||||
{option.name}
|
||||
</SearchOptionItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..';
|
||||
|
||||
// TODO: Move these factories to @sd/client
|
||||
/**
|
||||
* Creates a boolean filter to handle conditions like `true` or `false`.
|
||||
* This function leverages the generic factory structure to keep the logic reusable and consistent.
|
||||
*
|
||||
* @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors.
|
||||
* @returns A filter object that supports CRUD operations for boolean conditions.
|
||||
*/
|
||||
export function createBooleanFilter(
|
||||
filter: CreateFilterFunction<FilterTypeCondition['trueOrFalse'], boolean>
|
||||
): ReturnType<typeof createFilter<FilterTypeCondition['trueOrFalse'], boolean>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.trueOrFalse,
|
||||
|
||||
create: (value: boolean) => filter.create(value),
|
||||
|
||||
getCondition: (data) => (data ? 'true' : 'false'),
|
||||
|
||||
setCondition: (_, condition) => condition === 'true',
|
||||
|
||||
argsToFilterOptions: (data) => {
|
||||
if (filter.argsToFilterOptions) {
|
||||
return filter.argsToFilterOptions([data], new Map());
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: filter.name,
|
||||
value: data
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
applyAdd: (_, option) => option.value,
|
||||
|
||||
applyRemove: () => undefined, // Boolean filters don't have multiple values, so nothing to remove
|
||||
|
||||
merge: (left) => left // Boolean filters don't require merging; return the existing value
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Range } from '@sd/client';
|
||||
|
||||
import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..';
|
||||
|
||||
/**
|
||||
* Creates a range filter to handle conditions such as `from` and `to`.
|
||||
* This function leverages the generic factory structure to keep the logic reusable and consistent.
|
||||
*
|
||||
* @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors.
|
||||
* @returns A filter object that supports CRUD operations for range conditions.
|
||||
*/
|
||||
export function createDateRangeFilter<T extends string | number>(
|
||||
filter: CreateFilterFunction<FilterTypeCondition['dateRange'], Range<T>>
|
||||
): ReturnType<typeof createFilter<FilterTypeCondition['dateRange'], Range<T>>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.dateRange,
|
||||
|
||||
create: (data) => {
|
||||
if ('from' in data) {
|
||||
return filter.create({ from: data.from });
|
||||
} else if ('to' in data) {
|
||||
return filter.create({ to: data.to });
|
||||
} else {
|
||||
throw new Error('Invalid Range data');
|
||||
}
|
||||
},
|
||||
|
||||
getCondition: (data) => {
|
||||
if ('from' in data) return 'from';
|
||||
else if ('to' in data) return 'to';
|
||||
else throw new Error('Invalid Range data');
|
||||
},
|
||||
|
||||
setCondition: (data, condition) => {
|
||||
if (condition === 'from' && 'from' in data) {
|
||||
return { from: data.from };
|
||||
} else if (condition === 'to' && 'to' in data) {
|
||||
return { to: data.to };
|
||||
} else {
|
||||
throw new Error('Invalid condition or missing data');
|
||||
}
|
||||
},
|
||||
|
||||
argsToFilterOptions: (data, options) => {
|
||||
const values: T[] = [];
|
||||
if ('from' in data) values.push(data.from);
|
||||
if ('to' in data) values.push(data.to);
|
||||
|
||||
if (filter.argsToFilterOptions) {
|
||||
return filter.argsToFilterOptions(values, options);
|
||||
}
|
||||
|
||||
return values.map((value) => ({
|
||||
type: filter.name,
|
||||
name: String(value),
|
||||
value
|
||||
}));
|
||||
},
|
||||
|
||||
applyAdd: (data, option) => {
|
||||
if ('from' in data) {
|
||||
data.from = option.value;
|
||||
} else if ('to' in data) {
|
||||
data.to = option.value;
|
||||
} else {
|
||||
throw new Error('Invalid Range data');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
applyRemove: (data, option): Range<T> | undefined => {
|
||||
if ('from' in data && data.from === option.value) {
|
||||
const { from, ...rest } = data; // Omit `from`
|
||||
return Object.keys(rest).length ? (rest as Range<T>) : undefined;
|
||||
} else if ('to' in data && data.to === option.value) {
|
||||
const { to, ...rest } = data; // Omit `to`
|
||||
return Object.keys(rest).length ? (rest as Range<T>) : undefined;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
merge: (left, right): Range<T> => {
|
||||
return {
|
||||
...('from' in left ? { from: left.from } : {}),
|
||||
...('to' in left ? { to: left.to } : {}),
|
||||
...('from' in right ? { from: right.from } : {}),
|
||||
...('to' in right ? { to: right.to } : {})
|
||||
} as Range<T>;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { InOrNotIn } from '@sd/client';
|
||||
|
||||
import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..';
|
||||
|
||||
/**
|
||||
* Creates an "In or Not In" filter to handle conditions like `in` or `notIn`.
|
||||
* This function leverages the generic factory structure to keep the logic reusable and consistent.
|
||||
*
|
||||
* @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors.
|
||||
* @returns A filter object that supports CRUD operations for in/notIn conditions.
|
||||
*/
|
||||
export function createInOrNotInFilter<T extends string | number>(
|
||||
filter: CreateFilterFunction<FilterTypeCondition['inOrNotIn'], InOrNotIn<T>>
|
||||
): ReturnType<typeof createFilter<FilterTypeCondition['inOrNotIn'], InOrNotIn<T>>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.inOrNotIn,
|
||||
|
||||
create: (data) => {
|
||||
if (typeof data === 'number' || typeof data === 'string') {
|
||||
return filter.create({ in: [data as any] });
|
||||
} else if (data) {
|
||||
return filter.create(data);
|
||||
} else {
|
||||
return filter.create({ in: [] });
|
||||
}
|
||||
},
|
||||
|
||||
getCondition: (data) => {
|
||||
if ('in' in data) return 'in';
|
||||
else return 'notIn';
|
||||
},
|
||||
|
||||
setCondition: (data, condition) => {
|
||||
const contents = 'in' in data ? data.in : data.notIn;
|
||||
return condition === 'in' ? { in: contents } : { notIn: contents };
|
||||
},
|
||||
|
||||
argsToFilterOptions: (data, options) => {
|
||||
let values: T[];
|
||||
if ('in' in data) {
|
||||
values = data.in;
|
||||
} else {
|
||||
values = data.notIn;
|
||||
}
|
||||
if (filter.argsToFilterOptions) {
|
||||
return filter.argsToFilterOptions(values, options);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
applyAdd: (data, option) => {
|
||||
if ('in' in data) {
|
||||
data.in = [...new Set([...data.in, option.value])];
|
||||
} else {
|
||||
data.notIn = [...new Set([...data.notIn, option.value])];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
applyRemove: (data, option) => {
|
||||
if ('in' in data) {
|
||||
data.in = data.in.filter((id) => id !== option.value);
|
||||
if (data.in.length === 0) return;
|
||||
} else {
|
||||
data.notIn = data.notIn.filter((id) => id !== option.value);
|
||||
if (data.notIn.length === 0) return;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
merge: (left, right) => {
|
||||
if ('in' in left && 'in' in right) {
|
||||
return { in: [...new Set([...left.in, ...right.in])] };
|
||||
} else if ('notIn' in left && 'notIn' in right) {
|
||||
return { notIn: [...new Set([...left.notIn, ...right.notIn])] };
|
||||
}
|
||||
throw new Error('Cannot merge InOrNotIns with different conditions');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TextMatch } from '@sd/client';
|
||||
|
||||
import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..';
|
||||
|
||||
/**
|
||||
* Creates a text match filter to handle search conditions such as `contains`, `startsWith`, `endsWith`, and `equals`.
|
||||
* This function leverages the generic factory structure to keep the logic reusable and consistent.
|
||||
*
|
||||
* @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors.
|
||||
* @returns A filter object that supports CRUD operations for text matching conditions.
|
||||
*/
|
||||
export function createTextMatchFilter(
|
||||
filter: CreateFilterFunction<FilterTypeCondition['textMatch'], TextMatch>
|
||||
): ReturnType<typeof createFilter<FilterTypeCondition['textMatch'], TextMatch>> {
|
||||
return {
|
||||
...filter,
|
||||
conditions: filterTypeCondition.textMatch,
|
||||
create: (contains) => filter.create({ contains }),
|
||||
|
||||
getCondition: (data) => {
|
||||
if ('contains' in data) return 'contains';
|
||||
else if ('startsWith' in data) return 'startsWith';
|
||||
else if ('endsWith' in data) return 'endsWith';
|
||||
else return 'equals';
|
||||
},
|
||||
|
||||
setCondition: (data, condition) => {
|
||||
let value: string;
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return { [condition]: value };
|
||||
},
|
||||
|
||||
argsToFilterOptions: (data) => {
|
||||
let value: string;
|
||||
if ('contains' in data) value = data.contains;
|
||||
else if ('startsWith' in data) value = data.startsWith;
|
||||
else if ('endsWith' in data) value = data.endsWith;
|
||||
else value = data.equals;
|
||||
|
||||
return [
|
||||
{
|
||||
type: filter.name,
|
||||
name: value,
|
||||
value
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
applyAdd: (data, { value }) => ({ contains: value }),
|
||||
|
||||
applyRemove: () => undefined,
|
||||
|
||||
merge: (left) => left
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SearchFilterCRUD } from '..';
|
||||
import { FilterOption } from '../';
|
||||
import { UseSearch } from '../../useSearch';
|
||||
|
||||
export function useToggleOptionSelected({ search }: { search: UseSearch<any> }) {
|
||||
return ({
|
||||
filter,
|
||||
option,
|
||||
select
|
||||
}: {
|
||||
filter: SearchFilterCRUD;
|
||||
option: FilterOption;
|
||||
select: boolean;
|
||||
}) => {
|
||||
search.setFilters?.((filters = []) => {
|
||||
const rawArg = filters.find((arg) => filter.extract(arg));
|
||||
|
||||
if (!rawArg) {
|
||||
const arg = filter.create(option.value);
|
||||
filters.push(arg);
|
||||
} else {
|
||||
const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!;
|
||||
|
||||
const arg = filter.extract(rawArg)!;
|
||||
|
||||
if (select) {
|
||||
if (rawArg) filter.applyAdd(arg, option);
|
||||
} else {
|
||||
if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
};
|
||||
}
|
||||
112
interface/app/$libraryId/search/Filters/index.tsx
Normal file
112
interface/app/$libraryId/search/Filters/index.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* This module defines the logic for creating and managing search filters.
|
||||
* Please keep this index file clean and avoid adding any logic here.
|
||||
*
|
||||
* Instead of duplicating logic for every type of filter, we use generic factory patterns to create filters dynamically.
|
||||
* The core idea is to define reusable "conditions" for each filter type (e.g., `TextMatch`, `DateRange`, `InOrNotIn`) and
|
||||
* allow filters to be created via factory functions. The interface for CRUD operations remains the same across all filters,
|
||||
* but the condition logic varies depending on the type of filter.
|
||||
*
|
||||
* Key components:
|
||||
* - `SearchFilter`: Base interface for all filters.
|
||||
* - `SearchFilterCRUD`: Extends `SearchFilter` to handle conditions, CRUD operations, and UI rendering for filter options.
|
||||
* - `RenderSearchFilter`: Extends `SearchFilterCRUD` with rendering logic specific to each filter type.
|
||||
* - `createFilter`: A factory function to instantiate filters dynamically.
|
||||
* - `CreateFilterFunction`: A utility type for defining the structure of filter factories.
|
||||
*
|
||||
* This system allows the easy addition of new filters without repeating logic.
|
||||
*/
|
||||
import { Icon } from '@phosphor-icons/react';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { UseSearch } from '../useSearch';
|
||||
import { AllKeys, type FilterOption } from './store';
|
||||
import { OmitCommonFilterProperties } from './typeGuards';
|
||||
|
||||
export { filterRegistry, type FilterType } from './FilterRegistry';
|
||||
|
||||
export type { FilterOption };
|
||||
|
||||
export { useToggleOptionSelected } from './hooks/useToggleOptionSelected';
|
||||
|
||||
// Base interface for any search filter
|
||||
export interface SearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any
|
||||
> {
|
||||
name: string;
|
||||
icon: Icon;
|
||||
conditions: TConditions;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
// Extended interface for filters supporting CRUD operations
|
||||
export interface SearchFilterCRUD<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, // Available conditions for the filter
|
||||
T = any // The data type being filtered
|
||||
> extends SearchFilter<TConditions> {
|
||||
getCondition: (args: T) => AllKeys<TConditions>; // Gets the current filter condition
|
||||
setCondition: (args: T, condition: keyof TConditions) => void; // Sets a specific condition
|
||||
applyAdd: (args: T, option: FilterOption) => void; // Adds a filter option
|
||||
applyRemove: (args: T, option: FilterOption) => T | undefined; // Removes a filter option
|
||||
argsToFilterOptions: (args: T, options: Map<string, FilterOption[]>) => FilterOption[]; // Converts args to options for UI
|
||||
extract: (arg: SearchFilterArgs) => T | undefined; // Extracts relevant filter data
|
||||
create: (data: any) => SearchFilterArgs; // Creates a new filter argument
|
||||
merge: (left: T, right: T) => T; // Merges two sets of filter args
|
||||
}
|
||||
|
||||
// Renderable search filter interface
|
||||
export interface RenderSearchFilter<
|
||||
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
T = any
|
||||
> extends SearchFilterCRUD<TConditions, T> {
|
||||
Render: (props: {
|
||||
filter: SearchFilterCRUD<TConditions>;
|
||||
options: (FilterOption & { type: string })[];
|
||||
search: UseSearch<any>;
|
||||
}) => JSX.Element;
|
||||
useOptions: (props: { search: string }) => FilterOption[];
|
||||
}
|
||||
|
||||
// Factory function to create filters dynamically
|
||||
export function createFilter<TConditions extends FilterTypeCondition[keyof FilterTypeCondition], T>(
|
||||
filter: RenderSearchFilter<TConditions, T>
|
||||
) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Interface for filters that handle the `create` method
|
||||
export interface FilterWithCreate<T, Value> {
|
||||
create: (value: Value) => SearchFilterArgs;
|
||||
argsToFilterOptions?: (values: T[], options: Map<string, FilterOption[]>) => FilterOption[];
|
||||
}
|
||||
|
||||
// General factory type for creating filters
|
||||
export type CreateFilterFunction<
|
||||
Conditions extends FilterTypeCondition[keyof FilterTypeCondition],
|
||||
Value
|
||||
> = OmitCommonFilterProperties<ReturnType<typeof createFilter<Conditions, Value>>> &
|
||||
FilterWithCreate<any, Value>;
|
||||
|
||||
export const filterTypeCondition = {
|
||||
inOrNotIn: {
|
||||
in: i18n.t('is'),
|
||||
notIn: i18n.t('is_not')
|
||||
},
|
||||
textMatch: {
|
||||
contains: i18n.t('contains'),
|
||||
startsWith: i18n.t('starts_with'),
|
||||
endsWith: i18n.t('ends_with'),
|
||||
equals: i18n.t('equals')
|
||||
},
|
||||
trueOrFalse: {
|
||||
true: i18n.t('is'),
|
||||
false: i18n.t('is_not')
|
||||
},
|
||||
dateRange: {
|
||||
from: i18n.t('from'),
|
||||
to: i18n.t('to')
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type FilterTypeCondition = typeof filterTypeCondition;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Heart, SelectionSlash } from '@phosphor-icons/react';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { FilterOptionBoolean } from '../components/FilterOptionBoolean';
|
||||
import { createBooleanFilter } from '../factories/createBooleanFilter';
|
||||
|
||||
// Hidden Filter
|
||||
export const hiddenFilter = createBooleanFilter({
|
||||
name: i18n.t('hidden'),
|
||||
translationKey: 'hidden',
|
||||
icon: SelectionSlash,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
|
||||
},
|
||||
create: (hidden) => ({ filePath: { hidden } }),
|
||||
useOptions: () => [{ name: 'Hidden', value: true, icon: SelectionSlash }],
|
||||
Render: ({ filter, options, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
});
|
||||
|
||||
// Favorite Filter
|
||||
export const favoriteFilter = createBooleanFilter({
|
||||
name: i18n.t('favorite'),
|
||||
translationKey: 'favorite',
|
||||
icon: Heart,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite;
|
||||
},
|
||||
create: (favorite) => ({ object: { favorite } }),
|
||||
useOptions: () => [{ name: 'Favorite', value: true, icon: Heart }],
|
||||
Render: ({ filter, options, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
});
|
||||
169
interface/app/$libraryId/search/Filters/registry/DateFilters.tsx
Normal file
169
interface/app/$libraryId/search/Filters/registry/DateFilters.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {} from '@sd/client'; // required for type inference of createDateRangeFilter
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
CalendarDot,
|
||||
CalendarDots,
|
||||
CalendarPlus,
|
||||
CalendarStar,
|
||||
Camera,
|
||||
ClockCounterClockwise
|
||||
} from '@phosphor-icons/react';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { FilterOption } from '..';
|
||||
import { SearchOptionSubMenu } from '../../SearchOptions';
|
||||
import { FilterOptionList } from '../components/FilterOptionList';
|
||||
import { createDateRangeFilter } from '../factories/createDateRangeFilter';
|
||||
|
||||
export const useCommonDateOptions = (): FilterOption[] => {
|
||||
return [
|
||||
{
|
||||
name: i18n.t('Today'),
|
||||
value: { from: new Date(new Date().setHours(0, 0, 0, 0)).toISOString() },
|
||||
icon: ClockCounterClockwise
|
||||
},
|
||||
{
|
||||
name: i18n.t('Past 7 Days'),
|
||||
value: { from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
icon: ClockCounterClockwise
|
||||
},
|
||||
{
|
||||
name: i18n.t('Past 30 Days'),
|
||||
value: { from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
icon: ClockCounterClockwise
|
||||
},
|
||||
{
|
||||
name: i18n.t('Last Month'),
|
||||
value: {
|
||||
from: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).toISOString()
|
||||
},
|
||||
icon: Calendar
|
||||
},
|
||||
{
|
||||
name: i18n.t('This Year'),
|
||||
value: { from: new Date(new Date().getFullYear(), 0, 1).toISOString() },
|
||||
icon: Calendar
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const filePathDateCreated = createDateRangeFilter<string>({
|
||||
name: i18n.t('Date Created'),
|
||||
translationKey: 'dateCreated',
|
||||
icon: CalendarStar,
|
||||
create: (dateRange) => ({ filePath: { createdAt: dateRange } }),
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'createdAt' in arg.filePath) return arg.filePath.createdAt;
|
||||
},
|
||||
argsToFilterOptions: (dateRange) => {
|
||||
return dateRange.map((value) => ({
|
||||
name: 'custom-date-range',
|
||||
value: value
|
||||
}));
|
||||
},
|
||||
useOptions: (): FilterOption[] => useCommonDateOptions(),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
export const filePathDateModified = createDateRangeFilter<string>({
|
||||
name: i18n.t('Date Modified'),
|
||||
translationKey: 'dateModified',
|
||||
icon: CalendarDots,
|
||||
create: (dateRange) => ({ filePath: { modifiedAt: dateRange } }),
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'modifiedAt' in arg.filePath) return arg.filePath.modifiedAt;
|
||||
},
|
||||
argsToFilterOptions: (dateRange) => {
|
||||
return dateRange.map((value) => ({
|
||||
name: value,
|
||||
value: value
|
||||
}));
|
||||
},
|
||||
useOptions: (): FilterOption[] => useCommonDateOptions(),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
export const filePathDateIndexed = createDateRangeFilter<string>({
|
||||
name: i18n.t('Date Indexed'),
|
||||
translationKey: 'dateIndexed',
|
||||
icon: CalendarPlus,
|
||||
create: (dateRange) => ({ filePath: { indexedAt: dateRange } }),
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'indexedAt' in arg.filePath) return arg.filePath.indexedAt;
|
||||
},
|
||||
argsToFilterOptions: (dateRange) => {
|
||||
return dateRange.map((value) => ({
|
||||
name: value,
|
||||
value: value
|
||||
}));
|
||||
},
|
||||
useOptions: (): FilterOption[] => useCommonDateOptions(),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
export const objectDateAccessed = createDateRangeFilter<string>({
|
||||
name: i18n.t('Date Last Accessed'),
|
||||
translationKey: 'dateLastAccessed',
|
||||
icon: CalendarDot,
|
||||
create: (dateRange) => ({ object: { dateAccessed: dateRange } }),
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'dateAccessed' in arg.object) return arg.object.dateAccessed;
|
||||
},
|
||||
argsToFilterOptions: (dateRange) => {
|
||||
return dateRange.map((value) => ({
|
||||
name: value,
|
||||
value: value
|
||||
}));
|
||||
},
|
||||
useOptions: (): FilterOption[] => useCommonDateOptions(),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
export const mediaDateTaken = createDateRangeFilter<string>({
|
||||
name: i18n.t('Date Taken'),
|
||||
translationKey: 'dateTaken',
|
||||
icon: Camera,
|
||||
create: (dateRange) => ({ object: { dateAccessed: dateRange } }),
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'dateAccessed' in arg.object) return arg.object.dateAccessed;
|
||||
},
|
||||
argsToFilterOptions: (dateRange) => {
|
||||
return dateRange.map((value) => ({
|
||||
name: value,
|
||||
value: value
|
||||
}));
|
||||
},
|
||||
useOptions: (): FilterOption[] => useCommonDateOptions(),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
|
||||
// export const filePathDateModified = createDateRangeFilter({});
|
||||
// export const filePathDateAccessed = createDateRangeFilter({});
|
||||
// export const objectDateAccessed = createDateRangeFilter({});
|
||||
|
||||
// export const dateFilters = [
|
||||
// filePathDateCreated,
|
||||
// filePathDateModified,
|
||||
// filePathDateAccessed
|
||||
// ] as const;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Cube } from '@phosphor-icons/react';
|
||||
import { ObjectKind } from '@sd/client'; // Assuming ObjectKind is an enum or set of constants
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { translateKindName } from '../../../Explorer/util';
|
||||
import { SearchOptionSubMenu } from '../../SearchOptions';
|
||||
import { FilterOptionList } from '../components/FilterOptionList';
|
||||
import { createInOrNotInFilter } from '../factories/createInOrNotInFilter';
|
||||
|
||||
export const kindFilter = createInOrNotInFilter<number>({
|
||||
name: i18n.t('kind'),
|
||||
translationKey: 'kind',
|
||||
icon: Cube,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
|
||||
},
|
||||
create: (kind) => ({ object: { kind } }),
|
||||
argsToFilterOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
if (!option) return;
|
||||
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () =>
|
||||
Object.keys(ObjectKind)
|
||||
.filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined)
|
||||
.map((key) => {
|
||||
const kind = ObjectKind[Number(key)] as string;
|
||||
return {
|
||||
name: translateKindName(kind),
|
||||
value: Number(key),
|
||||
icon: kind + '20'
|
||||
};
|
||||
}),
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Import icons
|
||||
import { Folder } from '@phosphor-icons/react';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { SearchOptionSubMenu } from '../../SearchOptions';
|
||||
import { FilterOptionList } from '../components/FilterOptionList';
|
||||
import { createInOrNotInFilter } from '../factories/createInOrNotInFilter';
|
||||
|
||||
export const locationFilter = createInOrNotInFilter<number>({
|
||||
name: i18n.t('location'),
|
||||
translationKey: 'location',
|
||||
icon: Folder,
|
||||
create: (locations) => ({ filePath: { locations } }),
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
|
||||
},
|
||||
argsToFilterOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
if (!option) return;
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
const locations = query.data;
|
||||
|
||||
return (locations ?? []).map((location) => ({
|
||||
name: location.name!,
|
||||
value: location.id,
|
||||
icon: 'Folder'
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList filter={filter} options={options} search={search} />
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CircleDashed } from '@phosphor-icons/react';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { SearchOptionSubMenu } from '../../SearchOptions';
|
||||
import { FilterOptionList } from '../components/FilterOptionList';
|
||||
import { createInOrNotInFilter } from '../factories/createInOrNotInFilter';
|
||||
|
||||
export const tagsFilter = createInOrNotInFilter<number>({
|
||||
name: i18n.t('tags'),
|
||||
translationKey: 'tag',
|
||||
icon: CircleDashed,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
|
||||
},
|
||||
create: (tags) => ({ object: { tags } }),
|
||||
argsToFilterOptions(values, options) {
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
if (!option) return;
|
||||
return {
|
||||
...option,
|
||||
type: this.name
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any;
|
||||
},
|
||||
useOptions: () => {
|
||||
const query = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
const tags = query.data;
|
||||
|
||||
return (tags ?? []).map((tag) => ({
|
||||
name: tag.name!,
|
||||
value: tag.id,
|
||||
icon: tag.color || 'CircleDashed'
|
||||
}));
|
||||
},
|
||||
Render: ({ filter, options, search }) => (
|
||||
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
<FilterOptionList
|
||||
empty={() => (
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<span className="icon-tag size-4" />
|
||||
<p className="w-4/5 text-center text-xs text-ink-dull">
|
||||
{i18n.t('no_tags')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
filter={filter}
|
||||
options={options}
|
||||
search={search}
|
||||
/>
|
||||
</SearchOptionSubMenu>
|
||||
)
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {} from '@sd/client'; // required for type inference of createDateRangeFilter
|
||||
|
||||
import { Textbox } from '@phosphor-icons/react';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import { createInOrNotInFilter } from '../factories/createInOrNotInFilter';
|
||||
import { createTextMatchFilter } from '../factories/createTextMatchFilter';
|
||||
|
||||
// Name Filter
|
||||
export const nameFilter = createTextMatchFilter({
|
||||
name: i18n.t('name'),
|
||||
translationKey: 'name',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
|
||||
},
|
||||
create: (name) => ({ filePath: { name } }),
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter, search }) => <></>
|
||||
});
|
||||
|
||||
// Extension Filter
|
||||
export const extensionFilter = createInOrNotInFilter({
|
||||
name: i18n.t('extension'),
|
||||
translationKey: 'extension',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
|
||||
},
|
||||
create: (extension) => ({ filePath: { extension } }),
|
||||
useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
Render: ({ filter, search }) => <></>
|
||||
});
|
||||
100
interface/app/$libraryId/search/Filters/store.ts
Normal file
100
interface/app/$libraryId/search/Filters/store.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Icon } from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { proxy, ref, useSnapshot } from 'valtio';
|
||||
import { proxyMap } from 'valtio/utils';
|
||||
import { Range, SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import { FilterType, RenderSearchFilter } from '.';
|
||||
import { filterRegistry } from './FilterRegistry';
|
||||
|
||||
// TODO: this store should be in @sd/client
|
||||
|
||||
// Define filter option interface
|
||||
export interface FilterOption<T = any> {
|
||||
name: string;
|
||||
value: string | number | Range<T> | any;
|
||||
icon?: string | Icon;
|
||||
}
|
||||
|
||||
// Filter type is the `name` field of a filter inferred from the filter registry
|
||||
export interface FilterOptionWithType extends FilterOption {
|
||||
type: FilterType;
|
||||
}
|
||||
|
||||
const filterOptionStore = proxy({
|
||||
filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
|
||||
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
|
||||
});
|
||||
|
||||
// Generate a unique key for a filter option
|
||||
export const getKey = (filter: FilterOptionWithType) =>
|
||||
`${filter.type}-${filter.name}-${filter.value}`;
|
||||
|
||||
// Hook to register filter options into the local store
|
||||
export const useRegisterFilterOptions = (
|
||||
filter: RenderSearchFilter,
|
||||
options: (FilterOption & { type: FilterType })[]
|
||||
) => {
|
||||
const optionsAsKeys = useMemo(() => options.map(getKey), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
filterOptionStore.filterOptions.set(filter.name, options);
|
||||
filterOptionStore.filterOptions = ref(new Map(filterOptionStore.filterOptions));
|
||||
}, [optionsAsKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = options.map((option) => {
|
||||
const key = getKey(option);
|
||||
if (!filterOptionStore.registeredFilters.has(key)) {
|
||||
filterOptionStore.registeredFilters.set(key, option);
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
keys.forEach((key) => {
|
||||
if (key) filterOptionStore.registeredFilters.delete(key);
|
||||
});
|
||||
};
|
||||
}, [optionsAsKeys]);
|
||||
};
|
||||
|
||||
// Function to retrieve registered filters based on a query
|
||||
export const useSearchRegisteredFilters = (query: string) => {
|
||||
const { registeredFilters } = useFilterOptionStore();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query) return [];
|
||||
// Filter the registered filters by matching the query string
|
||||
return [...registeredFilters.entries()]
|
||||
.filter(([key, _]) => key.toLowerCase().includes(query.toLowerCase()))
|
||||
.map(([key, filter]) => ({ ...filter, key }));
|
||||
}, [registeredFilters, query]);
|
||||
};
|
||||
|
||||
// Get snapshot of the filter option store
|
||||
export const useFilterOptionStore = () => useSnapshot(filterOptionStore);
|
||||
|
||||
// Function to reset filter options (if needed)
|
||||
export const resetFilterOptionStore = () => {
|
||||
filterOptionStore.filterOptions.clear();
|
||||
filterOptionStore.registeredFilters.clear();
|
||||
};
|
||||
|
||||
// Helper to convert arguments to filter options
|
||||
export function argsToFilterOptions(
|
||||
args: SearchFilterArgs[],
|
||||
options: Map<string, FilterOption[]>
|
||||
) {
|
||||
return args.flatMap((fixedArg) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(fixedArg));
|
||||
if (!filter) return [];
|
||||
|
||||
return filter
|
||||
.argsToFilterOptions(filter.extract(fixedArg) as any, options)
|
||||
.map((arg) => ({ arg, filter }));
|
||||
});
|
||||
}
|
||||
|
||||
export type AllKeys<T> = T extends any ? keyof T : never;
|
||||
78
interface/app/$libraryId/search/Filters/typeGuards.ts
Normal file
78
interface/app/$libraryId/search/Filters/typeGuards.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Range, SearchFilterArgs } from '@sd/client';
|
||||
|
||||
// Type guard to check if arg contains the 'filePath' with the appropriate field.
|
||||
function isFilePathWithRange(
|
||||
arg: SearchFilterArgs,
|
||||
field: 'createdAt' | 'modifiedAt' | 'indexedAt'
|
||||
): arg is { filePath: { [key in typeof field]: Range<string> } } {
|
||||
return 'filePath' in arg && typeof arg.filePath === 'object' && field in arg.filePath;
|
||||
}
|
||||
|
||||
// Type guard to check if arg contains the 'object' with the appropriate field.
|
||||
function isObjectWithRange(
|
||||
arg: SearchFilterArgs,
|
||||
field: 'dateAccessed'
|
||||
): arg is { object: { [key in typeof field]: Range<string> } } {
|
||||
return 'object' in arg && typeof arg.object === 'object' && field in arg.object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a range (from and to) from the filePath part of SearchFilterArgs.
|
||||
* Handles fields like 'createdAt', 'modifiedAt', and 'indexedAt'.
|
||||
*
|
||||
* @param arg The search filter arguments.
|
||||
* @param field The specific range field to extract.
|
||||
* @returns A Range<string> object with from and to values, or undefined if not found.
|
||||
*/
|
||||
export function extractFilePathRange(
|
||||
arg: SearchFilterArgs,
|
||||
field: 'createdAt' | 'modifiedAt' | 'indexedAt'
|
||||
): Range<string> | undefined {
|
||||
if (isFilePathWithRange(arg, field)) {
|
||||
const range = arg.filePath[field];
|
||||
|
||||
// Handle cases where only `from` or `to` exists
|
||||
const from = 'from' in range ? range.from : '';
|
||||
const to = 'to' in range ? range.to : '';
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a range (from and to) from the object part of SearchFilterArgs.
|
||||
* Handles the 'dateAccessed' field.
|
||||
*
|
||||
* @param arg The search filter arguments.
|
||||
* @param field The specific range field to extract.
|
||||
* @returns A Range<string> object with from and to values, or undefined if not found.
|
||||
*/
|
||||
export function extractObjectRange(
|
||||
arg: SearchFilterArgs,
|
||||
field: 'dateAccessed'
|
||||
): Range<string> | undefined {
|
||||
if (isObjectWithRange(arg, field)) {
|
||||
const range = arg.object[field];
|
||||
|
||||
// Handle cases where only `from` or `to` exists
|
||||
const from = 'from' in range ? range.from : '';
|
||||
const to = 'to' in range ? range.to : '';
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Utility type that omits common properties from the filter
|
||||
export type OmitCommonFilterProperties<T> = Omit<
|
||||
T,
|
||||
| 'conditions'
|
||||
| 'getCondition'
|
||||
| 'argsToFilterOptions'
|
||||
| 'setCondition'
|
||||
| 'applyAdd'
|
||||
| 'applyRemove'
|
||||
| 'create'
|
||||
| 'merge'
|
||||
>;
|
||||
890
interface/app/$libraryId/search/FiltersOld.tsx
Normal file
890
interface/app/$libraryId/search/FiltersOld.tsx
Normal file
@@ -0,0 +1,890 @@
|
||||
// import {
|
||||
// Calendar,
|
||||
// CircleDashed,
|
||||
// Cube,
|
||||
// Folder,
|
||||
// Heart,
|
||||
// Icon,
|
||||
// SelectionSlash,
|
||||
// Textbox
|
||||
// } from '@phosphor-icons/react';
|
||||
// import { useState } from 'react';
|
||||
// import {
|
||||
// InOrNotIn,
|
||||
// ObjectKind,
|
||||
// Range,
|
||||
// SearchFilterArgs,
|
||||
// TextMatch,
|
||||
// useLibraryQuery
|
||||
// } from '@sd/client';
|
||||
// import { Button, Input } from '@sd/ui';
|
||||
// import i18n from '~/app/I18n';
|
||||
// import { Icon as SDIcon } from '~/components';
|
||||
// import { useLocale } from '~/hooks';
|
||||
|
||||
// import { SearchOptionItem, SearchOptionSubMenu } from '.';
|
||||
// import { translateKindName } from '../Explorer/util';
|
||||
// import { FilterTypeCondition, filterTypeCondition } from './FiltersOld';
|
||||
// import { AllKeys, FilterOption, getKey } from './store';
|
||||
// import { UseSearch } from './useSearch';
|
||||
|
||||
// interface SearchFilter<
|
||||
// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any
|
||||
// > {
|
||||
// name: string;
|
||||
// icon: Icon;
|
||||
// conditions: TConditions;
|
||||
// translationKey?: string;
|
||||
// }
|
||||
|
||||
// interface SearchFilterCRUD<
|
||||
// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, // TConditions represents the available conditions for a specific filter, it defaults to any condition from the FilterTypeCondition
|
||||
// T = any // T is the type of the data that is being filtered. This can be any type.
|
||||
// > extends SearchFilter<TConditions> {
|
||||
// // Extends the base SearchFilter interface, adding CRUD operations specific to handling filters
|
||||
|
||||
// // Returns the current filter condition for a given set of arguments (args).
|
||||
// // This is used to determine which condition the filter is currently using (e.g., in, out, equals).
|
||||
// getCondition: (args: T) => AllKeys<TConditions>;
|
||||
|
||||
// // Sets a specific filter condition (e.g., in, out, equals) for the given arguments (args).
|
||||
// // The condition will be one of the predefined conditions in TConditions.
|
||||
// setCondition: (args: T, condition: keyof TConditions) => void;
|
||||
|
||||
// // Adds a filter option to the current filter.
|
||||
// // For example, if you are adding a tag, this method adds that tag to the filter’s arguments (args).
|
||||
// applyAdd: (args: T, option: FilterOption) => void;
|
||||
|
||||
// // Removes a filter option from the current filter.
|
||||
// // For example, if you are removing a tag, this method removes that tag from the filter's arguments (args).
|
||||
// // Returns undefined if there are no more valid filters after removal.
|
||||
// applyRemove: (args: T, option: FilterOption) => T | undefined;
|
||||
|
||||
// // Converts the filter arguments (args) into filter options that can be rendered in the UI.
|
||||
// // It maps the provided arguments to an array of FilterOption objects, which are typically used in the dropdown or selectable options UI.
|
||||
// argsToOptions: (args: T, options: Map<string, FilterOption[]>) => FilterOption[];
|
||||
|
||||
// // Extracts the relevant filter data from the larger SearchFilterArgs structure.
|
||||
// // This is used to isolate the specific part of the filter (e.g., tag filter, date filter) that this filter instance is responsible for.
|
||||
// extract: (arg: SearchFilterArgs) => T | undefined;
|
||||
|
||||
// // Creates a new SearchFilterArgs object based on the provided data.
|
||||
// // This method builds the arguments used to represent the filter in the search request.
|
||||
// create: (data: any) => SearchFilterArgs;
|
||||
|
||||
// // Merges two sets of filter arguments (left and right) into one.
|
||||
// // This is useful when combining two different filter conditions for the same filter (e.g., merging two date ranges or tag selections).
|
||||
// merge: (left: T, right: T) => T;
|
||||
// }
|
||||
|
||||
// interface RenderSearchFilter<
|
||||
// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
|
||||
// T = any
|
||||
// > extends SearchFilterCRUD<TConditions, T> {
|
||||
// // Render is responsible for fetching the filter options and rendering them
|
||||
// Render: (props: {
|
||||
// filter: SearchFilterCRUD<TConditions>;
|
||||
// options: (FilterOption & { type: string })[];
|
||||
// search: UseSearch<any>;
|
||||
// }) => JSX.Element;
|
||||
// // Apply is responsible for applying the filter to the search args
|
||||
// useOptions: (props: { search: string }) => FilterOption[];
|
||||
// }
|
||||
|
||||
// function useToggleOptionSelected({ search }: { search: UseSearch<any> }) {
|
||||
// return ({
|
||||
// filter,
|
||||
// option,
|
||||
// select
|
||||
// }: {
|
||||
// filter: SearchFilterCRUD;
|
||||
// option: FilterOption;
|
||||
// select: boolean;
|
||||
// }) => {
|
||||
// search.setFilters?.((filters = []) => {
|
||||
// const rawArg = filters.find((arg) => filter.extract(arg));
|
||||
|
||||
// if (!rawArg) {
|
||||
// const arg = filter.create(option.value);
|
||||
// filters.push(arg);
|
||||
// } else {
|
||||
// const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!;
|
||||
|
||||
// const arg = filter.extract(rawArg)!;
|
||||
|
||||
// if (select) {
|
||||
// if (rawArg) filter.applyAdd(arg, option);
|
||||
// } else {
|
||||
// if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return filters;
|
||||
// });
|
||||
// };
|
||||
// }
|
||||
|
||||
// const FilterOptionList = ({
|
||||
// filter,
|
||||
// options,
|
||||
// search,
|
||||
// empty
|
||||
// }: {
|
||||
// filter: SearchFilterCRUD;
|
||||
// options: FilterOption[];
|
||||
// search: UseSearch<any>;
|
||||
// empty?: () => JSX.Element;
|
||||
// }) => {
|
||||
// const { allFiltersKeys } = search;
|
||||
|
||||
// const toggleOptionSelected = useToggleOptionSelected({ search });
|
||||
|
||||
// return (
|
||||
// <SearchOptionSubMenu name={filter.name} icon={filter.icon}>
|
||||
// {empty?.() && options.length === 0
|
||||
// ? empty()
|
||||
// : options?.map((option) => {
|
||||
// const optionKey = getKey({
|
||||
// ...option,
|
||||
// type: filter.name
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <SearchOptionItem
|
||||
// selected={allFiltersKeys.has(optionKey)}
|
||||
// setSelected={(value) => {
|
||||
// toggleOptionSelected({
|
||||
// filter,
|
||||
// option,
|
||||
// select: value
|
||||
// });
|
||||
// }}
|
||||
// key={option.value}
|
||||
// icon={option.icon}
|
||||
// >
|
||||
// {option.name}
|
||||
// </SearchOptionItem>
|
||||
// );
|
||||
// })}
|
||||
// </SearchOptionSubMenu>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const FilterOptionText = ({
|
||||
// filter,
|
||||
// search
|
||||
// }: {
|
||||
// filter: SearchFilterCRUD;
|
||||
// search: UseSearch<any>;
|
||||
// }) => {
|
||||
// const [value, setValue] = useState('');
|
||||
|
||||
// const { allFiltersKeys } = search;
|
||||
// const key = getKey({
|
||||
// type: filter.name,
|
||||
// name: value,
|
||||
// value
|
||||
// });
|
||||
|
||||
// const { t } = useLocale();
|
||||
|
||||
// return (
|
||||
// <SearchOptionSubMenu className="!p-1.5" name={filter.name} icon={filter.icon}>
|
||||
// <form
|
||||
// className="flex gap-1.5"
|
||||
// onSubmit={(e) => {
|
||||
// e.preventDefault();
|
||||
// search.setFilters?.((filters) => {
|
||||
// if (allFiltersKeys.has(key)) return filters;
|
||||
|
||||
// const arg = filter.create(value);
|
||||
// filters?.push(arg);
|
||||
// setValue('');
|
||||
|
||||
// return filters;
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// <Input className="w-3/4" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
// <Button
|
||||
// disabled={value.length === 0 || allFiltersKeys.has(key)}
|
||||
// variant="accent"
|
||||
// className="w-full"
|
||||
// type="submit"
|
||||
// >
|
||||
// {t('apply')}
|
||||
// </Button>
|
||||
// </form>
|
||||
// </SearchOptionSubMenu>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const FilterOptionBoolean = ({
|
||||
// filter,
|
||||
// search
|
||||
// }: {
|
||||
// filter: SearchFilterCRUD;
|
||||
// search: UseSearch<any>;
|
||||
// }) => {
|
||||
// const { allFiltersKeys } = search;
|
||||
|
||||
// const key = getKey({
|
||||
// type: filter.name,
|
||||
// name: filter.name,
|
||||
// value: true
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <SearchOptionItem
|
||||
// icon={filter.icon}
|
||||
// selected={allFiltersKeys?.has(key)}
|
||||
// setSelected={() => {
|
||||
// search.setFilters?.((filters = []) => {
|
||||
// const index = filters.findIndex((f) => filter.extract(f) !== undefined);
|
||||
|
||||
// if (index !== -1) {
|
||||
// filters.splice(index, 1);
|
||||
// } else {
|
||||
// const arg = filter.create(true);
|
||||
// filters.push(arg);
|
||||
// }
|
||||
|
||||
// return filters;
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// {filter.name}
|
||||
// </SearchOptionItem>
|
||||
// );
|
||||
// };
|
||||
|
||||
// function createFilter<TConditions extends FilterTypeCondition[keyof FilterTypeCondition], T>(
|
||||
// filter: RenderSearchFilter<TConditions, T>
|
||||
// ) {
|
||||
// return filter;
|
||||
// }
|
||||
|
||||
// function createInOrNotInFilter<T extends string | number>(
|
||||
// filter: Omit<
|
||||
// ReturnType<typeof createFilter<any, InOrNotIn<T>>>,
|
||||
// | 'conditions'
|
||||
// | 'getCondition'
|
||||
// | 'argsToOptions'
|
||||
// | 'setCondition'
|
||||
// | 'applyAdd'
|
||||
// | 'applyRemove'
|
||||
// | 'create'
|
||||
// | 'merge'
|
||||
// > & {
|
||||
// create(value: InOrNotIn<T>): SearchFilterArgs;
|
||||
// argsToOptions(values: T[], options: Map<string, FilterOption[]>): FilterOption[];
|
||||
// }
|
||||
// ): ReturnType<typeof createFilter<(typeof filterTypeCondition)['inOrNotIn'], InOrNotIn<T>>> {
|
||||
// return {
|
||||
// ...filter,
|
||||
// create: (data) => {
|
||||
// if (typeof data === 'number' || typeof data === 'string')
|
||||
// return filter.create({
|
||||
// in: [data as any]
|
||||
// });
|
||||
// else if (data) return filter.create(data);
|
||||
// else return filter.create({ in: [] });
|
||||
// },
|
||||
// conditions: filterTypeCondition.inOrNotIn,
|
||||
// getCondition: (data) => {
|
||||
// if ('in' in data) return 'in';
|
||||
// else return 'notIn';
|
||||
// },
|
||||
// setCondition: (data, condition) => {
|
||||
// const contents = 'in' in data ? data.in : data.notIn;
|
||||
|
||||
// return condition === 'in' ? { in: contents } : { notIn: contents };
|
||||
// },
|
||||
// argsToOptions: (data, options) => {
|
||||
// let values: T[];
|
||||
|
||||
// if ('in' in data) values = data.in;
|
||||
// else values = data.notIn;
|
||||
|
||||
// return filter.argsToOptions(values, options);
|
||||
// },
|
||||
// applyAdd: (data, option) => {
|
||||
// if ('in' in data) data.in = [...new Set([...data.in, option.value])];
|
||||
// else data.notIn = [...new Set([...data.notIn, option.value])];
|
||||
|
||||
// return data;
|
||||
// },
|
||||
// applyRemove: (data, option) => {
|
||||
// if ('in' in data) {
|
||||
// data.in = data.in.filter((id) => id !== option.value);
|
||||
|
||||
// if (data.in.length === 0) return;
|
||||
// } else {
|
||||
// data.notIn = data.notIn.filter((id) => id !== option.value);
|
||||
|
||||
// if (data.notIn.length === 0) return;
|
||||
// }
|
||||
|
||||
// return data;
|
||||
// },
|
||||
// merge: (left, right) => {
|
||||
// if ('in' in left && 'in' in right) {
|
||||
// return {
|
||||
// in: [...new Set([...left.in, ...right.in])]
|
||||
// };
|
||||
// } else if ('notIn' in left && 'notIn' in right) {
|
||||
// return {
|
||||
// notIn: [...new Set([...left.notIn, ...right.notIn])]
|
||||
// };
|
||||
// }
|
||||
|
||||
// throw new Error('Cannot merge InOrNotIns with different conditions');
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
// function createTextMatchFilter(
|
||||
// filter: Omit<
|
||||
// ReturnType<typeof createFilter<any, TextMatch>>,
|
||||
// | 'conditions'
|
||||
// | 'getCondition'
|
||||
// | 'argsToOptions'
|
||||
// | 'setCondition'
|
||||
// | 'applyAdd'
|
||||
// | 'applyRemove'
|
||||
// | 'create'
|
||||
// | 'merge'
|
||||
// > & {
|
||||
// create(value: TextMatch): SearchFilterArgs;
|
||||
// }
|
||||
// ): ReturnType<typeof createFilter<(typeof filterTypeCondition)['textMatch'], TextMatch>> {
|
||||
// return {
|
||||
// ...filter,
|
||||
// conditions: filterTypeCondition.textMatch,
|
||||
// create: (contains) => filter.create({ contains }),
|
||||
// getCondition: (data) => {
|
||||
// if ('contains' in data) return 'contains';
|
||||
// else if ('startsWith' in data) return 'startsWith';
|
||||
// else if ('endsWith' in data) return 'endsWith';
|
||||
// else return 'equals';
|
||||
// },
|
||||
// setCondition: (data, condition) => {
|
||||
// let value: string;
|
||||
|
||||
// if ('contains' in data) value = data.contains;
|
||||
// else if ('startsWith' in data) value = data.startsWith;
|
||||
// else if ('endsWith' in data) value = data.endsWith;
|
||||
// else value = data.equals;
|
||||
|
||||
// return {
|
||||
// [condition]: value
|
||||
// };
|
||||
// },
|
||||
// argsToOptions: (data) => {
|
||||
// let value: string;
|
||||
|
||||
// if ('contains' in data) value = data.contains;
|
||||
// else if ('startsWith' in data) value = data.startsWith;
|
||||
// else if ('endsWith' in data) value = data.endsWith;
|
||||
// else value = data.equals;
|
||||
|
||||
// return [
|
||||
// {
|
||||
// type: filter.name,
|
||||
// name: value,
|
||||
// value
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// applyAdd: (data, { value }) => {
|
||||
// if ('contains' in data) return { contains: value };
|
||||
// else if ('startsWith' in data) return { startsWith: value };
|
||||
// else if ('endsWith' in data) return { endsWith: value };
|
||||
// else if ('equals' in data) return { equals: value };
|
||||
// },
|
||||
// applyRemove: () => undefined,
|
||||
// merge: (left) => left
|
||||
// };
|
||||
// }
|
||||
|
||||
// function createBooleanFilter(
|
||||
// filter: Omit<
|
||||
// ReturnType<typeof createFilter<any, boolean>>,
|
||||
// | 'conditions'
|
||||
// | 'getCondition'
|
||||
// | 'argsToOptions'
|
||||
// | 'setCondition'
|
||||
// | 'applyAdd'
|
||||
// | 'applyRemove'
|
||||
// | 'create'
|
||||
// | 'merge'
|
||||
// > & {
|
||||
// create(value: boolean): SearchFilterArgs;
|
||||
// }
|
||||
// ): ReturnType<typeof createFilter<(typeof filterTypeCondition)['trueOrFalse'], boolean>> {
|
||||
// return {
|
||||
// ...filter,
|
||||
// conditions: filterTypeCondition.trueOrFalse,
|
||||
// create: () => filter.create(true),
|
||||
// getCondition: (data) => (data ? 'true' : 'false'),
|
||||
// setCondition: (_, condition) => condition === 'true',
|
||||
// argsToOptions: (value) => {
|
||||
// if (!value) return [];
|
||||
|
||||
// return [
|
||||
// {
|
||||
// type: filter.name,
|
||||
// name: filter.name,
|
||||
// value
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// applyAdd: (_, { value }) => value,
|
||||
// applyRemove: () => undefined,
|
||||
// merge: (left) => left
|
||||
// };
|
||||
// }
|
||||
|
||||
// function createRangeFilter<T>(
|
||||
// filter: Omit<
|
||||
// ReturnType<typeof createFilter<any, Range<T>>>,
|
||||
// | 'conditions'
|
||||
// | 'getCondition'
|
||||
// | 'argsToOptions'
|
||||
// | 'setCondition'
|
||||
// | 'applyAdd'
|
||||
// | 'applyRemove'
|
||||
// | 'create'
|
||||
// | 'merge'
|
||||
// > & {
|
||||
// create(value: Range<T>): SearchFilterArgs;
|
||||
// argsToOptions(values: T[], options: Map<string, FilterOption[]>): FilterOption[];
|
||||
// }
|
||||
// ): ReturnType<typeof createFilter<(typeof filterTypeCondition)['range'], Range<T>>> {
|
||||
// return {
|
||||
// ...filter,
|
||||
// conditions: filterTypeCondition.range,
|
||||
// create: (data) => {
|
||||
// if ('from' in data) {
|
||||
// return filter.create({ from: data.from });
|
||||
// } else if ('to' in data) {
|
||||
// return filter.create({ to: data.to });
|
||||
// } else {
|
||||
// throw new Error('Invalid Range data');
|
||||
// }
|
||||
// },
|
||||
// getCondition: (data) => {
|
||||
// if ('from' in data) return 'from';
|
||||
// else if ('to' in data) return 'to';
|
||||
// else throw new Error('Invalid Range data');
|
||||
// },
|
||||
// setCondition: (data, condition) => {
|
||||
// return condition === 'from' && 'from' in data
|
||||
// ? { from: data.from }
|
||||
// : condition === 'to' && 'to' in data
|
||||
// ? { to: data.to }
|
||||
// : (() => {
|
||||
// throw new Error('Invalid condition or missing data');
|
||||
// })();
|
||||
// },
|
||||
// argsToOptions: (data, options) => {
|
||||
// const values: T[] = [];
|
||||
// if ('from' in data) values.push(data.from);
|
||||
// if ('to' in data) values.push(data.to);
|
||||
|
||||
// return values.map((value) => ({
|
||||
// type: filter.name,
|
||||
// name: String(value),
|
||||
// value
|
||||
// }));
|
||||
// },
|
||||
// applyAdd: (data, option) => {
|
||||
// if ('from' in data) {
|
||||
// data.from = option.value;
|
||||
// } else if ('to' in data) {
|
||||
// data.to = option.value;
|
||||
// } else {
|
||||
// throw new Error('Invalid Range data');
|
||||
// }
|
||||
// return data;
|
||||
// },
|
||||
// applyRemove: (data, option): Range<T> | undefined => {
|
||||
// if ('from' in data && data.from === option.value) {
|
||||
// const { from, ...rest } = data; // Omit `from`
|
||||
// return Object.keys(rest).length ? (rest as Range<T>) : undefined;
|
||||
// } else if ('to' in data && data.to === option.value) {
|
||||
// const { to, ...rest } = data; // Omit `to`
|
||||
// return Object.keys(rest).length ? (rest as Range<T>) : undefined;
|
||||
// }
|
||||
|
||||
// return data;
|
||||
// },
|
||||
// merge: (left, right): Range<T> => {
|
||||
// const result = {
|
||||
// ...('from' in left
|
||||
// ? { from: left.from }
|
||||
// : 'from' in right
|
||||
// ? { from: right.from }
|
||||
// : {}),
|
||||
// ...('to' in left ? { to: left.to } : 'to' in right ? { to: right.to } : {})
|
||||
// };
|
||||
|
||||
// return result as Range<T>;
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
// function createGenericRangeFilter<T>(
|
||||
// name: string,
|
||||
// translationKey: string,
|
||||
// icon: Icon,
|
||||
// extractFn: (arg: SearchFilterArgs) => Range<T> | undefined,
|
||||
// createFn: (range: Range<T>) => SearchFilterArgs
|
||||
// ): ReturnType<typeof createFilter<(typeof filterTypeCondition)['range'], Range<T>>> {
|
||||
// return createRangeFilter({
|
||||
// name,
|
||||
// translationKey,
|
||||
// icon,
|
||||
// extract: extractFn,
|
||||
// create: createFn,
|
||||
// Render: ({ filter, options, search }) => (
|
||||
// <FilterOptionList filter={filter} options={options} search={search} />
|
||||
// ),
|
||||
// useOptions: (): FilterOption[] => {
|
||||
// // Predefined date range options, or you can make it dynamic based on type T
|
||||
// return [
|
||||
// {
|
||||
// name: 'Last 7 Days',
|
||||
// value: { from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
// icon: Calendar
|
||||
// },
|
||||
// {
|
||||
// name: 'Last 30 Days',
|
||||
// value: { from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
// icon: Calendar
|
||||
// },
|
||||
// {
|
||||
// name: 'This Year',
|
||||
// value: { from: new Date(new Date().getFullYear(), 0, 1).toISOString() },
|
||||
// icon: Calendar
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// argsToOptions: (values: T[], options: Map<string, FilterOption[]>): FilterOption[] => {
|
||||
// return values.map((value) => ({
|
||||
// type: name,
|
||||
// name: String(value),
|
||||
// value,
|
||||
// icon: Calendar
|
||||
// }));
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// const filterRegistry = [
|
||||
// createGenericRangeFilter(
|
||||
// i18n.t('date_created_range'),
|
||||
// 'date_created_range',
|
||||
// Calendar,
|
||||
// // extract
|
||||
// (arg) => {
|
||||
// if ('filePath' in arg && 'date_created' in arg.filePath) {
|
||||
// return {
|
||||
// from: arg.filePath.date_created,
|
||||
// to: arg.filePath.date_created
|
||||
// } as Range<string>;
|
||||
// }
|
||||
// },
|
||||
// // create
|
||||
// (dateRange: Range<string>) => {
|
||||
// return {
|
||||
// filePath: {
|
||||
// createdAt: {
|
||||
// from: 'from' in dateRange ? dateRange.from : undefined,
|
||||
// to: 'to' in dateRange ? dateRange.to : undefined
|
||||
// }
|
||||
// }
|
||||
// } as SearchFilterArgs;
|
||||
// }
|
||||
// ),
|
||||
// createGenericRangeFilter(
|
||||
// i18n.t('date_accessed_range'),
|
||||
// 'date_accessed_range',
|
||||
// Calendar,
|
||||
// // extract
|
||||
// (arg) => {
|
||||
// if ('object' in arg && 'date_accessed' in arg.object) {
|
||||
// return {
|
||||
// from: arg.object.date_accessed,
|
||||
// to: arg.object.date_accessed
|
||||
// } as Range<string>;
|
||||
// }
|
||||
// },
|
||||
// // create
|
||||
// (dateRange: Range<string>) => {
|
||||
// return {
|
||||
// object: {
|
||||
// dateAccessed: {
|
||||
// from: 'from' in dateRange ? dateRange.from : undefined,
|
||||
// to: 'to' in dateRange ? dateRange.to : undefined
|
||||
// }
|
||||
// }
|
||||
// } as SearchFilterArgs;
|
||||
// }
|
||||
// ),
|
||||
// createInOrNotInFilter({
|
||||
// name: i18n.t('location'),
|
||||
// translationKey: 'location',
|
||||
// icon: Folder, // Phosphor folder icon
|
||||
// extract: (arg) => {
|
||||
// if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
|
||||
// },
|
||||
// create: (locations) => ({ filePath: { locations } }),
|
||||
// argsToOptions(values, options) {
|
||||
// return values
|
||||
// .map((value) => {
|
||||
// const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
// if (!option) return;
|
||||
|
||||
// return {
|
||||
// ...option,
|
||||
// type: this.name
|
||||
// };
|
||||
// })
|
||||
// .filter(Boolean) as any;
|
||||
// },
|
||||
// useOptions: () => {
|
||||
// const query = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
// const locations = query.data;
|
||||
|
||||
// return (locations ?? []).map((location) => ({
|
||||
// name: location.name!,
|
||||
// value: location.id,
|
||||
// icon: 'Folder' // Spacedrive folder icon
|
||||
// }));
|
||||
// },
|
||||
// Render: ({ filter, options, search }) => (
|
||||
// <FilterOptionList filter={filter} options={options} search={search} />
|
||||
// )
|
||||
// }),
|
||||
// createInOrNotInFilter({
|
||||
// name: i18n.t('tags'),
|
||||
// translationKey: 'tag',
|
||||
// icon: CircleDashed,
|
||||
// extract: (arg) => {
|
||||
// if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
|
||||
// },
|
||||
// create: (tags) => ({ object: { tags } }),
|
||||
// argsToOptions(values, options) {
|
||||
// return values
|
||||
// .map((value) => {
|
||||
// const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
// if (!option) return;
|
||||
|
||||
// return {
|
||||
// ...option,
|
||||
// type: this.name
|
||||
// };
|
||||
// })
|
||||
// .filter(Boolean) as any;
|
||||
// },
|
||||
// useOptions: () => {
|
||||
// const query = useLibraryQuery(['tags.list']);
|
||||
// const tags = query.data;
|
||||
// return (tags ?? []).map((tag) => ({
|
||||
// name: tag.name!,
|
||||
// value: tag.id,
|
||||
// icon: tag.color || 'CircleDashed'
|
||||
// }));
|
||||
// },
|
||||
// Render: ({ filter, options, search }) => {
|
||||
// return (
|
||||
// <FilterOptionList
|
||||
// empty={() => (
|
||||
// <div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
// <SDIcon name="Tags" size={32} />
|
||||
// <p className="w-4/5 text-center text-xs text-ink-dull">
|
||||
// {i18n.t('no_tags')}
|
||||
// </p>
|
||||
// </div>
|
||||
// )}
|
||||
// filter={filter}
|
||||
// options={options}
|
||||
// search={search}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
// }),
|
||||
// createInOrNotInFilter({
|
||||
// name: i18n.t('kind'),
|
||||
// translationKey: 'kind',
|
||||
// icon: Cube,
|
||||
// extract: (arg) => {
|
||||
// if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
|
||||
// },
|
||||
// create: (kind) => ({ object: { kind } }),
|
||||
// argsToOptions(values, options) {
|
||||
// return values
|
||||
// .map((value) => {
|
||||
// const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
// if (!option) return;
|
||||
|
||||
// return {
|
||||
// ...option,
|
||||
// type: this.name
|
||||
// };
|
||||
// })
|
||||
// .filter(Boolean) as any;
|
||||
// },
|
||||
// useOptions: () =>
|
||||
// Object.keys(ObjectKind)
|
||||
// .filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined)
|
||||
// .map((key) => {
|
||||
// const kind = ObjectKind[Number(key)] as string;
|
||||
// return {
|
||||
// name: translateKindName(kind),
|
||||
// value: Number(key),
|
||||
// icon: kind + '20'
|
||||
// };
|
||||
// }),
|
||||
// Render: ({ filter, options, search }) => (
|
||||
// <FilterOptionList filter={filter} options={options} search={search} />
|
||||
// )
|
||||
// }),
|
||||
// createTextMatchFilter({
|
||||
// name: i18n.t('name'),
|
||||
// translationKey: 'name',
|
||||
// icon: Textbox,
|
||||
// extract: (arg) => {
|
||||
// if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
|
||||
// },
|
||||
// create: (name) => ({ filePath: { name } }),
|
||||
// useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
// Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
// }),
|
||||
// createInOrNotInFilter({
|
||||
// name: i18n.t('extension'),
|
||||
// translationKey: 'extension',
|
||||
// icon: Textbox,
|
||||
// extract: (arg) => {
|
||||
// if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
|
||||
// },
|
||||
// create: (extension) => ({ filePath: { extension } }),
|
||||
// argsToOptions(values) {
|
||||
// return values.map((value) => ({
|
||||
// type: this.name,
|
||||
// name: value,
|
||||
// value
|
||||
// }));
|
||||
// },
|
||||
// useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }],
|
||||
// Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
// }),
|
||||
// createBooleanFilter({
|
||||
// name: i18n.t('hidden'),
|
||||
// translationKey: 'hidden',
|
||||
// icon: SelectionSlash,
|
||||
// extract: (arg) => {
|
||||
// if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
|
||||
// },
|
||||
// create: (hidden) => ({ filePath: { hidden } }),
|
||||
// useOptions: () => {
|
||||
// return [
|
||||
// {
|
||||
// name: 'Hidden',
|
||||
// value: true,
|
||||
// icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
// }),
|
||||
// createBooleanFilter({
|
||||
// name: i18n.t('favorite'),
|
||||
// translationKey: 'favorite',
|
||||
// icon: Heart,
|
||||
// extract: (arg) => {
|
||||
// if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite;
|
||||
// },
|
||||
// create: (favorite) => ({ object: { favorite } }),
|
||||
// useOptions: () => {
|
||||
// return [
|
||||
// {
|
||||
// name: 'Favorite',
|
||||
// value: true,
|
||||
// icon: 'Heart' // Spacedrive folder icon
|
||||
// }
|
||||
// ];
|
||||
// },
|
||||
// Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
// })
|
||||
// // createInOrNotInFilter({
|
||||
// // name: i18n.t('label'),
|
||||
// // icon: Tag,
|
||||
// // extract: (arg) => {
|
||||
// // if ('object' in arg && 'labels' in arg.object) return arg.object.labels;
|
||||
// // },
|
||||
// // create: (labels) => ({ object: { labels } }),
|
||||
// // argsToOptions(values, options) {
|
||||
// // return values
|
||||
// // .map((value) => {
|
||||
// // const option = options.get(this.name)?.find((o) => o.value === value);
|
||||
|
||||
// // if (!option) return;
|
||||
|
||||
// // return {
|
||||
// // ...option,
|
||||
// // type: this.name
|
||||
// // };
|
||||
// // })
|
||||
// // .filter(Boolean) as any;
|
||||
// // },
|
||||
// // useOptions: () => {
|
||||
// // const query = useLibraryQuery(['labels.list']);
|
||||
|
||||
// // return (query.data ?? []).map((label) => ({
|
||||
// // name: label.name!,
|
||||
// // value: label.id
|
||||
// // }));
|
||||
// // },
|
||||
// // Render: ({ filter, options, search }) => (
|
||||
// // <FilterOptionList filter={filter} options={options} search={search} />
|
||||
// // )
|
||||
// // })
|
||||
// // idk how to handle this rn since include_descendants is part of 'path' now
|
||||
// //
|
||||
// // createFilter({
|
||||
// // name: i18n.t('with_descendants'),
|
||||
// // icon: SelectionSlash,
|
||||
// // conditions: filterTypeCondition.trueOrFalse,
|
||||
// // setCondition: (args, condition: 'true' | 'false') => {
|
||||
// // const filePath = (args.filePath ??= {});
|
||||
|
||||
// // filePath.withDescendants = condition === 'true';
|
||||
// // },
|
||||
// // applyAdd: () => {},
|
||||
// // applyRemove: (args) => {
|
||||
// // delete args.filePath?.withDescendants;
|
||||
// // },
|
||||
// // useOptions: () => {
|
||||
// // return [
|
||||
// // {
|
||||
// // name: 'With Descendants',
|
||||
// // value: true,
|
||||
// // icon: 'SelectionSlash' // Spacedrive folder icon
|
||||
// // }
|
||||
// // ];
|
||||
// // },
|
||||
// // Render: ({ filter }) => {
|
||||
// // return <FilterOptionBoolean filter={filter} />;
|
||||
// // },
|
||||
// // apply(filter, args) {
|
||||
// // (args.filePath ??= {}).withDescendants = filter.condition;
|
||||
// // }
|
||||
// // })
|
||||
// ] as const satisfies ReadonlyArray<RenderSearchFilter<any>>;
|
||||
|
||||
// type FilterType = (typeof filterRegistry)[number]['name'];
|
||||
@@ -19,15 +19,15 @@ import {
|
||||
import { useIsDark, useKeybind, useLocale, useShortcut } from '~/hooks';
|
||||
|
||||
import { getQuickPreviewStore, useQuickPreviewStore } from '../Explorer/QuickPreview/store';
|
||||
import { AppliedFilters, InteractiveSection } from './AppliedFilters';
|
||||
import { useSearchContext } from './context';
|
||||
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters';
|
||||
import { AppliedFilters, InteractiveSection } from './Filters/components/AppliedFilters';
|
||||
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters/index';
|
||||
import {
|
||||
getSearchStore,
|
||||
useRegisterSearchFilterOptions,
|
||||
useSearchRegisteredFilters,
|
||||
useSearchStore
|
||||
} from './store';
|
||||
useFilterOptionStore,
|
||||
useRegisterFilterOptions,
|
||||
useSearchRegisteredFilters
|
||||
} from './Filters/store';
|
||||
import { getSearchStore, useSearchStore } from './store';
|
||||
import { UseSearch } from './useSearch';
|
||||
import { RenderIcon } from './util';
|
||||
|
||||
@@ -209,7 +209,7 @@ const SearchResults = memo(
|
||||
|
||||
function AddFilterButton() {
|
||||
const search = useSearchContext();
|
||||
const searchState = useSearchStore();
|
||||
const filterStore = useFilterOptionStore();
|
||||
|
||||
const [searchQuery, setSearch] = useState('');
|
||||
|
||||
@@ -261,7 +261,7 @@ function AddFilterButton() {
|
||||
<filter.Render
|
||||
key={filter.name}
|
||||
filter={filter as any}
|
||||
options={searchState.filterOptions.get(filter.name)!}
|
||||
options={filterStore.filterOptions.get(filter.name)!}
|
||||
search={search}
|
||||
/>
|
||||
))
|
||||
@@ -373,7 +373,7 @@ function RegisterSearchFilterOptions(props: {
|
||||
}) {
|
||||
const options = props.filter.useOptions({ search: props.searchQuery });
|
||||
|
||||
useRegisterSearchFilterOptions(
|
||||
useRegisterFilterOptions(
|
||||
props.filter,
|
||||
useMemo(
|
||||
() => options.map((o) => ({ ...o, type: props.filter.name })),
|
||||
|
||||
@@ -1,98 +1,27 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Icon } from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { proxy, ref, useSnapshot } from 'valtio';
|
||||
import { proxyMap } from 'valtio/utils';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import { filterRegistry, FilterType, RenderSearchFilter } from './Filters';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
export type SearchType = 'paths' | 'objects';
|
||||
|
||||
export type SearchScope = 'directory' | 'location' | 'device' | 'library';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string | any;
|
||||
name: string;
|
||||
icon?: string | Icon; // "Folder" or "#efefef"
|
||||
}
|
||||
|
||||
export interface FilterOptionWithType extends FilterOption {
|
||||
type: FilterType;
|
||||
}
|
||||
|
||||
export type AllKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
const searchStore = proxy({
|
||||
interactingWithSearchOptions: false,
|
||||
searchType: 'paths' as SearchType,
|
||||
filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
|
||||
// we register filters so we can search them
|
||||
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
|
||||
searchQuery: '' // Search query to track user input
|
||||
// Any other search-specific state can go here
|
||||
});
|
||||
|
||||
// this makes the filter unique and easily searchable using .includes
|
||||
export const getKey = (filter: FilterOptionWithType) =>
|
||||
`${filter.type}-${filter.name}-${filter.value}`;
|
||||
|
||||
// this hook allows us to register filters to the search store
|
||||
// and returns the filters with the correct type
|
||||
export const useRegisterSearchFilterOptions = (
|
||||
filter: RenderSearchFilter,
|
||||
options: (FilterOption & { type: FilterType })[]
|
||||
) => {
|
||||
const optionsAsKeys = useMemo(() => options.map(getKey), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
searchStore.filterOptions.set(filter.name, options);
|
||||
searchStore.filterOptions = ref(new Map(searchStore.filterOptions));
|
||||
}, [optionsAsKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = options.map((option) => {
|
||||
const key = getKey(option);
|
||||
|
||||
if (!searchStore.registeredFilters.has(key)) {
|
||||
searchStore.registeredFilters.set(key, option);
|
||||
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
return () =>
|
||||
keys.forEach((key) => {
|
||||
if (key) searchStore.registeredFilters.delete(key);
|
||||
});
|
||||
}, [optionsAsKeys]);
|
||||
};
|
||||
|
||||
export function argsToOptions(args: SearchFilterArgs[], options: Map<string, FilterOption[]>) {
|
||||
return args.flatMap((fixedArg) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(fixedArg));
|
||||
if (!filter) return [];
|
||||
|
||||
return filter
|
||||
.argsToOptions(filter.extract(fixedArg) as any, options)
|
||||
.map((arg) => ({ arg, filter }));
|
||||
});
|
||||
}
|
||||
|
||||
export const useSearchRegisteredFilters = (query: string) => {
|
||||
const { registeredFilters } = useSearchStore();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
!query
|
||||
? []
|
||||
: [...registeredFilters.entries()]
|
||||
.filter(([key, _]) => key.toLowerCase().includes(query.toLowerCase()))
|
||||
.map(([key, filter]) => ({ ...filter, key })),
|
||||
[registeredFilters, query]
|
||||
);
|
||||
};
|
||||
|
||||
export const resetSearchStore = () => {};
|
||||
|
||||
// Hook to interact with the search store
|
||||
export const useSearchStore = () => useSnapshot(searchStore);
|
||||
|
||||
// Function to set the search query
|
||||
export const setSearchQuery = (query: string) => {
|
||||
searchStore.searchQuery = query;
|
||||
};
|
||||
|
||||
// Function to reset search state (if needed)
|
||||
export const resetSearchStore = () => {
|
||||
searchStore.interactingWithSearchOptions = false;
|
||||
searchStore.searchQuery = '';
|
||||
};
|
||||
|
||||
// Function to retrieve the search store directly
|
||||
export const getSearchStore = () => searchStore;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSearchParams as useRawSearchParams } from 'react-router-dom';
|
||||
import { useDebouncedValue } from 'rooks';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import { argsToOptions, getKey, useSearchStore } from './store';
|
||||
import { argsToFilterOptions, getKey, useFilterOptionStore } from './Filters/store';
|
||||
|
||||
export type SearchTarget = 'paths' | 'objects';
|
||||
|
||||
@@ -120,11 +120,11 @@ export function useSearch<TSource extends UseSearchSource>(props: UseSearchProps
|
||||
|
||||
const [searchBarFocused, setSearchBarFocused] = useState(false);
|
||||
|
||||
const searchState = useSearchStore();
|
||||
const filterStore = useFilterOptionStore();
|
||||
|
||||
const filtersAsOptions = useMemo(
|
||||
() => argsToOptions(filters ?? [], searchState.filterOptions),
|
||||
[filters, searchState.filterOptions]
|
||||
() => argsToFilterOptions(filters ?? [], filterStore.filterOptions),
|
||||
[filters, filterStore.filterOptions]
|
||||
);
|
||||
|
||||
const filtersKeys: Set<string> = useMemo(() => {
|
||||
@@ -140,7 +140,6 @@ export function useSearch<TSource extends UseSearchSource>(props: UseSearchProps
|
||||
}, [filtersAsOptions]);
|
||||
|
||||
// Merging of filters that should be ORed
|
||||
|
||||
const mergedFilters = useMemo(
|
||||
() => filters?.map((arg, removalIndex) => ({ arg, removalIndex })),
|
||||
[filters]
|
||||
@@ -166,8 +165,8 @@ export function useSearch<TSource extends UseSearchSource>(props: UseSearchProps
|
||||
);
|
||||
|
||||
const allFiltersAsOptions = useMemo(
|
||||
() => argsToOptions(allFilters, searchState.filterOptions),
|
||||
[searchState.filterOptions, allFilters]
|
||||
() => argsToFilterOptions(allFilters, filterStore.filterOptions),
|
||||
[filterStore.filterOptions, allFilters]
|
||||
);
|
||||
|
||||
const allFiltersKeys: Set<string> = useMemo(() => {
|
||||
|
||||
@@ -4,29 +4,6 @@ import clsx from 'clsx';
|
||||
import i18n from '~/app/I18n';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
|
||||
export const filterTypeCondition = {
|
||||
inOrNotIn: {
|
||||
in: i18n.t('is'),
|
||||
notIn: i18n.t('is_not')
|
||||
},
|
||||
textMatch: {
|
||||
contains: i18n.t('contains'),
|
||||
startsWith: i18n.t('starts_with'),
|
||||
endsWith: i18n.t('ends_with'),
|
||||
equals: i18n.t('equals')
|
||||
},
|
||||
optionalRange: {
|
||||
from: i18n.t('from'),
|
||||
to: i18n.t('to')
|
||||
},
|
||||
trueOrFalse: {
|
||||
true: i18n.t('is'),
|
||||
false: i18n.t('is_not')
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type FilterTypeCondition = typeof filterTypeCondition;
|
||||
|
||||
export const RenderIcon = ({
|
||||
className,
|
||||
icon
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-cmdk": "^1.3.9",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-date-picker": "^11.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-force-graph-2d": "^1.25.5",
|
||||
@@ -86,4 +87,4 @@
|
||||
"vite": "^5.4.9",
|
||||
"vite-plugin-svgr": "^3.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/assets/icons/Document_srt.png
Normal file
BIN
packages/assets/icons/Document_srt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
2
packages/assets/icons/index.ts
generated
2
packages/assets/icons/index.ts
generated
@@ -47,6 +47,7 @@ import Document_doc from './Document_doc.png';
|
||||
import Document_Light from './Document_Light.png';
|
||||
import Document_pdf_Light from './Document_pdf_Light.png';
|
||||
import Document_pdf from './Document_pdf.png';
|
||||
import Document_srt from './Document_srt.png';
|
||||
import Document_xls_Light from './Document_xls_Light.png';
|
||||
import Document_xls from './Document_xls.png';
|
||||
import Document_xmp from './Document_xmp.png';
|
||||
@@ -242,6 +243,7 @@ export {
|
||||
Document_doc_Light,
|
||||
Document_pdf,
|
||||
Document_pdf_Light,
|
||||
Document_srt,
|
||||
Document_xls,
|
||||
Document_xls_Light,
|
||||
Document_xmp,
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user