This commit is contained in:
James Pine
2025-01-03 17:56:43 -08:00
30 changed files with 2153 additions and 845 deletions

View File

@@ -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}

View File

@@ -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'];

View 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'];

View File

@@ -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>
</>
)}

View File

@@ -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>
);
};

View File

@@ -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>
// );
// };

View File

@@ -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>
);
})}
</>
);
};

View File

@@ -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
};
}

View File

@@ -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>;
}
};
}

View File

@@ -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');
}
};
}

View File

@@ -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
};
}

View File

@@ -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;
});
};
}

View 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;

View File

@@ -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} />
});

View 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;

View File

@@ -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>
)
});

View File

@@ -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>
)
});

View File

@@ -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>
)
});

View File

@@ -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 }) => <></>
});

View 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;

View 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'
>;

View 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 filters 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'];

View File

@@ -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 })),

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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"
}
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -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
View File

Binary file not shown.