From 506e6c19d15c65582f2d8170bdc9c127e715b073 Mon Sep 17 00:00:00 2001 From: jamie Date: Sat, 21 Sep 2024 21:55:39 -0700 Subject: [PATCH 01/31] refactored Filters.tsx and designed date range API --- interface/app/$libraryId/search/Filters.tsx | 1561 ++++++++++------- .../search/Filters/FilterRegistry.tsx | 48 + .../components}/AppliedFilters.tsx | 12 +- .../components/FilterOptionBoolean.tsx | 43 + .../Filters/components/FilterOptionList.tsx | 51 + .../Filters/components/FilterOptionRange.tsx | 0 .../Filters/components/FilterOptionText.tsx | 57 + .../Filters/factories/createBooleanFilter.ts | 42 + .../factories/createInOrNotInFilter.ts | 81 + .../Filters/factories/createRangeFilter.ts | 93 + .../factories/createTextMatchFilter.ts | 59 + .../Filters/hooks/useToggleOptionSelected.tsx | 36 + .../app/$libraryId/search/Filters/index.tsx | 123 ++ .../$libraryId/search/Filters/typeGuards.ts | 78 + .../app/$libraryId/search/SearchOptions.tsx | 4 +- interface/app/$libraryId/search/store.tsx | 11 +- interface/app/$libraryId/search/useSearch.ts | 6 +- interface/app/$libraryId/search/util.tsx | 23 - 18 files changed, 1618 insertions(+), 710 deletions(-) create mode 100644 interface/app/$libraryId/search/Filters/FilterRegistry.tsx rename interface/app/$libraryId/search/{ => Filters/components}/AppliedFilters.tsx (93%) create mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx create mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx create mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx create mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx create mode 100644 interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts create mode 100644 interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts create mode 100644 interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts create mode 100644 interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts create mode 100644 interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx create mode 100644 interface/app/$libraryId/search/Filters/index.tsx create mode 100644 interface/app/$libraryId/search/Filters/typeGuards.ts diff --git a/interface/app/$libraryId/search/Filters.tsx b/interface/app/$libraryId/search/Filters.tsx index f9e12a55bcc3..6cbad09fc8b8 100644 --- a/interface/app/$libraryId/search/Filters.tsx +++ b/interface/app/$libraryId/search/Filters.tsx @@ -1,671 +1,890 @@ -import { - CircleDashed, - Cube, - Folder, - Heart, - Icon, - SelectionSlash, - Tag, - Textbox -} from '@phosphor-icons/react'; -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 { - getCondition: (args: T) => AllKeys; - 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) => 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 { - // Render is responsible for fetching the filter options and rendering them - Render: (props: { - filter: SearchFilterCRUD; - options: (FilterOption & { type: string })[]; - search: UseSearch; - }) => JSX.Element; - // Apply is responsible for applying the filter to the search args - useOptions: (props: { search: string }) => FilterOption[]; -} - -export function useToggleOptionSelected({ search }: { search: UseSearch }) { - 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; - 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 ( - { - toggleOptionSelected({ - filter, - option, - select: value - }); - }} - key={option.value} - icon={option.icon} - > - {option.name} - - ); - })} - - ); -}; - -const FilterOptionText = ({ - filter, - search -}: { - filter: SearchFilterCRUD; - search: UseSearch; -}) => { - const [value, setValue] = useState(''); - - const { allFiltersKeys } = search; - const key = getKey({ - type: filter.name, - name: value, - value - }); - - const { t } = useLocale(); - - return ( - -
{ - e.preventDefault(); - search.setFilters?.((filters) => { - if (allFiltersKeys.has(key)) return filters; - - const arg = filter.create(value); - filters?.push(arg); - setValue(''); - - return filters; - }); - }} - > - setValue(e.target.value)} /> - -
-
- ); -}; - -const FilterOptionBoolean = ({ - filter, - search -}: { - filter: SearchFilterCRUD; - search: UseSearch; -}) => { - const { allFiltersKeys } = search; - - const key = getKey({ - type: filter.name, - name: filter.name, - value: true - }); - - return ( - { - 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} - - ); -}; - -function createFilter( - filter: RenderSearchFilter -) { - return filter; -} - -function createInOrNotInFilter( - filter: Omit< - ReturnType>>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: InOrNotIn): SearchFilterArgs; - argsToOptions(values: T[], options: Map): FilterOption[]; - } -): ReturnType>> { - 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>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: TextMatch): SearchFilterArgs; - } -): ReturnType> { - 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>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: boolean): SearchFilterArgs; - } -): ReturnType> { - 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'], { 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 }) => ( - - ) - }), - 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 ( - ( -
- -

- {i18n.t('no_tags')} -

-
- )} - 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 }) => ( - - ) - }), - 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 }) => - }), - 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 }) => - }), - 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 }) => - }), - 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 }) => - }) - // 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 }) => ( - // - // ) - // }) - // 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 ; - // }, - // apply(filter, args) { - // (args.filePath ??= {}).withDescendants = filter.condition; - // } - // }) -] as const satisfies ReadonlyArray>; - -export type FilterType = (typeof filterRegistry)[number]['name']; +// 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 './Filters'; +// import { AllKeys, FilterOption, getKey } from './store'; +// import { UseSearch } from './useSearch'; + +// 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, // 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 { +// // 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; + +// // 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) => 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; +// } + +// export interface RenderSearchFilter< +// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, +// T = any +// > extends SearchFilterCRUD { +// // Render is responsible for fetching the filter options and rendering them +// Render: (props: { +// filter: SearchFilterCRUD; +// options: (FilterOption & { type: string })[]; +// search: UseSearch; +// }) => JSX.Element; +// // Apply is responsible for applying the filter to the search args +// useOptions: (props: { search: string }) => FilterOption[]; +// } + +// export function useToggleOptionSelected({ search }: { search: UseSearch }) { +// 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; +// 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 ( +// { +// toggleOptionSelected({ +// filter, +// option, +// select: value +// }); +// }} +// key={option.value} +// icon={option.icon} +// > +// {option.name} +// +// ); +// })} +// +// ); +// }; + +// const FilterOptionText = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const [value, setValue] = useState(''); + +// const { allFiltersKeys } = search; +// const key = getKey({ +// type: filter.name, +// name: value, +// value +// }); + +// const { t } = useLocale(); + +// return ( +// +//
{ +// e.preventDefault(); +// search.setFilters?.((filters) => { +// if (allFiltersKeys.has(key)) return filters; + +// const arg = filter.create(value); +// filters?.push(arg); +// setValue(''); + +// return filters; +// }); +// }} +// > +// setValue(e.target.value)} /> +// +//
+//
+// ); +// }; + +// const FilterOptionBoolean = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const { allFiltersKeys } = search; + +// const key = getKey({ +// type: filter.name, +// name: filter.name, +// value: true +// }); + +// return ( +// { +// 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} +// +// ); +// }; + +// function createFilter( +// filter: RenderSearchFilter +// ) { +// return filter; +// } + +// function createInOrNotInFilter( +// filter: Omit< +// ReturnType>>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: InOrNotIn): SearchFilterArgs; +// argsToOptions(values: T[], options: Map): FilterOption[]; +// } +// ): ReturnType>> { +// 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>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: TextMatch): SearchFilterArgs; +// } +// ): ReturnType> { +// 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>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: boolean): SearchFilterArgs; +// } +// ): ReturnType> { +// 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( +// filter: Omit< +// ReturnType>>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: Range): SearchFilterArgs; +// argsToOptions(values: T[], options: Map): FilterOption[]; +// } +// ): ReturnType>> { +// 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 | undefined => { +// if ('from' in data && data.from === option.value) { +// const { from, ...rest } = data; // Omit `from` +// return Object.keys(rest).length ? (rest as Range) : undefined; +// } else if ('to' in data && data.to === option.value) { +// const { to, ...rest } = data; // Omit `to` +// return Object.keys(rest).length ? (rest as Range) : undefined; +// } + +// return data; +// }, +// merge: (left, right): Range => { +// 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; +// } +// }; +// } + +// function createGenericRangeFilter( +// name: string, +// translationKey: string, +// icon: Icon, +// extractFn: (arg: SearchFilterArgs) => Range | undefined, +// createFn: (range: Range) => SearchFilterArgs +// ): ReturnType>> { +// return createRangeFilter({ +// name, +// translationKey, +// icon, +// extract: extractFn, +// create: createFn, +// Render: ({ filter, options, 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): FilterOption[] => { +// return values.map((value) => ({ +// type: name, +// name: String(value), +// value, +// icon: Calendar +// })); +// } +// }); +// } + +// export 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; +// } +// }, +// // create +// (dateRange: Range) => { +// 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; +// } +// }, +// // create +// (dateRange: Range) => { +// 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 }) => ( +// +// ) +// }), +// 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 ( +// ( +//
+// +//

+// {i18n.t('no_tags')} +//

+//
+// )} +// 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 }) => ( +// +// ) +// }), +// 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 }) => +// }), +// 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 }) => +// }), +// 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 }) => +// }), +// 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 }) => +// }) +// // 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 }) => ( +// // +// // ) +// // }) +// // 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 ; +// // }, +// // apply(filter, args) { +// // (args.filePath ??= {}).withDescendants = filter.condition; +// // } +// // }) +// ] as const satisfies ReadonlyArray>; + +// export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx new file mode 100644 index 000000000000..bfbf7aac1939 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx @@ -0,0 +1,48 @@ +// Import icons +import { Folder } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; + +import { RenderSearchFilter } from '.'; +import i18n from '../../../I18n'; +import { FilterOptionList } from './components/FilterOptionList'; +import { createInOrNotInFilter } from './factories/createInOrNotInFilter'; + +// Range Filters +export const filterRegistry = [ + createInOrNotInFilter({ + 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 }) => ( + + ) + }) +] as const satisfies ReadonlyArray>; + +export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/AppliedFilters.tsx b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx similarity index 93% rename from interface/app/$libraryId/search/AppliedFilters.tsx rename to interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx index 2a70412dafed..660fe8167e0d 100644 --- a/interface/app/$libraryId/search/AppliedFilters.tsx +++ b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx @@ -4,11 +4,11 @@ import { SearchFilterArgs } from '@sd/client'; import { 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 { useSearchContext } from '../..'; +import HorizontalScroll from '../../../overview/Layout/HorizontalScroll'; +import { filterRegistry } from '../../Filters/index'; +import { useSearchStore } from '../../store'; +import { RenderIcon } from '../../util'; export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden shrink-0 h-6`; @@ -81,7 +81,7 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: 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 ); diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx new file mode 100644 index 000000000000..37bfd487a3f0 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx @@ -0,0 +1,43 @@ +import { SearchFilterCRUD } from '..'; +import { SearchOptionItem } from '../../SearchOptions'; +import { getKey } from '../../store'; +import { UseSearch } from '../../useSearch'; + +export const FilterOptionBoolean = ({ + filter, + search +}: { + filter: SearchFilterCRUD; + search: UseSearch; +}) => { + const { allFiltersKeys } = search; + + const key = getKey({ + type: filter.name, + name: filter.name, + value: true + }); + + return ( + { + 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} + + ); +}; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx new file mode 100644 index 000000000000..d7c1252972df --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx @@ -0,0 +1,51 @@ +import { SearchFilterCRUD } from '..'; +import { SearchOptionItem, SearchOptionSubMenu } from '../../SearchOptions'; +import { FilterOption, getKey } from '../../store'; +import { UseSearch } from '../../useSearch'; +import { useToggleOptionSelected } from '../hooks/useToggleOptionSelected'; + +export const FilterOptionList = ({ + filter, + options, + search, + empty +}: { + filter: SearchFilterCRUD; + options: FilterOption[]; + search: UseSearch; + 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 ( + { + toggleOptionSelected({ + filter, + option, + select: value + }); + }} + key={option.value} + icon={option.icon} + > + {option.name} + + ); + })} + + ); +}; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx new file mode 100644 index 000000000000..660965e21f8a --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Button, Input } from '@sd/ui'; +import { useLocale } from '~/hooks'; + +import { SearchFilterCRUD } from '..'; +import { SearchOptionSubMenu } from '../../SearchOptions'; +import { getKey } from '../../store'; +import { UseSearch } from '../../useSearch'; + +export const FilterOptionText = ({ + filter, + search +}: { + filter: SearchFilterCRUD; + search: UseSearch; +}) => { + const [value, setValue] = useState(''); + + const { allFiltersKeys } = search; + const key = getKey({ + type: filter.name, + name: value, + value + }); + + const { t } = useLocale(); + + return ( + +
{ + e.preventDefault(); + search.setFilters?.((filters) => { + if (allFiltersKeys.has(key)) return filters; + + const arg = filter.create(value); + filters?.push(arg); + setValue(''); + + return filters; + }); + }} + > + setValue(e.target.value)} /> + +
+
+ ); +}; diff --git a/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts new file mode 100644 index 000000000000..eef9d8f7153f --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts @@ -0,0 +1,42 @@ +import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..'; + +/** + * 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 +): ReturnType> { + 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 + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts b/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts new file mode 100644 index 000000000000..abe81a23a80b --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts @@ -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( + filter: CreateFilterFunction> +): ReturnType>> { + 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'); + } + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts b/interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts new file mode 100644 index 000000000000..8b2963527eb3 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts @@ -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 createRangeFilter( + filter: CreateFilterFunction> +): ReturnType>> { + 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 | undefined => { + if ('from' in data && data.from === option.value) { + const { from, ...rest } = data; // Omit `from` + return Object.keys(rest).length ? (rest as Range) : undefined; + } else if ('to' in data && data.to === option.value) { + const { to, ...rest } = data; // Omit `to` + return Object.keys(rest).length ? (rest as Range) : undefined; + } + + return data; + }, + + merge: (left, right): Range => { + 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; + } + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts b/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts new file mode 100644 index 000000000000..cf5baa7202e8 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts @@ -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 +): ReturnType> { + 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 + }; +} diff --git a/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx new file mode 100644 index 000000000000..85e20528deb6 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx @@ -0,0 +1,36 @@ +import { SearchFilterCRUD } from '..'; +import { FilterOption } from '../../store'; +import { UseSearch } from '../../useSearch'; + +export function useToggleOptionSelected({ search }: { search: UseSearch }) { + 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; + }); + }; +} diff --git a/interface/app/$libraryId/search/Filters/index.tsx b/interface/app/$libraryId/search/Filters/index.tsx new file mode 100644 index 000000000000..17209ffbb5fc --- /dev/null +++ b/interface/app/$libraryId/search/Filters/index.tsx @@ -0,0 +1,123 @@ +/** + * This module defines an abstraction layer for search filters, reducing redundancy and improving scalability. + * + * 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`, `Range`, `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. + * + * To handle these dynamic conditions, we use TypeScript generics and utility types (like `OmitCommonFilterProperties`) + * to abstract away boilerplate code, making the creation of new filters easier and more maintainable. + * + * 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, ensuring consistency across different filters. + * + * This system allows the easy addition of new filters without repeating logic, keeping the code DRY and extensible. + */ + +import { Icon } from '@phosphor-icons/react'; +import { SearchFilterArgs } from '@sd/client'; + +import i18n from '../../../I18n'; +import { AllKeys, FilterOption } from '../store'; +import { UseSearch } from '../useSearch'; +import { OmitCommonFilterProperties } from './typeGuards'; + +export { filterRegistry, FilterType } from './FilterRegistry'; + +export { 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 { + getCondition: (args: T) => AllKeys; // 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) => 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 { + Render: (props: { + filter: SearchFilterCRUD; + options: (FilterOption & { type: string })[]; + search: UseSearch; + }) => JSX.Element; + useOptions: (props: { search: string }) => FilterOption[]; +} + +// Factory function to create filters dynamically +export function createFilter( + filter: RenderSearchFilter +) { + return filter; +} + +// Interface for filters that handle the `create` method +export interface FilterWithCreate { + create: (value: Value) => SearchFilterArgs; + argsToFilterOptions?: (values: T[], options: Map) => FilterOption[]; +} + +// Generic type for creating filters +// export type CreateFilterFunction< +// Conditions extends { readonly from: string; readonly to: string }, +// Value +// > = OmitCommonFilterProperties>> & +// FilterWithCreate; + +// General factory type for creating filters +// General factory type for creating filters +export type CreateFilterFunction< + Conditions extends FilterTypeCondition[keyof FilterTypeCondition], + Value +> = OmitCommonFilterProperties>> & + FilterWithCreate; + +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; diff --git a/interface/app/$libraryId/search/Filters/typeGuards.ts b/interface/app/$libraryId/search/Filters/typeGuards.ts new file mode 100644 index 000000000000..a440776d6fb5 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/typeGuards.ts @@ -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 } } { + 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 } } { + 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 object with from and to values, or undefined if not found. + */ +export function extractFilePathRange( + arg: SearchFilterArgs, + field: 'createdAt' | 'modifiedAt' | 'indexedAt' +): Range | 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 object with from and to values, or undefined if not found. + */ +export function extractObjectRange( + arg: SearchFilterArgs, + field: 'dateAccessed' +): Range | 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 = Omit< + T, + | 'conditions' + | 'getCondition' + | 'argsToFilterOptions' + | 'setCondition' + | 'applyAdd' + | 'applyRemove' + | 'create' + | 'merge' +>; diff --git a/interface/app/$libraryId/search/SearchOptions.tsx b/interface/app/$libraryId/search/SearchOptions.tsx index 5f1276818a7d..991de794349e 100644 --- a/interface/app/$libraryId/search/SearchOptions.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -19,9 +19,9 @@ 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, diff --git a/interface/app/$libraryId/search/store.tsx b/interface/app/$libraryId/search/store.tsx index e05cc8ab3027..0d933fbc3a8a 100644 --- a/interface/app/$libraryId/search/store.tsx +++ b/interface/app/$libraryId/search/store.tsx @@ -5,12 +5,10 @@ import { proxy, ref, useSnapshot } from 'valtio'; import { proxyMap } from 'valtio/utils'; import { SearchFilterArgs } from '@sd/client'; -import { filterRegistry, FilterType, RenderSearchFilter } from './Filters'; +import { filterRegistry, FilterType, RenderSearchFilter } from './Filters/index'; export type SearchType = 'paths' | 'objects'; -export type SearchScope = 'directory' | 'location' | 'device' | 'library'; - export interface FilterOption { value: string | any; name: string; @@ -66,13 +64,16 @@ export const useRegisterSearchFilterOptions = ( }, [optionsAsKeys]); }; -export function argsToOptions(args: SearchFilterArgs[], options: Map) { +export function argsToFilterOptions( + args: SearchFilterArgs[], + options: Map +) { return args.flatMap((fixedArg) => { const filter = filterRegistry.find((f) => f.extract(fixedArg)); if (!filter) return []; return filter - .argsToOptions(filter.extract(fixedArg) as any, options) + .argsToFilterOptions(filter.extract(fixedArg) as any, options) .map((arg) => ({ arg, filter })); }); } diff --git a/interface/app/$libraryId/search/useSearch.ts b/interface/app/$libraryId/search/useSearch.ts index 9b7b29996b33..1982d8baa494 100644 --- a/interface/app/$libraryId/search/useSearch.ts +++ b/interface/app/$libraryId/search/useSearch.ts @@ -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, useSearchStore } from './store'; export type SearchTarget = 'paths' | 'objects'; @@ -123,7 +123,7 @@ export function useSearch(props: UseSearchProps const searchState = useSearchStore(); const filtersAsOptions = useMemo( - () => argsToOptions(filters ?? [], searchState.filterOptions), + () => argsToFilterOptions(filters ?? [], searchState.filterOptions), [filters, searchState.filterOptions] ); @@ -166,7 +166,7 @@ export function useSearch(props: UseSearchProps ); const allFiltersAsOptions = useMemo( - () => argsToOptions(allFilters, searchState.filterOptions), + () => argsToFilterOptions(allFilters, searchState.filterOptions), [searchState.filterOptions, allFilters] ); diff --git a/interface/app/$libraryId/search/util.tsx b/interface/app/$libraryId/search/util.tsx index 3bf56d1e77cf..27a6f514292b 100644 --- a/interface/app/$libraryId/search/util.tsx +++ b/interface/app/$libraryId/search/util.tsx @@ -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 From ca6d5efc43efc8bcbcd17e7d40ba0c46d77d7fb0 Mon Sep 17 00:00:00 2001 From: jamie Date: Sun, 22 Sep 2024 00:50:58 -0700 Subject: [PATCH 02/31] progress on implementation --- .../search/Filters/FilterRegistry.tsx | 58 +++-------- .../{Filters.tsx => Filters/FiltersOld.tsx} | 14 +-- .../Filters/components/AppliedFilters.tsx | 6 +- .../components/FilterOptionBoolean.tsx | 2 +- .../components/FilterOptionDateRange.tsx | 45 +++++++++ .../Filters/components/FilterOptionList.tsx | 2 +- .../Filters/components/FilterOptionRange.tsx | 0 .../Filters/components/FilterOptionText.tsx | 2 +- ...angeFilter.ts => createDateRangeFilter.ts} | 2 +- .../Filters/hooks/useToggleOptionSelected.tsx | 2 +- .../app/$libraryId/search/Filters/index.tsx | 12 +-- .../search/Filters/registry/DateFilters.tsx | 58 +++++++++++ .../search/Filters/registry/KindFilter.tsx | 43 ++++++++ .../Filters/registry/LocationFilter.tsx | 42 ++++++++ .../search/Filters/registry/TagsFilter.tsx | 51 ++++++++++ .../search/Filters/registry/TextFilters.tsx | 34 +++++++ .../app/$libraryId/search/Filters/store.ts | 97 ++++++++++++++++++ .../app/$libraryId/search/SearchOptions.tsx | 16 +-- interface/app/$libraryId/search/store.tsx | 98 +++---------------- interface/app/$libraryId/search/useSearch.ts | 13 ++- 20 files changed, 432 insertions(+), 165 deletions(-) rename interface/app/$libraryId/search/{Filters.tsx => Filters/FiltersOld.tsx} (98%) create mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx delete mode 100644 interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx rename interface/app/$libraryId/search/Filters/factories/{createRangeFilter.ts => createDateRangeFilter.ts} (97%) create mode 100644 interface/app/$libraryId/search/Filters/registry/DateFilters.tsx create mode 100644 interface/app/$libraryId/search/Filters/registry/KindFilter.tsx create mode 100644 interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx create mode 100644 interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx create mode 100644 interface/app/$libraryId/search/Filters/registry/TextFilters.tsx create mode 100644 interface/app/$libraryId/search/Filters/store.ts diff --git a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx index bfbf7aac1939..59b809e2ad02 100644 --- a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx +++ b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx @@ -1,48 +1,18 @@ -// Import icons -import { Folder } from '@phosphor-icons/react'; -import { useLibraryQuery } from '@sd/client'; - import { RenderSearchFilter } from '.'; -import i18n from '../../../I18n'; -import { FilterOptionList } from './components/FilterOptionList'; -import { createInOrNotInFilter } from './factories/createInOrNotInFilter'; - -// Range Filters -export const filterRegistry = [ - createInOrNotInFilter({ - 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; +import { filePathDateCreated } from './registry/DateFilters'; +import { kindFilter } from './registry/KindFilter'; +import { locationFilter } from './registry/LocationFilter'; +import { tagsFilter } from './registry/TagsFilter'; +import { extensionFilter, nameFilter } from './registry/TextFilters'; - return (locations ?? []).map((location) => ({ - name: location.name!, - value: location.id, - icon: 'Folder' - })); - }, - Render: ({ filter, options, search }) => ( - - ) - }) -] as const satisfies ReadonlyArray>; +export const filterRegistry: ReadonlyArray> = [ + // Put filters here + locationFilter, + filePathDateCreated, + tagsFilter, + kindFilter, + nameFilter, + extensionFilter +] as const; export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/Filters.tsx b/interface/app/$libraryId/search/Filters/FiltersOld.tsx similarity index 98% rename from interface/app/$libraryId/search/Filters.tsx rename to interface/app/$libraryId/search/Filters/FiltersOld.tsx index 6cbad09fc8b8..77229ecd02dc 100644 --- a/interface/app/$libraryId/search/Filters.tsx +++ b/interface/app/$libraryId/search/Filters/FiltersOld.tsx @@ -24,11 +24,11 @@ // import { SearchOptionItem, SearchOptionSubMenu } from '.'; // import { translateKindName } from '../Explorer/util'; -// import { FilterTypeCondition, filterTypeCondition } from './Filters'; +// import { FilterTypeCondition, filterTypeCondition } from './FiltersOld'; // import { AllKeys, FilterOption, getKey } from './store'; // import { UseSearch } from './useSearch'; -// export interface SearchFilter< +// interface SearchFilter< // TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any // > { // name: string; @@ -37,7 +37,7 @@ // translationKey?: string; // } -// export interface SearchFilterCRUD< +// 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 { @@ -77,7 +77,7 @@ // merge: (left: T, right: T) => T; // } -// export interface RenderSearchFilter< +// interface RenderSearchFilter< // TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, // T = any // > extends SearchFilterCRUD { @@ -91,7 +91,7 @@ // useOptions: (props: { search: string }) => FilterOption[]; // } -// export function useToggleOptionSelected({ search }: { search: UseSearch }) { +// function useToggleOptionSelected({ search }: { search: UseSearch }) { // return ({ // filter, // option, @@ -581,7 +581,7 @@ // }); // } -// export const filterRegistry = [ +// const filterRegistry = [ // createGenericRangeFilter( // i18n.t('date_created_range'), // 'date_created_range', @@ -887,4 +887,4 @@ // // }) // ] as const satisfies ReadonlyArray>; -// export type FilterType = (typeof filterRegistry)[number]['name']; +// type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx index 660fe8167e0d..1894627e3462 100644 --- a/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx +++ b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx @@ -7,8 +7,8 @@ import { useLocale } from '~/hooks'; import { useSearchContext } from '../..'; import HorizontalScroll from '../../../overview/Layout/HorizontalScroll'; import { filterRegistry } from '../../Filters/index'; -import { useSearchStore } from '../../store'; import { RenderIcon } from '../../util'; +import { useFilterOptionStore } from '../store'; export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden shrink-0 h-6`; @@ -75,7 +75,7 @@ export const AppliedFilters = () => { }; export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: () => void }) { - const searchStore = useSearchStore(); + const filterStore = useFilterOptionStore(); const { t } = useLocale(); const filter = filterRegistry.find((f) => f.extract(arg)); @@ -83,7 +83,7 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: const activeOptions = filter.argsToFilterOptions( filter.extract(arg)! as any, - searchStore.filterOptions + filterStore.filterOptions ); function isFilterDescriptionDisplayed() { diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx index 37bfd487a3f0..979a668647b0 100644 --- a/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx @@ -1,7 +1,7 @@ import { SearchFilterCRUD } from '..'; import { SearchOptionItem } from '../../SearchOptions'; -import { getKey } from '../../store'; import { UseSearch } from '../../useSearch'; +import { getKey } from '../store'; export const FilterOptionBoolean = ({ filter, diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx new file mode 100644 index 000000000000..8ebb23c3e1d9 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx @@ -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; +}) => { + const { allFiltersKeys } = search; + + const key = getKey({ + type: filter.name, + name: filter.name, + value: { start: new Date(), end: new Date() } // Example default range + }); + + return ( + { + 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} + + ); +}; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx index d7c1252972df..21e46d2a5017 100644 --- a/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx @@ -1,8 +1,8 @@ import { SearchFilterCRUD } from '..'; import { SearchOptionItem, SearchOptionSubMenu } from '../../SearchOptions'; -import { FilterOption, getKey } from '../../store'; import { UseSearch } from '../../useSearch'; import { useToggleOptionSelected } from '../hooks/useToggleOptionSelected'; +import { FilterOption, getKey } from '../store'; export const FilterOptionList = ({ filter, diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionRange.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx index 660965e21f8a..6b35e494b954 100644 --- a/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx @@ -4,8 +4,8 @@ import { useLocale } from '~/hooks'; import { SearchFilterCRUD } from '..'; import { SearchOptionSubMenu } from '../../SearchOptions'; -import { getKey } from '../../store'; import { UseSearch } from '../../useSearch'; +import { getKey } from '../store'; export const FilterOptionText = ({ filter, diff --git a/interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts b/interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts similarity index 97% rename from interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts rename to interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts index 8b2963527eb3..0d74f0356ba5 100644 --- a/interface/app/$libraryId/search/Filters/factories/createRangeFilter.ts +++ b/interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts @@ -9,7 +9,7 @@ import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCond * @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 createRangeFilter( +export function createDateRangeFilter( filter: CreateFilterFunction> ): ReturnType>> { return { diff --git a/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx index 85e20528deb6..65764af14c15 100644 --- a/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx +++ b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx @@ -1,5 +1,5 @@ import { SearchFilterCRUD } from '..'; -import { FilterOption } from '../../store'; +import { FilterOption } from '../'; import { UseSearch } from '../../useSearch'; export function useToggleOptionSelected({ search }: { search: UseSearch }) { diff --git a/interface/app/$libraryId/search/Filters/index.tsx b/interface/app/$libraryId/search/Filters/index.tsx index 17209ffbb5fc..f456899b2aaf 100644 --- a/interface/app/$libraryId/search/Filters/index.tsx +++ b/interface/app/$libraryId/search/Filters/index.tsx @@ -1,8 +1,8 @@ /** - * This module defines an abstraction layer for search filters, reducing redundancy and improving scalability. + * This module defines an abstraction layer for search filters. * * 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`, `Range`, `InOrNotIn`) and + * 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. * @@ -21,15 +21,15 @@ import { Icon } from '@phosphor-icons/react'; import { SearchFilterArgs } from '@sd/client'; +import i18n from '~/app/I18n'; -import i18n from '../../../I18n'; -import { AllKeys, FilterOption } from '../store'; import { UseSearch } from '../useSearch'; +import { AllKeys, type FilterOption } from './store'; import { OmitCommonFilterProperties } from './typeGuards'; -export { filterRegistry, FilterType } from './FilterRegistry'; +export { filterRegistry, type FilterType } from './FilterRegistry'; -export { FilterOption }; +export type { FilterOption }; export { useToggleOptionSelected } from './hooks/useToggleOptionSelected'; diff --git a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx new file mode 100644 index 000000000000..c80d515eed96 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx @@ -0,0 +1,58 @@ +import type {} from '@sd/client'; // required for type inference of createDateRangeFilter + +import { Calendar } from '@phosphor-icons/react'; +import i18n from '~/app/I18n'; + +import { FilterOption } from '..'; +import { FilterOptionList } from '../components/FilterOptionList'; +import { createDateRangeFilter } from '../factories/createDateRangeFilter'; + +export const useCommonDateOptions = (): FilterOption[] => { + return [ + { + name: i18n.t('Last 7 Days'), + value: { from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() }, + icon: Calendar + }, + { + name: i18n.t('Last 30 Days'), + value: { from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() }, + icon: Calendar + }, + { + name: i18n.t('This Year'), + value: { from: new Date(new Date().getFullYear(), 0, 1).toISOString() }, + icon: Calendar + } + ]; +}; + +export const filePathDateCreated = createDateRangeFilter({ + name: i18n.t('Date Created'), + translationKey: 'dateCreated', + icon: Calendar, + 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: value, + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + ) +}); + +// export const filePathDateModified = createDateRangeFilter({}); +// export const filePathDateAccessed = createDateRangeFilter({}); +// export const objectDateAccessed = createDateRangeFilter({}); + +// export const dateFilters = [ +// filePathDateCreated, +// filePathDateModified, +// filePathDateAccessed +// ] as const; diff --git a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx new file mode 100644 index 000000000000..b42bd936b28a --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx @@ -0,0 +1,43 @@ +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 { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const kindFilter = 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 } }), + 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: i18n.t(kind), // Assuming translations for kinds + value: Number(key), + icon: Cube // You can customize this based on the kind if needed + }; + }), + Render: ({ filter, options, search }) => ( + + ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx new file mode 100644 index 000000000000..f6bad1539020 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx @@ -0,0 +1,42 @@ +// Import icons +import { Folder } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; +import i18n from '~/app/I18n'; + +import { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const locationFilter = createInOrNotInFilter({ + 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 }) => ( + + ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx new file mode 100644 index 000000000000..7cffad8e548e --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx @@ -0,0 +1,51 @@ +import { CircleDashed } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; +import i18n from '~/app/I18n'; + +import { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const tagsFilter = 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 } }), + 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 }) => ( + ( +
+ +

{i18n.t('no_tags')}

+
+ )} + filter={filter} + options={options} + search={search} + /> + ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx new file mode 100644 index 000000000000..eeeacc7c00d2 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx @@ -0,0 +1,34 @@ +import type {} from '@sd/client'; // required for type inference of createDateRangeFilter + +import { Textbox } from '@phosphor-icons/react'; +import i18n from '~/app/I18n'; + +import { FilterOptionText } from '../components/FilterOptionText'; +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 }) => +}); diff --git a/interface/app/$libraryId/search/Filters/store.ts b/interface/app/$libraryId/search/Filters/store.ts new file mode 100644 index 000000000000..29312fa0c2fc --- /dev/null +++ b/interface/app/$libraryId/search/Filters/store.ts @@ -0,0 +1,97 @@ +/* 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'; + +// Define filter option interface +export interface FilterOption { + value: string | Range | any; + name: string; + icon?: string | Icon; +} + +export interface FilterOptionWithType extends FilterOption { + type: FilterType; +} + +const filterOptionStore = proxy({ + filterOptions: ref(new Map()), + registeredFilters: proxyMap() as Map +}); + +// 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 +) { + 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 extends any ? keyof T : never; diff --git a/interface/app/$libraryId/search/SearchOptions.tsx b/interface/app/$libraryId/search/SearchOptions.tsx index 991de794349e..9629dd594c7f 100644 --- a/interface/app/$libraryId/search/SearchOptions.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -23,11 +23,11 @@ import { useSearchContext } from './context'; 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() { )) @@ -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 })), diff --git a/interface/app/$libraryId/search/store.tsx b/interface/app/$libraryId/search/store.tsx index 0d933fbc3a8a..3ce79da6e5a7 100644 --- a/interface/app/$libraryId/search/store.tsx +++ b/interface/app/$libraryId/search/store.tsx @@ -1,99 +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/index'; +import { proxy, useSnapshot } from 'valtio'; export type SearchType = 'paths' | 'objects'; -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 extends any ? keyof T : never; - const searchStore = proxy({ interactingWithSearchOptions: false, searchType: 'paths' as SearchType, - filterOptions: ref(new Map()), - // we register filters so we can search them - registeredFilters: proxyMap() as Map + 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; - } - }); +// Hook to interact with the search store +export const useSearchStore = () => useSnapshot(searchStore); - return () => - keys.forEach((key) => { - if (key) searchStore.registeredFilters.delete(key); - }); - }, [optionsAsKeys]); +// Function to set the search query +export const setSearchQuery = (query: string) => { + searchStore.searchQuery = query; }; -export function argsToFilterOptions( - args: SearchFilterArgs[], - options: Map -) { - 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 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] - ); +// Function to reset search state (if needed) +export const resetSearchStore = () => { + searchStore.interactingWithSearchOptions = false; + searchStore.searchQuery = ''; }; -export const resetSearchStore = () => {}; - -export const useSearchStore = () => useSnapshot(searchStore); - +// Function to retrieve the search store directly export const getSearchStore = () => searchStore; diff --git a/interface/app/$libraryId/search/useSearch.ts b/interface/app/$libraryId/search/useSearch.ts index 1982d8baa494..a72e2f5e88c5 100644 --- a/interface/app/$libraryId/search/useSearch.ts +++ b/interface/app/$libraryId/search/useSearch.ts @@ -4,7 +4,7 @@ import { useSearchParams as useRawSearchParams } from 'react-router-dom'; import { useDebouncedValue } from 'rooks'; import { SearchFilterArgs } from '@sd/client'; -import { argsToFilterOptions, getKey, useSearchStore } from './store'; +import { argsToFilterOptions, getKey, useFilterOptionStore } from './Filters/store'; export type SearchTarget = 'paths' | 'objects'; @@ -120,11 +120,11 @@ export function useSearch(props: UseSearchProps const [searchBarFocused, setSearchBarFocused] = useState(false); - const searchState = useSearchStore(); + const filterStore = useFilterOptionStore(); const filtersAsOptions = useMemo( - () => argsToFilterOptions(filters ?? [], searchState.filterOptions), - [filters, searchState.filterOptions] + () => argsToFilterOptions(filters ?? [], filterStore.filterOptions), + [filters, filterStore.filterOptions] ); const filtersKeys: Set = useMemo(() => { @@ -140,7 +140,6 @@ export function useSearch(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(props: UseSearchProps ); const allFiltersAsOptions = useMemo( - () => argsToFilterOptions(allFilters, searchState.filterOptions), - [searchState.filterOptions, allFilters] + () => argsToFilterOptions(allFilters, filterStore.filterOptions), + [filterStore.filterOptions, allFilters] ); const allFiltersKeys: Set = useMemo(() => { From 5941d639cfbd24d9992f9f775ea22e6ef00c769f Mon Sep 17 00:00:00 2001 From: jamie Date: Sun, 22 Sep 2024 02:03:23 -0700 Subject: [PATCH 03/31] completed implementing previous filters --- .../search/Filters/FilterRegistry.tsx | 17 +++- .../Filters/factories/createBooleanFilter.ts | 1 + .../app/$libraryId/search/Filters/index.tsx | 19 +--- .../Filters/registry/BooleanFilters.tsx | 31 +++++++ .../search/Filters/registry/DateFilters.tsx | 89 +++++++++++++++++-- .../search/Filters/registry/KindFilter.tsx | 5 +- .../app/$libraryId/search/Filters/store.ts | 2 + .../search/{Filters => }/FiltersOld.tsx | 0 8 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx rename interface/app/$libraryId/search/{Filters => }/FiltersOld.tsx (100%) diff --git a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx index 59b809e2ad02..ef849addf555 100644 --- a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx +++ b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx @@ -1,5 +1,11 @@ import { RenderSearchFilter } from '.'; -import { filePathDateCreated } from './registry/DateFilters'; +import { favoriteFilter, hiddenFilter } from './registry/BooleanFilters'; +import { + filePathDateCreated, + filePathDateIndexed, + filePathDateModified, + objectDateAccessed +} from './registry/DateFilters'; import { kindFilter } from './registry/KindFilter'; import { locationFilter } from './registry/LocationFilter'; import { tagsFilter } from './registry/TagsFilter'; @@ -8,11 +14,16 @@ import { extensionFilter, nameFilter } from './registry/TextFilters'; export const filterRegistry: ReadonlyArray> = [ // Put filters here locationFilter, - filePathDateCreated, tagsFilter, kindFilter, nameFilter, - extensionFilter + extensionFilter, + filePathDateCreated, + filePathDateModified, + objectDateAccessed, + filePathDateIndexed, + favoriteFilter, + hiddenFilter ] as const; export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts index eef9d8f7153f..cc0dee3cfb6d 100644 --- a/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts +++ b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts @@ -1,5 +1,6 @@ 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. diff --git a/interface/app/$libraryId/search/Filters/index.tsx b/interface/app/$libraryId/search/Filters/index.tsx index f456899b2aaf..76c3f3e06f05 100644 --- a/interface/app/$libraryId/search/Filters/index.tsx +++ b/interface/app/$libraryId/search/Filters/index.tsx @@ -1,24 +1,21 @@ /** - * This module defines an abstraction layer for search filters. + * 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. * - * To handle these dynamic conditions, we use TypeScript generics and utility types (like `OmitCommonFilterProperties`) - * to abstract away boilerplate code, making the creation of new filters easier and more maintainable. - * * 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, ensuring consistency across different filters. + * - `CreateFilterFunction`: A utility type for defining the structure of filter factories. * - * This system allows the easy addition of new filters without repeating logic, keeping the code DRY and extensible. + * 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'; @@ -84,14 +81,6 @@ export interface FilterWithCreate { argsToFilterOptions?: (values: T[], options: Map) => FilterOption[]; } -// Generic type for creating filters -// export type CreateFilterFunction< -// Conditions extends { readonly from: string; readonly to: string }, -// Value -// > = OmitCommonFilterProperties>> & -// FilterWithCreate; - -// General factory type for creating filters // General factory type for creating filters export type CreateFilterFunction< Conditions extends FilterTypeCondition[keyof FilterTypeCondition], diff --git a/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx new file mode 100644 index 000000000000..f7c15f0f09a4 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx @@ -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, 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, search }) => +}); diff --git a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx index c80d515eed96..1748e1eb6869 100644 --- a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx +++ b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx @@ -1,6 +1,13 @@ import type {} from '@sd/client'; // required for type inference of createDateRangeFilter -import { Calendar } from '@phosphor-icons/react'; +import { + Calendar, + CalendarDot, + CalendarDots, + CalendarPlus, + CalendarStar, + ClockCounterClockwise +} from '@phosphor-icons/react'; import i18n from '~/app/I18n'; import { FilterOption } from '..'; @@ -10,13 +17,25 @@ import { createDateRangeFilter } from '../factories/createDateRangeFilter'; export const useCommonDateOptions = (): FilterOption[] => { return [ { - name: i18n.t('Last 7 Days'), + 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: Calendar + icon: ClockCounterClockwise }, { - name: i18n.t('Last 30 Days'), + 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 }, { @@ -30,7 +49,7 @@ export const useCommonDateOptions = (): FilterOption[] => { export const filePathDateCreated = createDateRangeFilter({ name: i18n.t('Date Created'), translationKey: 'dateCreated', - icon: Calendar, + icon: CalendarStar, create: (dateRange) => ({ filePath: { createdAt: dateRange } }), extract: (arg) => { if ('filePath' in arg && 'createdAt' in arg.filePath) return arg.filePath.createdAt; @@ -47,6 +66,66 @@ export const filePathDateCreated = createDateRangeFilter({ ) }); +export const filePathDateModified = createDateRangeFilter({ + 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 }) => ( + + ) +}); + +export const filePathDateIndexed = createDateRangeFilter({ + 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 }) => ( + + ) +}); + +export const objectDateAccessed = createDateRangeFilter({ + 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 }) => ( + + ) +}); + // export const filePathDateModified = createDateRangeFilter({}); // export const filePathDateAccessed = createDateRangeFilter({}); // export const objectDateAccessed = createDateRangeFilter({}); diff --git a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx index b42bd936b28a..99adb2af8ea7 100644 --- a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx +++ b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx @@ -2,6 +2,7 @@ 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 { FilterOptionList } from '../components/FilterOptionList'; import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; @@ -32,9 +33,9 @@ export const kindFilter = createInOrNotInFilter({ .map((key) => { const kind = ObjectKind[Number(key)] as string; return { - name: i18n.t(kind), // Assuming translations for kinds + name: translateKindName(kind), value: Number(key), - icon: Cube // You can customize this based on the kind if needed + icon: kind + '20' }; }), Render: ({ filter, options, search }) => ( diff --git a/interface/app/$libraryId/search/Filters/store.ts b/interface/app/$libraryId/search/Filters/store.ts index 29312fa0c2fc..496ea75884d3 100644 --- a/interface/app/$libraryId/search/Filters/store.ts +++ b/interface/app/$libraryId/search/Filters/store.ts @@ -8,6 +8,8 @@ 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 { value: string | Range | any; diff --git a/interface/app/$libraryId/search/Filters/FiltersOld.tsx b/interface/app/$libraryId/search/FiltersOld.tsx similarity index 100% rename from interface/app/$libraryId/search/Filters/FiltersOld.tsx rename to interface/app/$libraryId/search/FiltersOld.tsx From 295b71653deba92c517e2641ddf6f1f19fe51bdd Mon Sep 17 00:00:00 2001 From: jamie Date: Sun, 22 Sep 2024 16:18:07 -0700 Subject: [PATCH 04/31] tweaks wip --- .../search/Filters/FilterRegistry.tsx | 2 + .../Filters/components/AppliedFilters.tsx | 9 +-- .../components/FilterOptionDateRange.tsx | 78 +++++++++---------- .../Filters/components/FilterOptionText.tsx | 17 +++- .../search/Filters/registry/DateFilters.tsx | 23 +++++- .../app/$libraryId/search/Filters/store.ts | 3 +- 6 files changed, 84 insertions(+), 48 deletions(-) diff --git a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx index ef849addf555..82fed0b1f3bd 100644 --- a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx +++ b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx @@ -4,6 +4,7 @@ import { filePathDateCreated, filePathDateIndexed, filePathDateModified, + mediaDateTaken, objectDateAccessed } from './registry/DateFilters'; import { kindFilter } from './registry/KindFilter'; @@ -22,6 +23,7 @@ export const filterRegistry: ReadonlyArray> = [ filePathDateModified, objectDateAccessed, filePathDateIndexed, + mediaDateTaken, favoriteFilter, hiddenFilter ] as const; diff --git a/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx index 1894627e3462..9c7df4bc5ef1 100644 --- a/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx +++ b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx @@ -33,6 +33,8 @@ export const CloseTab = forwardRef void }>(({ o export const AppliedFilters = () => { const search = useSearchContext(); + // console.log(search.mergedFilters); + return ( <> {search.search && ( @@ -102,10 +104,7 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: {isFilterDescriptionDisplayed() && ( <> - - {/* {Object.entries(filter.conditions).map(([value, displayName]) => ( -
{displayName}
- ))} */} + { (filter.conditions as any)[ filter.getCondition(filter.extract(arg) as any) as any @@ -113,7 +112,7 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: } - + {activeOptions && ( <> {activeOptions.length === 1 ? ( diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx index 8ebb23c3e1d9..d8ef97a71a92 100644 --- a/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx @@ -1,45 +1,45 @@ -import { Range } from '@sd/client'; +// import { Range } from '@sd/client'; -import { SearchFilterCRUD } from '..'; -import { SearchOptionItem } from '../../SearchOptions'; -import { UseSearch } from '../../useSearch'; -import { getKey } from '../store'; +// import { SearchFilterCRUD } from '..'; +// import { SearchOptionItem } from '../../SearchOptions'; +// import { UseSearch } from '../../useSearch'; +// import { getKey } from '../store'; -export const FilterOptionDateRange = ({ - filter, - search -}: { - filter: SearchFilterCRUD; - search: UseSearch; -}) => { - const { allFiltersKeys } = search; +// export const FilterOptionDateRange = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const { allFiltersKeys } = search; - const key = getKey({ - type: filter.name, - name: filter.name, - value: { start: new Date(), end: new Date() } // Example default range - }); +// const key = getKey({ +// type: filter.name, +// name: filter.name, +// value: { start: new Date(), end: new Date() } // Example default range +// }); - return ( - { - search.setFilters?.((filters = []) => { - const index = filters.findIndex((f) => filter.extract(f) !== undefined); +// return ( +// { +// 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); - } +// 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} - - ); -}; +// return filters; +// }); +// }} +// > +// {filter.name} +// +// ); +// }; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx index 6b35e494b954..708e1f8d48e0 100644 --- a/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionText.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Button, Input } from '@sd/ui'; +import { Button, Input, Select } from '@sd/ui'; import { useLocale } from '~/hooks'; -import { SearchFilterCRUD } from '..'; +import { FilterTypeCondition, SearchFilterCRUD } from '..'; import { SearchOptionSubMenu } from '../../SearchOptions'; import { UseSearch } from '../../useSearch'; import { getKey } from '../store'; @@ -15,6 +15,9 @@ export const FilterOptionText = ({ search: UseSearch; }) => { const [value, setValue] = useState(''); + const [matchType, setMatchType] = useState<'contains' | 'startsWith' | 'endsWith' | 'equals'>( + 'contains' + ); const { allFiltersKeys } = search; const key = getKey({ @@ -42,6 +45,16 @@ export const FilterOptionText = ({ }); }} > + setValue(e.target.value)} /> - - - ); -}; diff --git a/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx index f7c15f0f09a4..ebe2b4f924ed 100644 --- a/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx +++ b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx @@ -14,7 +14,7 @@ export const hiddenFilter = createBooleanFilter({ }, create: (hidden) => ({ filePath: { hidden } }), useOptions: () => [{ name: 'Hidden', value: true, icon: SelectionSlash }], - Render: ({ filter, search }) => + Render: ({ filter, options, search }) => }); // Favorite Filter @@ -27,5 +27,5 @@ export const favoriteFilter = createBooleanFilter({ }, create: (favorite) => ({ object: { favorite } }), useOptions: () => [{ name: 'Favorite', value: true, icon: Heart }], - Render: ({ filter, search }) => + Render: ({ filter, options, search }) => }); diff --git a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx index a88e24f50acf..50832d634998 100644 --- a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx +++ b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx @@ -12,6 +12,7 @@ import { import i18n from '~/app/I18n'; import { FilterOption } from '..'; +import { SearchOptionSubMenu } from '../../SearchOptions'; import { FilterOptionList } from '../components/FilterOptionList'; import { createDateRangeFilter } from '../factories/createDateRangeFilter'; @@ -63,7 +64,9 @@ export const filePathDateCreated = createDateRangeFilter({ }, useOptions: (): FilterOption[] => useCommonDateOptions(), Render: ({ filter, options, search }) => ( - + + + ) }); @@ -83,7 +86,9 @@ export const filePathDateModified = createDateRangeFilter({ }, useOptions: (): FilterOption[] => useCommonDateOptions(), Render: ({ filter, options, search }) => ( - + + + ) }); @@ -103,7 +108,9 @@ export const filePathDateIndexed = createDateRangeFilter({ }, useOptions: (): FilterOption[] => useCommonDateOptions(), Render: ({ filter, options, search }) => ( - + + + ) }); @@ -123,7 +130,9 @@ export const objectDateAccessed = createDateRangeFilter({ }, useOptions: (): FilterOption[] => useCommonDateOptions(), Render: ({ filter, options, search }) => ( - + + + ) }); @@ -143,7 +152,9 @@ export const mediaDateTaken = createDateRangeFilter({ }, useOptions: (): FilterOption[] => useCommonDateOptions(), Render: ({ filter, options, search }) => ( - + + + ) }); diff --git a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx index 99adb2af8ea7..457f0f00a48a 100644 --- a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx +++ b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx @@ -3,6 +3,7 @@ import { ObjectKind } from '@sd/client'; // Assuming ObjectKind is an enum or se import i18n from '~/app/I18n'; import { translateKindName } from '../../../Explorer/util'; +import { SearchOptionSubMenu } from '../../SearchOptions'; import { FilterOptionList } from '../components/FilterOptionList'; import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; @@ -39,6 +40,8 @@ export const kindFilter = createInOrNotInFilter({ }; }), Render: ({ filter, options, search }) => ( - + + + ) }); diff --git a/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx index f6bad1539020..e0cf9e57daa4 100644 --- a/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx +++ b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx @@ -3,6 +3,7 @@ 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'; @@ -37,6 +38,8 @@ export const locationFilter = createInOrNotInFilter({ })); }, Render: ({ filter, options, search }) => ( - + + + ) }); diff --git a/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx index 7cffad8e548e..5964ede62c9f 100644 --- a/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx +++ b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx @@ -2,6 +2,7 @@ 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'; @@ -36,16 +37,20 @@ export const tagsFilter = createInOrNotInFilter({ })); }, Render: ({ filter, options, search }) => ( - ( -
- -

{i18n.t('no_tags')}

-
- )} - filter={filter} - options={options} - search={search} - /> + + ( +
+ +

+ {i18n.t('no_tags')} +

+
+ )} + filter={filter} + options={options} + search={search} + /> +
) }); diff --git a/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx index eeeacc7c00d2..dc54fe964a75 100644 --- a/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx +++ b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx @@ -3,7 +3,6 @@ import type {} from '@sd/client'; // required for type inference of createDateRa import { Textbox } from '@phosphor-icons/react'; import i18n from '~/app/I18n'; -import { FilterOptionText } from '../components/FilterOptionText'; import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; import { createTextMatchFilter } from '../factories/createTextMatchFilter'; @@ -17,7 +16,7 @@ export const nameFilter = createTextMatchFilter({ }, create: (name) => ({ filePath: { name } }), useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], - Render: ({ filter, search }) => + Render: ({ filter, search }) => <> }); // Extension Filter @@ -30,5 +29,5 @@ export const extensionFilter = createInOrNotInFilter({ }, create: (extension) => ({ filePath: { extension } }), useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], - Render: ({ filter, search }) => + Render: ({ filter, search }) => <> }); diff --git a/packages/assets/icons/Document_srt.png b/packages/assets/icons/Document_srt.png new file mode 100644 index 0000000000000000000000000000000000000000..2269ef7f061cd6ee180a48e6a7b0c3cb21f74888 GIT binary patch literal 263079 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 WKn>kj9E$+N2cn? Date: Mon, 4 Nov 2024 20:32:01 -0800 Subject: [PATCH 07/31] Add devices module and integrate device management into API; enhance overview with recent items and favorite items features --- core/src/api/devices.rs | 14 ++ core/src/api/mod.rs | 2 + .../Explorer/View/Grid/SimpleGridItem.tsx | 47 ++++ .../Layout/Sidebar/sections/Local/index.tsx | 13 +- .../overview/Layout/HorizontalScroll.tsx | 4 +- .../overview/cards/FavoriteItems.tsx | 27 ++ .../overview/{ => cards}/FileKindStats.tsx | 236 +++++++++--------- .../$libraryId/overview/cards/ItemsCard.tsx | 122 +++++++++ .../overview/{ => cards}/LibraryStats.tsx | 9 +- .../$libraryId/overview/cards/RecentItems.tsx | 27 ++ .../overview/cards/RecentLocationsList.tsx | 98 ++++++++ interface/app/$libraryId/overview/index.tsx | 109 +++----- interface/app/$libraryId/settings/Sidebar.tsx | 54 ++-- .../$libraryId/settings/client/extensions.tsx | 80 ------ .../app/$libraryId/settings/client/index.ts | 2 - .../app/$libraryId/settings/client/usage.tsx | 149 ----------- .../library/{contacts.tsx => clouds.tsx} | 3 +- .../$libraryId/settings/library/devices.tsx | 12 + .../app/$libraryId/settings/library/index.tsx | 10 +- .../app/$libraryId/settings/library/keys.tsx | 12 + .../library/locations/AddLocationDialog.tsx | 14 ++ .../app/$libraryId/settings/library/sync.tsx | 12 + .../app/$libraryId/settings/library/users.tsx | 12 + .../$libraryId/settings/library/volumes.tsx | 0 interface/package.json | 2 +- packages/client/src/core.ts | 21 +- pnpm-lock.yaml | 125 ++++++++-- 27 files changed, 715 insertions(+), 501 deletions(-) create mode 100644 core/src/api/devices.rs create mode 100644 interface/app/$libraryId/Explorer/View/Grid/SimpleGridItem.tsx create mode 100644 interface/app/$libraryId/overview/cards/FavoriteItems.tsx rename interface/app/$libraryId/overview/{ => cards}/FileKindStats.tsx (61%) create mode 100644 interface/app/$libraryId/overview/cards/ItemsCard.tsx rename interface/app/$libraryId/overview/{ => cards}/LibraryStats.tsx (97%) create mode 100644 interface/app/$libraryId/overview/cards/RecentItems.tsx create mode 100644 interface/app/$libraryId/overview/cards/RecentLocationsList.tsx delete mode 100644 interface/app/$libraryId/settings/client/extensions.tsx delete mode 100644 interface/app/$libraryId/settings/client/usage.tsx rename interface/app/$libraryId/settings/library/{contacts.tsx => clouds.tsx} (67%) create mode 100644 interface/app/$libraryId/settings/library/devices.tsx create mode 100644 interface/app/$libraryId/settings/library/keys.tsx create mode 100644 interface/app/$libraryId/settings/library/sync.tsx create mode 100644 interface/app/$libraryId/settings/library/users.tsx create mode 100644 interface/app/$libraryId/settings/library/volumes.tsx diff --git a/core/src/api/devices.rs b/core/src/api/devices.rs new file mode 100644 index 000000000000..36e7e179989b --- /dev/null +++ b/core/src/api/devices.rs @@ -0,0 +1,14 @@ +use super::{utils::library, Ctx, R}; +use rspc::alpha::AlphaRouter; +use serde::Deserialize; +use specta::Type; + +pub(crate) fn mount() -> AlphaRouter { + R.router().procedure( + "list", + R.with2(library()) + .query(|(node, library), _: ()| async move { + Ok(library.db.device().find_many(vec![]).exec().await?) + }), + ) +} diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 7a1dd1597d58..3f2ba1acc386 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -26,6 +26,7 @@ use tracing::warn; mod backups; mod cloud; +pub mod devices; mod ephemeral_files; mod files; mod jobs; @@ -197,6 +198,7 @@ pub(crate) fn mount() -> Arc { .merge("search.", search::mount()) .merge("library.", libraries::mount()) .merge("volumes.", volumes::mount()) + .merge("devices.", devices::mount()) .merge("tags.", tags::mount()) .merge("labels.", labels::mount()) .merge("locations.", locations::mount()) diff --git a/interface/app/$libraryId/Explorer/View/Grid/SimpleGridItem.tsx b/interface/app/$libraryId/Explorer/View/Grid/SimpleGridItem.tsx new file mode 100644 index 000000000000..46e95f0368fb --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/SimpleGridItem.tsx @@ -0,0 +1,47 @@ +import { HTMLAttributes, ReactNode } from 'react'; +import { useNavigate } from 'react-router'; +import { useSelector, type ExplorerItem } from '@sd/client'; +import { useOperatingSystem } from '~/hooks'; +import { useRoutingContext } from '~/RoutingContext'; + +import { useExplorerContext } from '../../Context'; +import { explorerStore, isCut } from '../../store'; + +interface Props extends Omit, 'children'> { + item: ExplorerItem; + children: (state: { selected: boolean; cut: boolean }) => ReactNode; +} + +export const SimpleGridItem = ({ children, item, ...props }: Props) => { + const explorer = useExplorerContext(); + const { currentIndex, maxIndex } = useRoutingContext(); + const os = useOperatingSystem(); + const navigate = useNavigate(); + + const cutCopyState = useSelector(explorerStore, (s) => s.cutCopyState); + const cut = isCut(item, cutCopyState); + const selected = explorer.selectedItems.has(item); + + const canGoBack = currentIndex !== 0; + const canGoForward = currentIndex !== maxIndex; + + return ( +
{ + e.stopPropagation(); + if (os === 'browser') return; + if (e.buttons === 8 || e.buttons === 3) { + if (!canGoBack) return; + navigate(-1); + } else if (e.buttons === 16 || e.buttons === 4) { + if (!canGoForward) return; + navigate(1); + } + }} + > + {children({ selected, cut })} +
+ ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index 31225bc70072..11e7c6b1bc6c 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -82,12 +82,13 @@ export default function LocalSection() { // Improved volume tracking const trackVolumeMutation = useLibraryMutation('volumes.track'); - // Mapping of volume paths to location IDs + // Mapping of volume paths to location IDs so we can open the location if root const locationIdsForVolumes = useMemo(() => { if (!locations || !volumes) return {}; return locations.reduce( (acc, location) => { + // match location path to volume mount point const matchingVolume = volumes.find((v) => v.mount_points.some((mp) => mp === location.path) ); @@ -95,13 +96,13 @@ export default function LocalSection() { if (matchingVolume && matchingVolume.pub_id && location.path) { acc[location.path] = { locationId: location.id, - volumeId: new Uint8Array(matchingVolume.pub_id) + volumeFingerprint: matchingVolume.fingerprint }; } return acc; }, - {} as Record + {} as Record ); }, [locations, volumes]); @@ -156,9 +157,9 @@ export default function LocalSection() { onTrack={async () => { if (!isTracked && volume.pub_id) { try { - await trackVolumeMutation.mutateAsync({ - volume_id: Array.from(volume.pub_id) // Convert Uint8Array to number[] - }); + await trackVolumeMutation.mutateAsync( + Array.from(volume.pub_id) + ); toast.success('Volume tracked successfully'); } catch (error) { toast.error('Failed to track volume'); diff --git a/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx index a6ece682201e..6ef88229d7bb 100644 --- a/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx +++ b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx @@ -61,7 +61,7 @@ const HorizontalScroll = ({ children, className }: { children: ReactNode; classN }, rgba(0, 0, 0, 1) ${lastItemVisible ? '95%' : '85%'}, transparent 99%)`; return ( -
+
handleArrowOnClick('right')} className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')} @@ -72,7 +72,7 @@ const HorizontalScroll = ({ children, className }: { children: ReactNode; classN ref={ref} {...events} className={clsx( - 'no-scrollbar flex gap-2 space-x-px overflow-x-scroll pl-1 pr-[60px]', + 'no-scrollbar flex gap-2 space-x-px overflow-x-scroll pl-1 pr-[30px]', isContentOverflow ? 'cursor-grab' : 'cursor-default' )} style={{ diff --git a/interface/app/$libraryId/overview/cards/FavoriteItems.tsx b/interface/app/$libraryId/overview/cards/FavoriteItems.tsx new file mode 100644 index 000000000000..8d7ef28d434e --- /dev/null +++ b/interface/app/$libraryId/overview/cards/FavoriteItems.tsx @@ -0,0 +1,27 @@ +import { useLibraryQuery } from '@sd/client'; + +import { ItemsCard } from './ItemsCard'; + +const FavoriteItemsCard = () => { + const favoriteItemsQuery = useLibraryQuery([ + 'search.objects', + { + take: 6, + orderAndPagination: { + orderOnly: { field: 'dateAccessed', value: 'Desc' } + }, + filters: [{ object: { favorite: true } }] + } + ]); + + return ( + + ); +}; + +export default FavoriteItemsCard; diff --git a/interface/app/$libraryId/overview/FileKindStats.tsx b/interface/app/$libraryId/overview/cards/FileKindStats.tsx similarity index 61% rename from interface/app/$libraryId/overview/FileKindStats.tsx rename to interface/app/$libraryId/overview/cards/FileKindStats.tsx index d5b2dd07ec6b..249bc679af25 100644 --- a/interface/app/$libraryId/overview/FileKindStats.tsx +++ b/interface/app/$libraryId/overview/cards/FileKindStats.tsx @@ -13,7 +13,7 @@ import { import { Card, Loader, Tooltip } from '@sd/ui'; import { useIsDark, useLocale } from '~/hooks'; -import { FileKind } from '.'; +import { FileKind, OverviewCard } from '..'; const INFO_ICON_CLASSLIST = 'inline size-3 text-ink-faint opacity-0 ml-1 transition-opacity duration-300 group-hover:opacity-70'; @@ -21,7 +21,7 @@ const TOTAL_FILES_CLASSLIST = 'flex items-center justify-between whitespace-nowrap text-sm font-medium text-ink-dull mt-2 px-1 font-plex'; const UNIDENTIFIED_FILES_CLASSLIST = 'relative flex items-center text-xs font-plex text-ink-faint'; const BARS_CONTAINER_CLASSLIST = - 'relative mx-2.5 grid grow grid-cols-[repeat(auto-fit,_minmax(0,_1fr))] grid-rows-[136px_12px] font-plex tracking-wide items-end justify-items-center gap-x-1.5 gap-y-1 self-stretch'; + 'relative mt-2 grid grow grid-cols-[repeat(auto-fit,_minmax(0,_1fr))] grid-rows-[136px_12px] font-plex tracking-wide items-end justify-items-center gap-x-1 gap-y-1 self-stretch'; const mapFractionalValue = (numerator: bigint, denominator: bigint, maxValue: bigint): string => { if (denominator === 0n) return '0'; @@ -73,7 +73,7 @@ const FileKindStats: React.FC = () => { const [fileKinds, setFileKinds] = useState>(new Map()); const [cardWidth, setCardWidth] = useState(0); const [loading, setLoading] = useState(true); - const containerRef = useRef(null); + const barsContainerRef = useRef(null); const iconsRef = useRef<{ [key: string]: HTMLImageElement }>({}); const BAR_MAX_HEIGHT = 115n; @@ -106,35 +106,26 @@ const FileKindStats: React.FC = () => { }; const handleResize = useCallback(() => { - if (containerRef.current) { - const factor = window.innerWidth > 1500 ? 0.35 : 0.4; - setCardWidth(window.innerWidth * factor); + if (barsContainerRef.current) { + const width = barsContainerRef.current.getBoundingClientRect().width; + setCardWidth(width); } }, []); useEffect(() => { - window.addEventListener('resize', handleResize); handleResize(); + window.addEventListener('resize', handleResize); - const containerElement = containerRef.current; - if (containerElement) { - const observer = new MutationObserver(handleResize); - observer.observe(containerElement, { - attributes: true, - childList: true, - subtree: true, - attributeFilter: ['style'] - }); - - return () => { - observer.disconnect(); - }; + const resizeObserver = new ResizeObserver(handleResize); + if (barsContainerRef.current) { + resizeObserver.observe(barsContainerRef.current); } return () => { window.removeEventListener('resize', handleResize); + resizeObserver.disconnect(); }; - }, [handleResize, fileKinds]); + }, [handleResize]); useEffect(() => { if (data) { @@ -203,117 +194,116 @@ const FileKindStats: React.FC = () => { navigate(path); }; + const getVisibleFileKinds = useCallback(() => { + if (cardWidth === 0) return sortedFileKinds; + const minWidthPerBar = 32; + const maxBars = Math.max(1, Math.floor((cardWidth + 4) / (minWidthPerBar + 4))); + return sortedFileKinds.slice(0, maxBars); + }, [cardWidth, sortedFileKinds]); + return ( -
- - {loading ? ( -
-
- -

{t('fetching_file_kind_statistics')}

-
+ + {loading ? ( +
+
+ +

{t('fetching_file_kind_statistics')}

- ) : ( - <> -
- -
- - {data?.total_identified_files - ? formatNumberWithCommas(data.total_identified_files) - : '0'}{' '} - -
- {t('total_files')} - -
+
+ ) : ( + <> +
+ +
+ + {data?.total_identified_files + ? formatNumberWithCommas(data.total_identified_files) + : '0'}{' '} + +
+ {t('total_files')} +
- -
- - - {data?.total_unidentified_files - ? formatNumberWithCommas(data.total_unidentified_files) - : '0'}{' '} - {t('unidentified_files')} - -
+ +
+ + + {data?.total_unidentified_files + ? formatNumberWithCommas(data.total_unidentified_files) + : '0'}{' '} + {t('unidentified_files')} + +
-
- {sortedFileKinds.map((fileKind, index) => { - const iconImage = iconsRef.current[fileKind.name]; - const barColor = interpolateHexColor( - BAR_COLOR_START, - BAR_COLOR_END, - index / (barCount - 1) - ); +
+
+ {getVisibleFileKinds().map((fileKind, index) => { + const iconImage = iconsRef.current[fileKind.name]; + const barColor = interpolateHexColor( + BAR_COLOR_START, + BAR_COLOR_END, + index / (getVisibleFileKinds().length - 1) + ); - const barHeight = - mapFractionalValue( - fileKind.count, - maxFileCount, - BAR_MAX_HEIGHT - ) + 'px'; + const barHeight = + mapFractionalValue(fileKind.count, maxFileCount, BAR_MAX_HEIGHT) + + 'px'; - return ( - <> - + +
-
- {iconImage && ( - {fileKind.name} - )} - -
- -
- {formatCount(fileKind.count)} + {iconImage && ( + {fileKind.name} + )} +
- - ); - })} -
- - )} - -
+ +
+ {formatCount(fileKind.count)} +
+ + ); + })} +
+ + )} + ); }; diff --git a/interface/app/$libraryId/overview/cards/ItemsCard.tsx b/interface/app/$libraryId/overview/cards/ItemsCard.tsx new file mode 100644 index 000000000000..7d74aefde94c --- /dev/null +++ b/interface/app/$libraryId/overview/cards/ItemsCard.tsx @@ -0,0 +1,122 @@ +import { UseQueryResult } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; +import { ObjectOrder, objectOrderingKeysSchema, useExplorerLayoutStore } from '@sd/client'; +import { Button } from '@sd/ui'; +import { useLocale } from '~/hooks'; + +import { OverviewCard } from '..'; +import { ExplorerContextProvider } from '../../Explorer/Context'; +import { createDefaultExplorerSettings } from '../../Explorer/store'; +import { useExplorer, useExplorerSettings } from '../../Explorer/useExplorer'; +import { uniqueId } from '../../Explorer/util'; +import { ExplorerViewContext } from '../../Explorer/View/Context'; +import { SimpleGridItem } from '../../Explorer/View/Grid/SimpleGridItem'; +import { GridViewItem } from '../../Explorer/View/GridView/Item'; +import HorizontalScroll from '../Layout/HorizontalScroll'; + +interface ItemsCardProps { + title: string; + query: UseQueryResult<{ items: any[] }>; + buttonText: string; + buttonLink: string; + maxItems?: number; +} + +export const ItemsCard = ({ + title, + query, + buttonText, + buttonLink, + maxItems = 6 +}: ItemsCardProps) => { + const navigate = useNavigate(); + const { t } = useLocale(); + const layoutStore = useExplorerLayoutStore(); + + const explorerSettings = useExplorerSettings({ + settings: { + ...createDefaultExplorerSettings({ + order: { field: 'dateAccessed', value: 'Desc' } + }), + gridItemSize: 80, + gridGap: 9 + }, + orderingKeys: objectOrderingKeysSchema + }); + + const items = query.data?.items ?? []; + const displayItems = items.slice(0, maxItems); + + const explorer = useExplorer({ + items: displayItems, + settings: explorerSettings, + isFetching: query.isLoading, + isFetchingNextPage: false + }); + + const itemDetailsHeight = + (layoutStore.showTags ? 60 : 44) + + (explorerSettings.settingsStore.showBytesInGridView ? 20 : 0); + const itemHeight = explorerSettings.settingsStore.gridItemSize + itemDetailsHeight; + + return ( + +
+ {t(title)} + + {items.length > 0 && `${displayItems.length} shown`} + +
+ + -1, + getFirstActiveItemIndex: () => -1, + updateActiveItem: () => {}, + updateFirstActiveItem: () => {}, + handleWindowsGridShiftSelection: () => {} + }} + > + +
+ {displayItems.map((item) => ( +
+ + {({ selected, cut }) => ( + + )} + +
+ ))} +
+
+
+
+ +
+ ); +}; diff --git a/interface/app/$libraryId/overview/LibraryStats.tsx b/interface/app/$libraryId/overview/cards/LibraryStats.tsx similarity index 97% rename from interface/app/$libraryId/overview/LibraryStats.tsx rename to interface/app/$libraryId/overview/cards/LibraryStats.tsx index 780f2fe88f10..e63d2f0c12d7 100644 --- a/interface/app/$libraryId/overview/LibraryStats.tsx +++ b/interface/app/$libraryId/overview/cards/LibraryStats.tsx @@ -14,8 +14,8 @@ import { Card, Loader, Tooltip } from '@sd/ui'; import i18n from '~/app/I18n'; import { useCounter, useIsDark, useLocale } from '~/hooks'; -import { FileKind } from '.'; -import StorageBar from './StorageBar'; +import { FileKind, OverviewCard } from '..'; +import StorageBar from '../StorageBar'; interface StatItemProps { title: string; @@ -194,9 +194,8 @@ const LibraryStats = () => { tooltip: `${humanizeSize(otherTotalBytes).value} ${t(`size_${humanizeSize(otherTotalBytes).unit.toLowerCase()}`)}` } ]; - return ( - + {loading ? (
@@ -236,7 +235,7 @@ const LibraryStats = () => {
)} - + ); }; diff --git a/interface/app/$libraryId/overview/cards/RecentItems.tsx b/interface/app/$libraryId/overview/cards/RecentItems.tsx new file mode 100644 index 000000000000..46890ea8e251 --- /dev/null +++ b/interface/app/$libraryId/overview/cards/RecentItems.tsx @@ -0,0 +1,27 @@ +import { ObjectOrder, objectOrderingKeysSchema, useLibraryQuery } from '@sd/client'; + +import { ItemsCard } from './ItemsCard'; + +const RecentItemsCard = () => { + const recentItemsQuery = useLibraryQuery([ + 'search.objects', + { + take: 6, + orderAndPagination: { + orderOnly: { field: 'dateAccessed', value: 'Desc' } + }, + filters: [{ object: { dateAccessed: { from: new Date(0).toISOString() } } }] + } + ]); + + return ( + + ); +}; + +export default RecentItemsCard; diff --git a/interface/app/$libraryId/overview/cards/RecentLocationsList.tsx b/interface/app/$libraryId/overview/cards/RecentLocationsList.tsx new file mode 100644 index 000000000000..721f5735837d --- /dev/null +++ b/interface/app/$libraryId/overview/cards/RecentLocationsList.tsx @@ -0,0 +1,98 @@ +import { FolderDashed } from '@phosphor-icons/react'; +import { keepPreviousData } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import { arraysEqual, Device, humanizeSize, useLibraryQuery, useOnlineLocations } from '@sd/client'; +import { Button, buttonStyles, Card, Tooltip } from '@sd/ui'; +import { Icon as SdIcon } from '~/components'; +import { useLocale } from '~/hooks'; + +import { OverviewCard } from '..'; +import { AddLocationButton } from '../../settings/library/locations/AddLocationButton'; + +const RecentLocationsList = () => { + const navigate = useNavigate(); + const { t } = useLocale(); + const onlineLocations = useOnlineLocations(); + + const devicesQuery = useLibraryQuery(['devices.list']); + // eslint-disable-next-line react-hooks/exhaustive-deps + const devices = devicesQuery.data ?? []; + + const devicesAsHashmap = useMemo(() => { + return devices.reduce( + (acc, device) => { + acc[device.id] = device; + return acc; + }, + {} as Record + ); + }, [devices]); + + const locationsQuery = useLibraryQuery(['locations.list'], { + placeholderData: keepPreviousData + }); + const locations = locationsQuery.data ?? []; + + return ( + +
+ {t('Recent Locations')} + {locations.length} total +
+ +
+ {locations.slice(0, 6).map((location) => ( + + ))} + +
+
+ ); +}; + +export default RecentLocationsList; diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 2f4c6143d5d0..1e4b3abeb7b0 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -1,7 +1,9 @@ import { keepPreviousData } from '@tanstack/react-query'; +import clsx from 'clsx'; import { Key, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { HardwareModel, useBridgeQuery, useLibraryQuery } from '@sd/client'; +import { Card } from '@sd/ui'; import { useAccessToken, useLocale, useOperatingSystem } from '~/hooks'; import { useRouteTitle } from '~/hooks/useRouteTitle'; import { hardwareModelToIcon } from '~/util/hardware'; @@ -11,9 +13,12 @@ import SearchBar from '../search/SearchBar'; import { AddLocationButton } from '../settings/library/locations/AddLocationButton'; import { TopBarPortal } from '../TopBar/Portal'; import TopBarOptions from '../TopBar/TopBarOptions'; -import FileKindStatistics from './FileKindStats'; +import FavoriteItems from './cards/FavoriteItems'; +import FileKindStatistics from './cards/FileKindStats'; +import LibraryStatistics from './cards/LibraryStats'; +import RecentItems from './cards/RecentItems'; +import RecentLocationsList from './cards/RecentLocationsList'; import OverviewSection from './Layout/Section'; -import LibraryStatistics from './LibraryStats'; import NewCard from './NewCard'; import StatisticItem from './StatCard'; @@ -24,6 +29,25 @@ export interface FileKind { total_bytes: bigint; } +export function OverviewCard({ + children, + className +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + export const Component = () => { useRouteTitle('Overview'); const os = useOperatingSystem(); @@ -65,75 +89,20 @@ export const Component = () => { center={} right={os === 'windows' && } /> -
- +
+
- - - - - {node && ( - - )} - {cloudDevicesList.data?.map((device) => ( - - ))} - - - - {locations?.map((item) => ( - - - - ))} - {!locations?.length && ( - } - /> - )} - - - - - +
+
+ +
+
+ +
+ +
+ +
diff --git a/interface/app/$libraryId/settings/Sidebar.tsx b/interface/app/$libraryId/settings/Sidebar.tsx index feea33031eef..3a7b8daba818 100644 --- a/interface/app/$libraryId/settings/Sidebar.tsx +++ b/interface/app/$libraryId/settings/Sidebar.tsx @@ -3,20 +3,32 @@ import { Books, Cloud, Database, + Devices, + Eject, + FloppyDisk, FlyingSaucer, + Folder, + FolderDashed, GearSix, GlobeSimple, HardDrive, + HardDrives, Key, KeyReturn, + Network, PaintBrush, PuzzlePiece, Receipt, ShareNetwork, ShieldCheck, + Tag, TagSimple, - User + User, + UserCircleDashed, + UserCircleGear, + UsersThree } from '@phosphor-icons/react'; +import { Drive } from '@sd/assets/icons'; import clsx from 'clsx'; import { useFeatureFlag } from '@sd/client'; import { tw } from '@sd/ui'; @@ -37,7 +49,7 @@ export default () => { const { sidebar } = useLayoutStore(); // const isPairingEnabled = useFeatureFlag('p2pPairing'); - const isBackupsEnabled = useFeatureFlag('backups'); + // const isBackupsEnabled = useFeatureFlag('backups'); // const cloudSync = useFeatureFlag('cloudSync'); const { t } = useLocale(); @@ -65,10 +77,6 @@ export default () => { {t('general')} - - - {t('account')} - {t('libraries')} @@ -82,10 +90,10 @@ export default () => { {t('appearance')} - + {t('network')} - + {t('backups')} @@ -93,10 +101,6 @@ export default () => { {t('keybinds')} - - - {t('extensions')} -
{t('library')} @@ -104,32 +108,36 @@ export default () => { {t('general')} - - - Volumes + + + {t('Users')} {t('sync')} + + + {t('devices')} + + + + {t('Volumes')} + - + {t('locations')} - + {t('tags')} - {/* - - Saved Searches - */} - + {t('clouds')} - + {t('keys')} diff --git a/interface/app/$libraryId/settings/client/extensions.tsx b/interface/app/$libraryId/settings/client/extensions.tsx deleted file mode 100644 index c673412357cd..000000000000 --- a/interface/app/$libraryId/settings/client/extensions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Button, Card, GridLayout, SearchInput } from '@sd/ui'; -import { useLocale } from '~/hooks'; - -import { Heading } from '../Layout'; - -// extensions should cache their logos in the app data folder -interface ExtensionItemData { - name: string; - uuid: string; - platforms: ['windows' | 'macOS' | 'linux']; - installed: boolean; - description: string; - logoUri: string; -} - -const extensions: ExtensionItemData[] = [ - { - name: 'Apple Photos', - uuid: 'com.apple.photos', - installed: true, - platforms: ['macOS'], - description: 'Import photos and videos with metadata from Apple Photos.', - logoUri: 'https://apple.com/apple-logo.png' - }, - { - name: 'Twitch VOD Archiver', - uuid: 'com.apple.photos', - installed: false, - platforms: ['macOS'], - description: 'Apple Photos is a photo management application for Mac.', - logoUri: 'https://apple.com/apple-logo.png' - }, - { - name: 'Shared Clipboard', - uuid: 'com.apple.photos', - installed: false, - platforms: ['macOS'], - description: 'Apple Photos is a photo management application for Mac.', - logoUri: 'https://apple.com/apple-logo.png' - } -]; - -function ExtensionItem(props: { extension: ExtensionItemData }) { - const { installed, name, description } = props.extension; - - const { t } = useLocale(); - - return ( - -

{name}

-

{description}

-
- - - ); -} - -export const Component = () => { - // const { data: volumes } = useBridgeQuery('GetVolumes'); - - const { t } = useLocale(); - - return ( - <> - } - /> - - - {extensions.map((extension) => ( - - ))} - - - ); -}; diff --git a/interface/app/$libraryId/settings/client/index.ts b/interface/app/$libraryId/settings/client/index.ts index 3fa39ac433a8..a9274cc71831 100644 --- a/interface/app/$libraryId/settings/client/index.ts +++ b/interface/app/$libraryId/settings/client/index.ts @@ -3,10 +3,8 @@ import { RouteObject } from 'react-router'; export default [ { path: 'general', lazy: () => import('./general') }, { path: 'account', lazy: () => import('./account') }, - { path: 'usage', lazy: () => import('./usage') }, { path: 'appearance', lazy: () => import('./appearance') }, { path: 'keybindings', lazy: () => import('./keybindings') }, - { path: 'extensions', lazy: () => import('./extensions') }, { path: 'privacy', lazy: () => import('./privacy') }, { path: 'backups', lazy: () => import('./backups') }, { path: 'network', lazy: () => import('./network/index') }, diff --git a/interface/app/$libraryId/settings/client/usage.tsx b/interface/app/$libraryId/settings/client/usage.tsx deleted file mode 100644 index 9e39fef8dd9a..000000000000 --- a/interface/app/$libraryId/settings/client/usage.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { iconNames } from '@sd/assets/util'; -import { memo, useEffect, useMemo, useState } from 'react'; -import { humanizeSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client'; -import { Card } from '@sd/ui'; -import { Icon } from '~/components'; -import { useCounter, useLocale } from '~/hooks'; - -import { Heading } from '../Layout'; - -export const Component = () => { - const { t } = useLocale(); - const stats = useLibraryQuery(['library.statistics'], { - refetchOnWindowFocus: false, - initialData: { total_bytes_capacity: '0', library_db_size: '0' } - }); - const locations = useLibraryQuery(['locations.list'], { - refetchOnWindowFocus: false - }); - - const discoveredPeers = useDiscoveredPeers(); - const info = useMemo(() => { - if (locations.data && discoveredPeers) { - const statistics = stats.data?.statistics; - const tb_capacity = humanizeSize(statistics?.total_local_bytes_capacity); - const free_space = humanizeSize(statistics?.total_local_bytes_free); - const library_db_size = humanizeSize(statistics?.library_db_size); - const preview_media = humanizeSize(statistics?.total_library_preview_media_bytes); - const data: { - icon: keyof typeof iconNames; - title?: string; - numberTitle?: number; - titleCount?: number; - unit?: string; - sub: string; - dataLength?: number; - }[] = [ - { - icon: 'Folder', - title: locations.data?.length === 1 ? 'Location' : 'Locations', - titleCount: locations.data?.length ?? 0, - sub: 'indexed directories' - }, - { - icon: 'Laptop', - title: discoveredPeers.size >= 0 ? 'Devices' : 'Device', - titleCount: discoveredPeers.size ?? 0, - sub: 'in your network' - }, - { - icon: 'DriveDarker', - numberTitle: tb_capacity.value, - sub: 'Total capacity', - unit: tb_capacity.unit - }, - { - icon: 'HDD', - numberTitle: free_space.value, - sub: 'Free space', - unit: free_space.unit - }, - { - icon: 'Collection', - numberTitle: library_db_size.value, - sub: 'Library size', - unit: library_db_size.unit - }, - { - icon: 'Image', - numberTitle: preview_media.value, - sub: 'Preview media', - unit: preview_media.unit - } - ]; - return data; - } - }, [locations, discoveredPeers, stats]); - - return ( - <> - - -
- {info?.map((i, index) => ( - - ))} -
-
- - ); -}; - -interface Props { - icon: keyof typeof iconNames; - title: string; - titleCount?: number; - numberTitle?: number; - statsLoading: boolean; - unit?: string; - sub: string; -} - -let mounted = false; -const UsageCard = memo( - ({ icon, title, titleCount, numberTitle, unit, sub, statsLoading }: Props) => { - const [isMounted] = useState(mounted); - const sizeCount = useCounter({ - name: title, - end: Number(numberTitle ? numberTitle : titleCount), - duration: isMounted ? 0 : 1, - precision: numberTitle ? 1 : 0, - saveState: false - }); - useEffect(() => { - if (!statsLoading) mounted = true; - }); - - return ( - -
- -
-

- {typeof titleCount === 'number' && ( - {sizeCount} - )} - {numberTitle && sizeCount} - {title} - {unit && ( - - {unit} - - )} -

-

{sub}

-
-
-
- ); - } -); diff --git a/interface/app/$libraryId/settings/library/contacts.tsx b/interface/app/$libraryId/settings/library/clouds.tsx similarity index 67% rename from interface/app/$libraryId/settings/library/contacts.tsx rename to interface/app/$libraryId/settings/library/clouds.tsx index 0d66f0888f33..408f7ee7cbd3 100644 --- a/interface/app/$libraryId/settings/library/contacts.tsx +++ b/interface/app/$libraryId/settings/library/clouds.tsx @@ -4,10 +4,9 @@ import { Heading } from '../Layout'; export const Component = () => { const { t } = useLocale(); - return ( <> - + ); }; diff --git a/interface/app/$libraryId/settings/library/devices.tsx b/interface/app/$libraryId/settings/library/devices.tsx new file mode 100644 index 000000000000..d3f3ee82da47 --- /dev/null +++ b/interface/app/$libraryId/settings/library/devices.tsx @@ -0,0 +1,12 @@ +import { useLocale } from '~/hooks'; + +import { Heading } from '../Layout'; + +export const Component = () => { + const { t } = useLocale(); + return ( + <> + + + ); +}; diff --git a/interface/app/$libraryId/settings/library/index.tsx b/interface/app/$libraryId/settings/library/index.tsx index 314890315692..e451fec66348 100644 --- a/interface/app/$libraryId/settings/library/index.tsx +++ b/interface/app/$libraryId/settings/library/index.tsx @@ -4,15 +4,17 @@ export default [ { lazy: () => import('../OverviewLayout'), children: [ - { path: 'contacts', lazy: () => import('./contacts') }, { path: 'security', lazy: () => import('./security') }, { path: 'sharing', lazy: () => import('./sharing') }, { path: 'general', lazy: () => import('./general') }, { path: 'tags', lazy: () => import('./tags') }, - // { path: 'saved-searches', lazy: () => import('./saved-searches') }, - //this is for edit in tags context menu { path: 'tags/:id', lazy: () => import('./tags') }, - { path: 'locations', lazy: () => import('./locations') } + { path: 'locations', lazy: () => import('./locations') }, + { path: 'volumes', lazy: () => import('./volumes') }, + { path: 'devices', lazy: () => import('./devices') }, + { path: 'sync', lazy: () => import('./sync') }, + { path: 'clouds', lazy: () => import('./clouds') }, + { path: 'users', lazy: () => import('./users') } ] }, { path: 'locations/:id', lazy: () => import('./locations/$id') } diff --git a/interface/app/$libraryId/settings/library/keys.tsx b/interface/app/$libraryId/settings/library/keys.tsx new file mode 100644 index 000000000000..620959be4e9a --- /dev/null +++ b/interface/app/$libraryId/settings/library/keys.tsx @@ -0,0 +1,12 @@ +import { useLocale } from '~/hooks'; + +import { Heading } from '../Layout'; + +export const Component = () => { + const { t } = useLocale(); + return ( + <> + + + ); +}; diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index d84cd5d54e2a..6df93e6be5df 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -270,6 +270,20 @@ export const AddLocationDialog = ({ {t('open_new_location_once_added')}
+
+ ( + + )} + control={form.control} + /> + +
); diff --git a/interface/app/$libraryId/settings/library/sync.tsx b/interface/app/$libraryId/settings/library/sync.tsx new file mode 100644 index 000000000000..e8f5507b5c0a --- /dev/null +++ b/interface/app/$libraryId/settings/library/sync.tsx @@ -0,0 +1,12 @@ +import { useLocale } from '~/hooks'; + +import { Heading } from '../Layout'; + +export const Component = () => { + const { t } = useLocale(); + return ( + <> + + + ); +}; diff --git a/interface/app/$libraryId/settings/library/users.tsx b/interface/app/$libraryId/settings/library/users.tsx new file mode 100644 index 000000000000..7cd53358c0a2 --- /dev/null +++ b/interface/app/$libraryId/settings/library/users.tsx @@ -0,0 +1,12 @@ +import { useLocale } from '~/hooks'; + +import { Heading } from '../Layout'; + +export const Component = () => { + const { t } = useLocale(); + return ( + <> + + + ); +}; diff --git a/interface/app/$libraryId/settings/library/volumes.tsx b/interface/app/$libraryId/settings/library/volumes.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/interface/package.json b/interface/package.json index cbcb35c67831..e4ec0bdb3381 100644 --- a/interface/package.json +++ b/interface/package.json @@ -14,7 +14,7 @@ "@headlessui/react": "^1.7.17", "@icons-pack/react-simple-icons": "^9.1.0", "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", - "@phosphor-icons/react": "^2.0.13", + "@phosphor-icons/react": "^2.1.7", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-progress": "^1.0.1", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 4e67bf7bfbcb..e5cb8633a0a1 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -16,6 +16,7 @@ export type Procedures = { { key: "cloud.syncGroups.leave", input: CloudSyncGroupPubId, result: null } | { key: "cloud.syncGroups.list", input: never, result: CloudSyncGroupBaseData[] } | { key: "cloud.syncGroups.remove_device", input: CloudSyncGroupsRemoveDeviceArgs, result: null } | + { key: "devices.list", input: LibraryArgs, result: Device[] } | { key: "ephemeralFiles.getMediaData", input: string, result: MediaData | null } | { key: "files.get", input: LibraryArgs, result: ObjectWithFilePaths2 | null } | { key: "files.getConvertibleImageExtensions", input: never, result: string[] } | @@ -138,7 +139,7 @@ export type Procedures = { { key: "tags.delete", input: LibraryArgs, result: null } | { key: "tags.update", input: LibraryArgs, result: null } | { key: "toggleFeatureFlag", input: BackendFeature, result: null } | - { key: "volumes.track", input: LibraryArgs, result: null } | + { key: "volumes.track", input: LibraryArgs, result: null } | { key: "volumes.unmount", input: LibraryArgs, result: null }, subscriptions: { key: "cloud.listenCloudServicesNotifications", input: never, result: CloudP2PNotifyUser } | @@ -285,6 +286,8 @@ export type CursorOrderItem = { order: SortOrder; data: T } export type DefaultLocations = { desktop: boolean; documents: boolean; downloads: boolean; pictures: boolean; music: boolean; videos: boolean } +export type Device = { id: number; pub_id: number[]; name: string | null; os: number | null; hardware_model: number | null; timestamp: bigint | null; date_created: string | null; date_deleted: string | null } + export type DeviceOS = "Linux" | "Windows" | "MacOS" | "iOS" | "Android" /** @@ -790,8 +793,6 @@ export type TextMatch = { contains: string } | { startsWith: string } | { endsWi */ export type ThumbKey = { shard_hex: string; cas_id: CasId; base_directory_str: string } -export type TrackVolumeInput = { volume_id: VolumeFingerprint } - export type UpdateThumbnailerPreferences = Record export type VideoProps = { pixel_format: string | null; color_range: string | null; bits_per_channel: number | null; color_space: string | null; color_primaries: string | null; color_transfer: string | null; field_order: string | null; chroma_location: string | null; width: number; height: number; aspect_ratio_num: number | null; aspect_ratio_den: number | null; properties: string[] } @@ -800,6 +801,11 @@ export type VideoProps = { pixel_format: string | null; color_range: string | nu * Represents a physical or virtual storage volume in the system */ export type Volume = { +/** + * Fingerprint of the volume as a hash of its properties, not persisted to the database + * Used as the unique identifier for a volume in this module + */ +fingerprint: VolumeFingerprint | null; /** * Database ID (None if not yet committed to database) */ @@ -863,11 +869,7 @@ total_bytes_capacity: string; /** * Available storage space in bytes */ -total_bytes_available: string; -/** - * Fingerprint of the volume, not persisted to the database - */ -fingerprint: string } +total_bytes_available: string } /** * Events emitted by the Volume Manager when volume state changes @@ -898,4 +900,7 @@ export type VolumeEvent = */ { VolumeError: { fingerprint: VolumeFingerprint; error: string } } +/** + * A fingerprint of a volume, used to identify it when it is not persisted in the database + */ export type VolumeFingerprint = number[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f810152c877..8e49f6ccb7bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,10 +196,10 @@ importers: version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-three/drei': specifier: ^9.88.13 - version: 9.102.6(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(@types/react@18.2.67)(@types/three@0.162.0)(immer@10.0.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(three@0.161.0) + version: 9.102.6(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(@types/react@18.2.67)(@types/three@0.162.0)(immer@10.0.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(three@0.161.0) '@react-three/fiber': specifier: ^8.15.11 - version: 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) + version: 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) '@sd/assets': specifier: workspace:* version: link:../../packages/assets @@ -714,8 +714,8 @@ importers: specifier: ^9.1.0 version: 9.4.0(react@18.2.0) '@phosphor-icons/react': - specifier: ^2.0.13 - version: 2.0.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: ^2.1.7 + version: 2.1.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1373,7 +1373,7 @@ packages: '@azure/core-http@3.0.4': resolution: {integrity: sha512-Fok9VVhMdxAFOtqiiAtg74fL0UJkt0z3D+ouUUxcRLzZNBioPRAMJFVxiWoJljYpXsRi4GDQHzQHDc9AiYaIUQ==} engines: {node: '>=14.0.0'} - deprecated: This package is no longer supported. Please migrate to use @azure/core-rest-pipeline + deprecated: deprecating as we migrated to core v2 '@azure/core-lro@2.7.0': resolution: {integrity: sha512-oj7d8vWEvOREIByH1+BnoiFwszzdE7OXUEd6UTv+cmx5HvjBBlkVezm3uZgpXWaxDj5ATL/k89+UMeGx1Ou9TQ==} @@ -4012,6 +4012,13 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@phosphor-icons/react@2.1.7': + resolution: {integrity: sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -18343,6 +18350,11 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@phosphor-icons/react@2.1.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + '@pkgjs/parseargs@0.11.0': optional: true @@ -19661,12 +19673,12 @@ snapshots: nullthrows: 1.1.1 react-native: 0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0) - '@react-native/virtualized-lists@0.74.87(@types/react@18.2.67)(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)': + '@react-native/virtualized-lists@0.74.87(@types/react@18.2.67)(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 18.2.0 - react-native: 0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0) + react-native: 0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0) optionalDependencies: '@types/react': 18.2.67 optional: true @@ -19784,13 +19796,13 @@ snapshots: '@react-spring/types': 9.7.3 react: 18.2.0 - '@react-spring/three@9.6.1(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(react@18.2.0)(three@0.161.0)': + '@react-spring/three@9.6.1(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(react@18.2.0)(three@0.161.0)': dependencies: '@react-spring/animated': 9.6.1(react@18.2.0) '@react-spring/core': 9.6.1(react@18.2.0) '@react-spring/shared': 9.6.1(react@18.2.0) '@react-spring/types': 9.6.1 - '@react-three/fiber': 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) + '@react-three/fiber': 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) react: 18.2.0 three: 0.161.0 @@ -19807,12 +19819,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-three/drei@9.102.6(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(@types/react@18.2.67)(@types/three@0.162.0)(immer@10.0.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(three@0.161.0)': + '@react-three/drei@9.102.6(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(@types/react@18.2.67)(@types/three@0.162.0)(immer@10.0.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(three@0.161.0)': dependencies: '@babel/runtime': 7.24.0 '@mediapipe/tasks-vision': 0.10.8 - '@react-spring/three': 9.6.1(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(react@18.2.0)(three@0.161.0) - '@react-three/fiber': 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) + '@react-spring/three': 9.6.1(@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0))(react@18.2.0)(three@0.161.0) + '@react-three/fiber': 8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0) '@use-gesture/react': 10.3.0(react@18.2.0) camera-controls: 2.8.3(three@0.161.0) cross-env: 7.0.3 @@ -19840,7 +19852,7 @@ snapshots: - '@types/three' - immer - '@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))))(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0)': + '@react-three/fiber@8.15.19(expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)))(expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)))(expo@51.0.36(@babel/core@7.24.0))(react-dom@18.2.0(react@18.2.0))(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0)(three@0.161.0)': dependencies: '@babel/runtime': 7.24.0 '@types/react-reconciler': 0.26.7 @@ -19856,11 +19868,11 @@ snapshots: three: 0.161.0 zustand: 3.7.2(react@18.2.0) optionalDependencies: - expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) - expo-asset: 10.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))) - expo-file-system: 17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))) + expo: 51.0.36(@babel/core@7.24.0) + expo-asset: 10.0.10(expo@51.0.36(@babel/core@7.24.0)) + expo-file-system: 17.0.1(expo@51.0.36(@babel/core@7.24.0)) react-dom: 18.2.0(react@18.2.0) - react-native: 0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0) + react-native: 0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0) '@redux-devtools/extension@3.3.0(redux@5.0.1)': dependencies: @@ -24418,7 +24430,7 @@ snapshots: debug: 4.3.7(supports-color@8.1.1) enhanced-resolve: 5.16.0 eslint: 8.57.1 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.7.3 @@ -24430,7 +24442,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: @@ -24880,6 +24892,16 @@ snapshots: transitivePeerDependencies: - supports-color + expo-asset@10.0.10(expo@51.0.36(@babel/core@7.24.0)): + dependencies: + expo: 51.0.36(@babel/core@7.24.0) + expo-constants: 16.0.2(expo@51.0.36(@babel/core@7.24.0)) + invariant: 2.2.4 + md5-file: 3.2.3 + transitivePeerDependencies: + - supports-color + optional: true + expo-av@14.0.7(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))): dependencies: expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) @@ -24902,15 +24924,35 @@ snapshots: transitivePeerDependencies: - supports-color + expo-constants@16.0.2(expo@51.0.36(@babel/core@7.24.0)): + dependencies: + '@expo/config': 9.0.3 + '@expo/env': 0.3.0 + expo: 51.0.36(@babel/core@7.24.0) + transitivePeerDependencies: + - supports-color + optional: true + expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))): dependencies: expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) + expo-file-system@17.0.1(expo@51.0.36(@babel/core@7.24.0)): + dependencies: + expo: 51.0.36(@babel/core@7.24.0) + optional: true + expo-font@12.0.10(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))): dependencies: expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) fontfaceobserver: 2.3.0 + expo-font@12.0.10(expo@51.0.36(@babel/core@7.24.0)): + dependencies: + expo: 51.0.36(@babel/core@7.24.0) + fontfaceobserver: 2.3.0 + optional: true + expo-haptics@13.0.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))): dependencies: expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) @@ -24923,6 +24965,11 @@ snapshots: dependencies: expo: 51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) + expo-keep-awake@13.0.2(expo@51.0.36(@babel/core@7.24.0)): + dependencies: + expo: 51.0.36(@babel/core@7.24.0) + optional: true + expo-linking@6.3.1(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))): dependencies: expo-constants: 16.0.2(expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))) @@ -24960,6 +25007,32 @@ snapshots: expo-status-bar@1.12.1: {} + expo@51.0.36(@babel/core@7.24.0): + dependencies: + '@babel/runtime': 7.24.0 + '@expo/cli': 0.18.30(expo-modules-autolinking@1.11.3) + '@expo/config': 9.0.4 + '@expo/config-plugins': 8.0.10 + '@expo/metro-config': 0.18.11 + '@expo/vector-icons': 14.0.4 + babel-preset-expo: 11.0.14(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)) + expo-asset: 10.0.10(expo@51.0.36(@babel/core@7.24.0)) + expo-file-system: 17.0.1(expo@51.0.36(@babel/core@7.24.0)) + expo-font: 12.0.10(expo@51.0.36(@babel/core@7.24.0)) + expo-keep-awake: 13.0.2(expo@51.0.36(@babel/core@7.24.0)) + expo-modules-autolinking: 1.11.3 + expo-modules-core: 1.12.25 + fbemitter: 3.0.0 + whatwg-url-without-unicode: 8.0.0-3 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + optional: true + expo@51.0.36(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0)): dependencies: '@babel/runtime': 7.24.0 @@ -29598,7 +29671,7 @@ snapshots: - supports-color - utf-8-validate - react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0): + react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native-community/cli': 13.6.9 @@ -29610,7 +29683,7 @@ snapshots: '@react-native/gradle-plugin': 0.74.87 '@react-native/js-polyfills': 0.74.87 '@react-native/normalize-colors': 0.74.87 - '@react-native/virtualized-lists': 0.74.87(@types/react@18.2.67)(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.2.67)(react@18.2.0))(react@18.2.0) + '@react-native/virtualized-lists': 0.74.87(@types/react@18.3.3)(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0))(react@18.2.0) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -29639,7 +29712,7 @@ snapshots: ws: 6.2.2 yargs: 17.7.2 optionalDependencies: - '@types/react': 18.2.67 + '@types/react': 18.3.3 transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' @@ -29647,9 +29720,8 @@ snapshots: - encoding - supports-color - utf-8-validate - optional: true - react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0): + react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native-community/cli': 13.6.9 @@ -29661,7 +29733,7 @@ snapshots: '@react-native/gradle-plugin': 0.74.87 '@react-native/js-polyfills': 0.74.87 '@react-native/normalize-colors': 0.74.87 - '@react-native/virtualized-lists': 0.74.87(@types/react@18.3.3)(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0))(react@18.2.0) + '@react-native/virtualized-lists': 0.74.87(@types/react@18.2.67)(react-native@0.74.5(@babel/core@7.24.0)(@types/react@18.2.67)(react@18.2.0))(react@18.2.0) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -29690,7 +29762,7 @@ snapshots: ws: 6.2.2 yargs: 17.7.2 optionalDependencies: - '@types/react': 18.3.3 + '@types/react': 18.2.67 transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' @@ -29698,6 +29770,7 @@ snapshots: - encoding - supports-color - utf-8-validate + optional: true react-reconciler@0.27.0(react@18.2.0): dependencies: From cb81cfae43fc6441e8ac0f389c36521d104f82a1 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 4 Nov 2024 22:23:12 -0800 Subject: [PATCH 08/31] Make overview cards composable --- .../overview/cards/FavoriteItems.tsx | 4 +- .../overview/cards/FileKindStats.tsx | 4 +- .../$libraryId/overview/cards/ItemsCard.tsx | 4 +- .../overview/cards/LibraryStats.tsx | 4 +- .../$libraryId/overview/cards/RecentItems.tsx | 4 +- ...tLocationsList.tsx => RecentLocations.tsx} | 8 +- interface/app/$libraryId/overview/index.tsx | 207 ++++++++++++------ interface/app/$libraryId/overview/store.tsx | 66 ++++++ interface/package.json | 3 +- pnpm-lock.yaml | 105 +++++++-- 10 files changed, 308 insertions(+), 101 deletions(-) rename interface/app/$libraryId/overview/cards/{RecentLocationsList.tsx => RecentLocations.tsx} (96%) create mode 100644 interface/app/$libraryId/overview/store.tsx diff --git a/interface/app/$libraryId/overview/cards/FavoriteItems.tsx b/interface/app/$libraryId/overview/cards/FavoriteItems.tsx index 8d7ef28d434e..2acbc13be064 100644 --- a/interface/app/$libraryId/overview/cards/FavoriteItems.tsx +++ b/interface/app/$libraryId/overview/cards/FavoriteItems.tsx @@ -2,7 +2,7 @@ import { useLibraryQuery } from '@sd/client'; import { ItemsCard } from './ItemsCard'; -const FavoriteItemsCard = () => { +const FavoriteItems = () => { const favoriteItemsQuery = useLibraryQuery([ 'search.objects', { @@ -24,4 +24,4 @@ const FavoriteItemsCard = () => { ); }; -export default FavoriteItemsCard; +export default FavoriteItems; diff --git a/interface/app/$libraryId/overview/cards/FileKindStats.tsx b/interface/app/$libraryId/overview/cards/FileKindStats.tsx index 249bc679af25..4566879a52b5 100644 --- a/interface/app/$libraryId/overview/cards/FileKindStats.tsx +++ b/interface/app/$libraryId/overview/cards/FileKindStats.tsx @@ -202,7 +202,7 @@ const FileKindStats: React.FC = () => { }, [cardWidth, sortedFileKinds]); return ( - + <> {loading ? (
@@ -303,7 +303,7 @@ const FileKindStats: React.FC = () => {
)} - + ); }; diff --git a/interface/app/$libraryId/overview/cards/ItemsCard.tsx b/interface/app/$libraryId/overview/cards/ItemsCard.tsx index 7d74aefde94c..446ea415de5f 100644 --- a/interface/app/$libraryId/overview/cards/ItemsCard.tsx +++ b/interface/app/$libraryId/overview/cards/ItemsCard.tsx @@ -60,7 +60,7 @@ export const ItemsCard = ({ const itemHeight = explorerSettings.settingsStore.gridItemSize + itemDetailsHeight; return ( - + <>
{t(title)} @@ -117,6 +117,6 @@ export const ItemsCard = ({ > {t(buttonText)} - + ); }; diff --git a/interface/app/$libraryId/overview/cards/LibraryStats.tsx b/interface/app/$libraryId/overview/cards/LibraryStats.tsx index e63d2f0c12d7..172ca9064d1c 100644 --- a/interface/app/$libraryId/overview/cards/LibraryStats.tsx +++ b/interface/app/$libraryId/overview/cards/LibraryStats.tsx @@ -195,7 +195,7 @@ const LibraryStats = () => { } ]; return ( - + <> {loading ? (
@@ -235,7 +235,7 @@ const LibraryStats = () => {
)} - + ); }; diff --git a/interface/app/$libraryId/overview/cards/RecentItems.tsx b/interface/app/$libraryId/overview/cards/RecentItems.tsx index 46890ea8e251..5375090a261b 100644 --- a/interface/app/$libraryId/overview/cards/RecentItems.tsx +++ b/interface/app/$libraryId/overview/cards/RecentItems.tsx @@ -2,7 +2,7 @@ import { ObjectOrder, objectOrderingKeysSchema, useLibraryQuery } from '@sd/clie import { ItemsCard } from './ItemsCard'; -const RecentItemsCard = () => { +const RecentFiles = () => { const recentItemsQuery = useLibraryQuery([ 'search.objects', { @@ -24,4 +24,4 @@ const RecentItemsCard = () => { ); }; -export default RecentItemsCard; +export default RecentFiles; diff --git a/interface/app/$libraryId/overview/cards/RecentLocationsList.tsx b/interface/app/$libraryId/overview/cards/RecentLocations.tsx similarity index 96% rename from interface/app/$libraryId/overview/cards/RecentLocationsList.tsx rename to interface/app/$libraryId/overview/cards/RecentLocations.tsx index 721f5735837d..664c48f4ba3e 100644 --- a/interface/app/$libraryId/overview/cards/RecentLocationsList.tsx +++ b/interface/app/$libraryId/overview/cards/RecentLocations.tsx @@ -10,7 +10,7 @@ import { useLocale } from '~/hooks'; import { OverviewCard } from '..'; import { AddLocationButton } from '../../settings/library/locations/AddLocationButton'; -const RecentLocationsList = () => { +const RecentLocations = () => { const navigate = useNavigate(); const { t } = useLocale(); const onlineLocations = useOnlineLocations(); @@ -35,7 +35,7 @@ const RecentLocationsList = () => { const locations = locationsQuery.data ?? []; return ( - + <>
{t('Recent Locations')} {locations.length} total @@ -91,8 +91,8 @@ const RecentLocationsList = () => { ))}
-
+ ); }; -export default RecentLocationsList; +export default RecentLocations; diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 1e4b3abeb7b0..4db94d133b56 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -1,26 +1,11 @@ -import { keepPreviousData } from '@tanstack/react-query'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; +import { ArrowsOutCardinal, DotsThreeVertical, GearSix } from '@phosphor-icons/react'; import clsx from 'clsx'; -import { Key, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { HardwareModel, useBridgeQuery, useLibraryQuery } from '@sd/client'; -import { Card } from '@sd/ui'; -import { useAccessToken, useLocale, useOperatingSystem } from '~/hooks'; -import { useRouteTitle } from '~/hooks/useRouteTitle'; -import { hardwareModelToIcon } from '~/util/hardware'; +import { useSnapshot } from 'valtio'; +import { Button, Card, DropdownMenu } from '@sd/ui'; +import { useLocale } from '~/hooks'; -import { SearchContextProvider, useSearchFromSearchParams } from '../search'; -import SearchBar from '../search/SearchBar'; -import { AddLocationButton } from '../settings/library/locations/AddLocationButton'; -import { TopBarPortal } from '../TopBar/Portal'; -import TopBarOptions from '../TopBar/TopBarOptions'; -import FavoriteItems from './cards/FavoriteItems'; -import FileKindStatistics from './cards/FileKindStats'; -import LibraryStatistics from './cards/LibraryStats'; -import RecentItems from './cards/RecentItems'; -import RecentLocationsList from './cards/RecentLocationsList'; -import OverviewSection from './Layout/Section'; -import NewCard from './NewCard'; -import StatisticItem from './StatCard'; +import { CardConfig, overviewStore, type CardSize } from './store'; export interface FileKind { kind: number; @@ -31,80 +16,160 @@ export interface FileKind { export function OverviewCard({ children, - className + className, + size = 'medium', + onSizeChange, + id, + dragHandleProps }: { children: React.ReactNode; className?: string; + size?: CardSize; + onSizeChange?: (size: CardSize) => void; + id: string; + dragHandleProps?: any; }) { + const { t } = useLocale(); + return ( +
+
+ +
+ + + + } + side="left" + sideOffset={5} + alignOffset={-10} + > + onSizeChange?.('small')}> + {t('small')} + + onSizeChange?.('medium')}> + {t('medium')} + + onSizeChange?.('large')}> + {t('large')} + + +
{children}
); } export const Component = () => { - useRouteTitle('Overview'); - const os = useOperatingSystem(); - + const store = useSnapshot(overviewStore); const { t } = useLocale(); - const accessToken = useAccessToken(); - const locationsQuery = useLibraryQuery(['locations.list'], { - placeholderData: keepPreviousData - }); - const locations = locationsQuery.data ?? []; + const handleCardSizeChange = (id: string, size: CardSize) => { + const cardIndex = overviewStore.cards.findIndex((card) => card.id === id); + if (cardIndex !== -1 && overviewStore.cards[cardIndex]?.id) { + overviewStore.cards[cardIndex] = { + ...overviewStore.cards[cardIndex], + size + }; + } + }; - // not sure if we'll need the node state in the future, as it should be returned with the cloud.devices.list query - // const { data: node } = useBridgeQuery(['nodeState']); - const cloudDevicesList = useBridgeQuery(['cloud.devices.list']); + const handleCardToggle = (id: string) => { + const cardIndex = overviewStore.cards.findIndex((card) => card.id === id); + if (cardIndex !== -1 && overviewStore.cards[cardIndex]?.id) { + overviewStore.cards[cardIndex].enabled = !overviewStore.cards[cardIndex].enabled; + } + }; - useEffect(() => { - const interval = setInterval(async () => { - await cloudDevicesList.refetch(); - }, 10000); - return () => clearInterval(interval); - }, []); - const { data: node } = useBridgeQuery(['nodeState']); - const stats = useLibraryQuery(['library.statistics']); + const handleDragEnd = (result: any) => { + if (!result.destination) return; - const search = useSearchFromSearchParams({ defaultTarget: 'paths' }); + const items = Array.from(overviewStore.cards); + const [reorderedItem] = items.splice(result.source.index, 1); + if (reorderedItem) { + items.splice(result.destination.index, 0, reorderedItem); + } + + overviewStore.cards = items; + }; return ( - -
- - - {t('library_overview')} - -
+
+
+ + + } - center={} - right={os === 'windows' && } - /> -
-
- -
-
- -
-
- -
- -
- -
-
+ side="bottom" + sideOffset={5} + > + {store.cards.map((card) => ( + handleCardToggle(card.id)}> + {card.title} + + ))} +
- + + + + {(provided) => ( +
+ {store.cards + .filter((card) => card.enabled) + .map((card, index) => ( + + {(provided) => ( +
+ + handleCardSizeChange(card.id, size) + } + dragHandleProps={provided.dragHandleProps} + > + {card.component} + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
); }; diff --git a/interface/app/$libraryId/overview/store.tsx b/interface/app/$libraryId/overview/store.tsx new file mode 100644 index 000000000000..eaae086dc252 --- /dev/null +++ b/interface/app/$libraryId/overview/store.tsx @@ -0,0 +1,66 @@ +import { proxy, subscribe, useSnapshot } from 'valtio'; +import { subscribeKey } from 'valtio/utils'; +import { valtioPersist } from '@sd/client'; + +import FavoriteItems from './cards/FavoriteItems'; +import FileKindStats from './cards/FileKindStats'; +import LibraryStatistics from './cards/LibraryStats'; +import RecentFiles from './cards/RecentItems'; +import RecentLocations from './cards/RecentLocations'; + +export type CardSize = 'small' | 'medium' | 'large'; + +export interface CardConfig { + id: string; + enabled: boolean; + size: CardSize; + component: JSX.Element; + title: string; +} + +interface OverviewStore { + cards: CardConfig[]; +} + +export const overviewStore = proxy({ + cards: [ + { + id: 'library-stats', + enabled: true, + size: 'large', + component: , + title: 'Library Statistics' + }, + { + id: 'favorites', + enabled: true, + size: 'small', + component: , + title: 'Favorites' + }, + { + id: 'file-kind-stats', + enabled: true, + size: 'small', + component: , + title: 'File Kinds' + }, + { + id: 'recent-files', + enabled: true, + size: 'small', + component: , + title: 'Recent Files' + }, + { + id: 'recent-locations', + enabled: true, + size: 'small', + component: , + title: 'Recent Locations' + } + ] +}); + +// Persist store +export const layoutStore = valtioPersist('sd-overview', overviewStore); diff --git a/interface/package.json b/interface/package.json index e4ec0bdb3381..2802665aeae2 100644 --- a/interface/package.json +++ b/interface/package.json @@ -12,8 +12,8 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^1.7.17", + "@hello-pangea/dnd": "^17.0.0", "@icons-pack/react-simple-icons": "^9.1.0", - "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", "@phosphor-icons/react": "^2.1.7", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -27,6 +27,7 @@ "@sd/client": "workspace:*", "@sd/ui": "workspace:*", "@sentry/browser": "^7.74.1", + "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", "@tanstack/react-query": "^5.59", "@tanstack/react-query-devtools": "^5.59", "@tanstack/react-table": "^8.20.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e49f6ccb7bf..7ff3c326047e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -710,6 +710,9 @@ importers: '@headlessui/react': specifier: ^1.7.17 version: 1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@hello-pangea/dnd': + specifier: ^17.0.0 + version: 17.0.0(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@icons-pack/react-simple-icons': specifier: ^9.1.0 version: 9.4.0(react@18.2.0) @@ -3438,6 +3441,12 @@ packages: peerDependencies: tailwindcss: ^3.0 + '@hello-pangea/dnd@17.0.0': + resolution: {integrity: sha512-LDDPOix/5N0j5QZxubiW9T0M0+1PR0rTDWeZF5pu1Tz91UQnuVK4qQ/EjY83Qm2QeX0eM8qDXANfDh3VVqtR4Q==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@heroicons/react@2.1.3': resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==} peerDependencies: @@ -6209,6 +6218,9 @@ packages: '@types/unist@3.0.2': resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + '@types/use-sync-external-store@0.0.3': + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -7631,6 +7643,9 @@ packages: engines: {node: '>=18'} hasBin: true + css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-line-break@2.1.0: resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} @@ -10664,6 +10679,9 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} @@ -12075,6 +12093,9 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} @@ -12370,6 +12391,18 @@ packages: peerDependencies: react: ^18.0.0 + react-redux@9.1.2: + resolution: {integrity: sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==} + peerDependencies: + '@types/react': ^18.2.25 + react: ^18.0 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -14141,6 +14174,11 @@ packages: '@types/react': optional: true + use-memo-one@1.1.3: + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-resize-observer@9.1.0: resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} peerDependencies: @@ -17337,7 +17375,7 @@ snapshots: '@expo/cli@0.18.30(expo-modules-autolinking@1.11.3)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@expo/code-signing-certificates': 0.0.5 '@expo/config': 9.0.4 '@expo/config-plugins': 8.0.10 @@ -17738,6 +17776,20 @@ snapshots: dependencies: tailwindcss: 3.4.1 + '@hello-pangea/dnd@17.0.0(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.25.7 + css-box-model: 1.2.1 + memoize-one: 6.0.0 + raf-schd: 4.0.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-redux: 9.1.2(@types/react@18.2.67)(react@18.2.0)(redux@5.0.1) + redux: 5.0.1 + use-memo-one: 1.1.3(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + '@heroicons/react@2.1.3(react@18.2.0)': dependencies: react: 18.2.0 @@ -18441,7 +18493,7 @@ snapshots: '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -18547,7 +18599,7 @@ snapshots: '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.67)(react@18.2.0) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -18598,7 +18650,7 @@ snapshots: '@radix-ui/react-focus-scope@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.67)(react@18.2.0) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.67)(react@18.2.0) @@ -18681,7 +18733,7 @@ snapshots: '@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@floating-ui/react-dom': 2.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.67)(react@18.2.0) @@ -18719,7 +18771,7 @@ snapshots: '@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -18790,7 +18842,7 @@ snapshots: '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.67)(react@18.2.0) @@ -18964,7 +19016,7 @@ snapshots: '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.67)(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.67)(react@18.2.0) react: 18.2.0 optionalDependencies: @@ -18986,7 +19038,7 @@ snapshots: '@radix-ui/react-use-rect@1.0.1(@types/react@18.2.67)(react@18.2.0)': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@radix-ui/rect': 1.0.1 react: 18.2.0 optionalDependencies: @@ -19012,7 +19064,7 @@ snapshots: '@radix-ui/rect@1.0.1': dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@react-native-async-storage/async-storage@1.23.1(react-native@0.74.5(@babel/core@7.24.0)(@babel/preset-env@7.25.4(@babel/core@7.24.0))(@types/react@18.3.3)(react@18.2.0))': dependencies: @@ -21159,7 +21211,7 @@ snapshots: '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.23.5 - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@types/aria-query': 5.0.4 aria-query: 5.1.3 chalk: 4.1.2 @@ -21170,7 +21222,7 @@ snapshots: '@testing-library/jest-dom@6.4.2': dependencies: '@adobe/css-tools': 4.3.3 - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 aria-query: 5.3.0 chalk: 3.0.0 css.escape: 1.5.1 @@ -21666,6 +21718,8 @@ snapshots: '@types/unist@3.0.2': {} + '@types/use-sync-external-store@0.0.3': {} + '@types/uuid@9.0.8': {} '@types/webxr@0.5.14': {} @@ -23525,6 +23579,10 @@ snapshots: semver: 7.6.3 tinyglobby: 0.2.9 + css-box-model@1.2.1: + dependencies: + tiny-invariant: 1.3.3 + css-line-break@2.1.0: dependencies: utrie: 1.0.2 @@ -24558,7 +24616,7 @@ snapshots: eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.1): dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 aria-query: 5.3.0 array-includes: 3.1.7 array.prototype.flatmap: 1.3.2 @@ -27365,7 +27423,7 @@ snapshots: mdx-bundler@9.2.1(esbuild@0.21.5): dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 '@esbuild-plugins/node-resolve': 0.1.4(esbuild@0.21.5) '@fal-works/esbuild-plugin-global-externals': 2.1.2 '@mdx-js/esbuild': 2.3.0(esbuild@0.21.5) @@ -27398,6 +27456,8 @@ snapshots: memoize-one@5.2.1: {} + memoize-one@6.0.0: {} + memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 @@ -28989,7 +29049,7 @@ snapshots: polished@4.3.1: dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.25.7 popmotion@11.0.3: dependencies: @@ -29294,6 +29354,8 @@ snapshots: dependencies: inherits: 2.0.4 + raf-schd@4.0.3: {} + raf@3.4.1: dependencies: performance-now: 2.1.0 @@ -29778,6 +29840,15 @@ snapshots: react: 18.2.0 scheduler: 0.21.0 + react-redux@9.1.2(@types/react@18.2.67)(react@18.2.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.3 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.67 + redux: 5.0.1 + react-refresh@0.14.2: {} react-refresh@0.4.3: {} @@ -31854,6 +31925,10 @@ snapshots: optionalDependencies: '@types/react': 18.2.67 + use-memo-one@1.1.3(react@18.2.0): + dependencies: + react: 18.2.0 + use-resize-observer@9.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@juggle/resize-observer': 3.4.0 From fe218fc2e8ca7479c3c55e44a10e7cc9a8493acd Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 5 Nov 2024 01:09:58 -0800 Subject: [PATCH 09/31] Implement lazy loading for overview cards and add CardWrapper for hot reloading support. Update store structure for better handling --- .../$libraryId/overview/cards/DeviceList.tsx | 49 +++++++++++++++++++ interface/app/$libraryId/overview/index.tsx | 25 ++++++++-- interface/app/$libraryId/overview/store.tsx | 38 ++++++-------- 3 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 interface/app/$libraryId/overview/cards/DeviceList.tsx diff --git a/interface/app/$libraryId/overview/cards/DeviceList.tsx b/interface/app/$libraryId/overview/cards/DeviceList.tsx new file mode 100644 index 000000000000..d3b5fcc781e2 --- /dev/null +++ b/interface/app/$libraryId/overview/cards/DeviceList.tsx @@ -0,0 +1,49 @@ +import { Desktop } from '@phosphor-icons/react'; +import { useNavigate } from 'react-router'; +import { Device, HardwareModel, useLibraryQuery } from '@sd/client'; +import { Button, buttonStyles, Tooltip } from '@sd/ui'; +import { Icon, Icon as SdIcon } from '~/components'; +import { useLocale } from '~/hooks'; +import { hardwareModelToIcon } from '~/util/hardware'; + +const DeviceList = () => { + const navigate = useNavigate(); + const { t } = useLocale(); + + const devicesQuery = useLibraryQuery(['devices.list'], {}); + const devices = devicesQuery.data ?? []; + + return ( + <> +
+ {t('Devices')} + {devices.length} total +
+ +
+ {devices?.map((device) => ( + + ))} +
+ + ); +}; + +export default DeviceList; diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 4db94d133b56..c43c8a852717 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -1,6 +1,7 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { ArrowsOutCardinal, DotsThreeVertical, GearSix } from '@phosphor-icons/react'; import clsx from 'clsx'; +import { createElement, lazy, Suspense, useEffect } from 'react'; import { useSnapshot } from 'valtio'; import { Button, Card, DropdownMenu } from '@sd/ui'; import { useLocale } from '~/hooks'; @@ -14,6 +15,16 @@ export interface FileKind { total_bytes: bigint; } +// Define components mapping with component types instead of JSX.Element +const CARD_COMPONENTS: Record = { + 'library-stats': lazy(() => import('./cards/LibraryStats')), + 'favorites': lazy(() => import('./cards/FavoriteItems')), + 'device-list': lazy(() => import('./cards/DeviceList')), + 'file-kind-stats': lazy(() => import('./cards/FileKindStats')), + 'recent-files': lazy(() => import('./cards/RecentItems')), + 'recent-locations': lazy(() => import('./cards/RecentLocations')) +}; + export function OverviewCard({ children, className, @@ -76,6 +87,12 @@ export function OverviewCard({ ); } +// Add a wrapper component to handle hot reloading +const CardWrapper = ({ id }: { id: string }) => { + const CardComponent = CARD_COMPONENTS[id]; + return CardComponent ? : null; +}; + export const Component = () => { const store = useSnapshot(overviewStore); const { t } = useLocale(); @@ -110,8 +127,8 @@ export const Component = () => { }; return ( -
-
+
+
@@ -159,7 +176,9 @@ export const Component = () => { } dragHandleProps={provided.dragHandleProps} > - {card.component} + Loading...
}> + +
)} diff --git a/interface/app/$libraryId/overview/store.tsx b/interface/app/$libraryId/overview/store.tsx index eaae086dc252..4d7f8dbd0f7d 100644 --- a/interface/app/$libraryId/overview/store.tsx +++ b/interface/app/$libraryId/overview/store.tsx @@ -2,19 +2,12 @@ import { proxy, subscribe, useSnapshot } from 'valtio'; import { subscribeKey } from 'valtio/utils'; import { valtioPersist } from '@sd/client'; -import FavoriteItems from './cards/FavoriteItems'; -import FileKindStats from './cards/FileKindStats'; -import LibraryStatistics from './cards/LibraryStats'; -import RecentFiles from './cards/RecentItems'; -import RecentLocations from './cards/RecentLocations'; - export type CardSize = 'small' | 'medium' | 'large'; export interface CardConfig { id: string; enabled: boolean; size: CardSize; - component: JSX.Element; title: string; } @@ -22,45 +15,46 @@ interface OverviewStore { cards: CardConfig[]; } -export const overviewStore = proxy({ +export const state = proxy({ cards: [ { id: 'library-stats', enabled: true, size: 'large', - component: , title: 'Library Statistics' }, { id: 'favorites', enabled: true, size: 'small', - component: , title: 'Favorites' }, { - id: 'file-kind-stats', + id: 'recent-locations', enabled: true, - size: 'small', - component: , - title: 'File Kinds' + size: 'medium', + title: 'Recent Locations' }, { - id: 'recent-files', + id: 'device-list', enabled: true, size: 'small', - component: , - title: 'Recent Files' + title: 'Devices' }, { - id: 'recent-locations', + id: 'file-kind-stats', enabled: true, - size: 'small', - component: , - title: 'Recent Locations' + size: 'medium', + title: 'File Kinds' + }, + { + id: 'recent-files', + enabled: true, + size: 'medium', + title: 'Recent Files' } ] }); // Persist store -export const layoutStore = valtioPersist('sd-overview', overviewStore); +export const overviewStore = valtioPersist('sd-overview-layout', state); From 167170d7b7bc89292b0e2efbf1b6a0d8be563612 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 5 Nov 2024 05:58:44 -0800 Subject: [PATCH 10/31] use default card configurations and fix responsive grid sizings --- .../$libraryId/overview/cards/DeviceList.tsx | 5 - .../overview/cards/FileKindStats.tsx | 21 ++-- .../$libraryId/overview/cards/ItemsCard.tsx | 6 - .../overview/cards/RecentLocations.tsx | 104 ++++++++--------- interface/app/$libraryId/overview/index.tsx | 108 +++++++++++------- interface/app/$libraryId/overview/store.tsx | 79 +++++++------ 6 files changed, 174 insertions(+), 149 deletions(-) diff --git a/interface/app/$libraryId/overview/cards/DeviceList.tsx b/interface/app/$libraryId/overview/cards/DeviceList.tsx index d3b5fcc781e2..011a79d70d23 100644 --- a/interface/app/$libraryId/overview/cards/DeviceList.tsx +++ b/interface/app/$libraryId/overview/cards/DeviceList.tsx @@ -15,11 +15,6 @@ const DeviceList = () => { return ( <> -
- {t('Devices')} - {devices.length} total -
-
{devices?.map((device) => (
diff --git a/interface/app/$libraryId/overview/cards/ItemsCard.tsx b/interface/app/$libraryId/overview/cards/ItemsCard.tsx index 446ea415de5f..f7b3e07ee6f1 100644 --- a/interface/app/$libraryId/overview/cards/ItemsCard.tsx +++ b/interface/app/$libraryId/overview/cards/ItemsCard.tsx @@ -61,12 +61,6 @@ export const ItemsCard = ({ return ( <> -
- {t(title)} - - {items.length > 0 && `${displayItems.length} shown`} - -
{ const { t } = useLocale(); const onlineLocations = useOnlineLocations(); - const devicesQuery = useLibraryQuery(['devices.list']); + const devicesQuery = useLibraryQuery(['devices.list'], { + // placeholderData: keepPreviousData + }); // eslint-disable-next-line react-hooks/exhaustive-deps const devices = devicesQuery.data ?? []; @@ -30,65 +32,65 @@ const RecentLocations = () => { }, [devices]); const locationsQuery = useLibraryQuery(['locations.list'], { - placeholderData: keepPreviousData + // placeholderData: keepPreviousData }); const locations = locationsQuery.data ?? []; return ( <> -
- {t('Recent Locations')} - {locations.length} total -
-
- {locations.slice(0, 6).map((location) => ( - - ))} +
+ + + )) + ) : ( +
No locations found
+ )}
diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index c43c8a852717..246e9d0f718b 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -6,7 +6,7 @@ import { useSnapshot } from 'valtio'; import { Button, Card, DropdownMenu } from '@sd/ui'; import { useLocale } from '~/hooks'; -import { CardConfig, overviewStore, type CardSize } from './store'; +import { CardConfig, defaultCards, overviewStore, type CardSize } from './store'; export interface FileKind { kind: number; @@ -25,13 +25,59 @@ const CARD_COMPONENTS: Record = { 'recent-locations': lazy(() => import('./cards/RecentLocations')) }; +interface CardHeadingProps { + title: string; + onSizeChange?: (size: CardSize) => void; + dragHandleProps?: any; +} + +function CardHeading({ title, onSizeChange, dragHandleProps }: CardHeadingProps) { + const { t } = useLocale(); + + return ( +
+
+
+ +
+ {title} +
+ + + + + } + side="left" + sideOffset={5} + alignOffset={-10} + > + onSizeChange?.('small')}> + {t('small')} + + onSizeChange?.('medium')}> + {t('medium')} + + onSizeChange?.('large')}> + {t('large')} + + +
+ ); +} + export function OverviewCard({ children, className, size = 'medium', onSizeChange, id, - dragHandleProps + dragHandleProps, + title }: { children: React.ReactNode; className?: string; @@ -39,49 +85,20 @@ export function OverviewCard({ onSizeChange?: (size: CardSize) => void; id: string; dragHandleProps?: any; + title: string; }) { - const { t } = useLocale(); - return ( -
-
- -
- - - - } - side="left" - sideOffset={5} - alignOffset={-10} - > - onSizeChange?.('small')}> - {t('small')} - - onSizeChange?.('medium')}> - {t('medium')} - - onSizeChange?.('large')}> - {t('large')} - - -
+ {children}
); @@ -126,6 +143,10 @@ export const Component = () => { overviewStore.cards = items; }; + const handleResetCards = () => { + overviewStore.cards = defaultCards; + }; + return (
@@ -143,6 +164,8 @@ export const Component = () => { {card.title} ))} + + handleResetCards()}>Reset
@@ -152,7 +175,7 @@ export const Component = () => {
{store.cards .filter((card) => card.enabled) @@ -164,8 +187,12 @@ export const Component = () => { {...provided.draggableProps} className={clsx('w-full', { 'col-span-1': card.size === 'small', - 'col-span-2': card.size === 'medium', - 'col-span-4': card.size === 'large' + 'col-span-1 sm:col-span-1': + card.size === 'small', + 'col-span-1 md:col-span-1 xl:col-span-2': + card.size === 'medium', + 'col-span-1 sm:col-span-2 lg:col-span-4': + card.size === 'large' })} > { handleCardSizeChange(card.id, size) } dragHandleProps={provided.dragHandleProps} + title={card.title} > Loading...
}> diff --git a/interface/app/$libraryId/overview/store.tsx b/interface/app/$libraryId/overview/store.tsx index 4d7f8dbd0f7d..73576aecc87f 100644 --- a/interface/app/$libraryId/overview/store.tsx +++ b/interface/app/$libraryId/overview/store.tsx @@ -15,45 +15,48 @@ interface OverviewStore { cards: CardConfig[]; } +export const defaultCards: CardConfig[] = [ + { + id: 'library-stats', + enabled: true, + size: 'medium', + title: 'Library Statistics' + }, + { + id: 'file-kind-stats', + enabled: true, + size: 'medium', + title: 'File Kinds' + }, + { + id: 'favorites', + enabled: true, + size: 'small', + title: 'Favorites' + }, + { + id: 'recent-locations', + enabled: true, + size: 'medium', + title: 'Recent Locations' + }, + { + id: 'device-list', + enabled: true, + size: 'small', + title: 'Devices' + }, + + { + id: 'recent-files', + enabled: true, + size: 'medium', + title: 'Recent Files' + } +]; + export const state = proxy({ - cards: [ - { - id: 'library-stats', - enabled: true, - size: 'large', - title: 'Library Statistics' - }, - { - id: 'favorites', - enabled: true, - size: 'small', - title: 'Favorites' - }, - { - id: 'recent-locations', - enabled: true, - size: 'medium', - title: 'Recent Locations' - }, - { - id: 'device-list', - enabled: true, - size: 'small', - title: 'Devices' - }, - { - id: 'file-kind-stats', - enabled: true, - size: 'medium', - title: 'File Kinds' - }, - { - id: 'recent-files', - enabled: true, - size: 'medium', - title: 'Recent Files' - } - ] + cards: defaultCards }); // Persist store From e1a0988144337b89ec3fb7907fe03253f2435afd Mon Sep 17 00:00:00 2001 From: James Pine Date: Tue, 5 Nov 2024 19:44:16 -0800 Subject: [PATCH 11/31] Implement .sdvolume file read/write operations. Enhance storage bar responsiveness. --- .cspell/project_words.txt | 1 + core/src/location/error.rs | 2 + core/src/location/mod.rs | 32 +++ core/src/volume/actor.rs | 36 ++- core/src/volume/error.rs | 19 ++ core/src/volume/types.rs | 216 +++++++++++++++++- .../app/$libraryId/overview/StorageBar.tsx | 41 ++-- .../$libraryId/overview/cards/DeviceList.tsx | 8 +- .../overview/cards/FileKindStats.tsx | 55 ++--- .../$libraryId/overview/cards/ItemsCard.tsx | 2 +- .../overview/cards/LibraryStats.tsx | 12 +- .../$libraryId/overview/cards/RecentItems.tsx | 2 +- .../overview/cards/RecentLocations.tsx | 18 +- interface/app/$libraryId/overview/index.tsx | 16 +- interface/app/$libraryId/settings/Sidebar.tsx | 2 +- interface/util/hardware.ts | 19 ++ packages/ui/src/CheckBox.tsx | 2 +- 17 files changed, 402 insertions(+), 81 deletions(-) diff --git a/.cspell/project_words.txt b/.cspell/project_words.txt index d0d6976e8fb4..61f51f0cdf39 100644 --- a/.cspell/project_words.txt +++ b/.cspell/project_words.txt @@ -59,6 +59,7 @@ rspcws rywalker sanjay sdvol +sdvolume sharma skippable spacedrive diff --git a/core/src/location/error.rs b/core/src/location/error.rs index 3e0f2b80ea3f..21779bd86ace 100644 --- a/core/src/location/error.rs +++ b/core/src/location/error.rs @@ -80,6 +80,8 @@ pub enum LocationError { InvalidScanStateValue(i32), #[error(transparent)] Sync(#[from] sd_core_sync::Error), + #[error("other error: {0}")] + Other(String), } impl From for rspc::Error { diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index e89639285267..35e47f18577c 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -166,6 +166,7 @@ impl LocationCreateArgs { let uuid = Uuid::now_v7(); let location = create_location( + node, library, uuid, &self.path, @@ -249,6 +250,7 @@ impl LocationCreateArgs { let uuid = Uuid::now_v7(); let location = create_location( + node, library, uuid, &self.path, @@ -705,6 +707,7 @@ pub(crate) fn normalize_path(path: impl AsRef) -> io::Result<(String, Stri } async fn create_location( + _node: &Node, library @ Library { db, sync, .. }: &Library, location_pub_id: Uuid, location_path: impl AsRef, @@ -733,6 +736,35 @@ async fn create_location( return Ok(None); } + // let library_arc = Arc::new(*library); + // // Track the volume before creating the location + // // Get the volume fingerprint for the location path + // let system_volumes = node + // .volumes + // .list_system_volumes(library_arc) + // .await + // .map_err(|e| { + // warn!("Failed to list system volumes: {}", e); + // LocationError::Other(e.to_string()) + // })?; + + // for volume in system_volumes { + // if let Some(mount_point) = volume.mount_point.as_ref() { + // if location_path.starts_with(mount_point) { + // // Track this volume since we're creating a location on it + // if let Err(e) = node + // .volumes + // .track_volume(volume.fingerprint, library.clone()) + // .await + // { + // warn!("Failed to track volume for new location: {}", e); + // // Continue with location creation even if volume tracking fails + // } + // break; + // } + // } + // } + let (sync_values, mut db_params) = [ sync_db_entry!(&name, location::name), sync_db_entry!(path, location::path), diff --git a/core/src/volume/actor.rs b/core/src/volume/actor.rs index 60fb456ab0db..fcb0b99cdaab 100644 --- a/core/src/volume/actor.rs +++ b/core/src/volume/actor.rs @@ -450,17 +450,45 @@ impl VolumeManagerActor { let state = self.state.write().await; let device_pub_id = self.ctx.device_id.clone(); - // Find the volume in our current system volumes let mut registry = state.registry.write().await; let mut volume = match registry.get_volume_mut(&fingerprint) { Some(v) => v.clone(), None => return Err(VolumeError::InvalidFingerprint(fingerprint.clone())), }; - // Create in database with current device association - volume.create(&library.db, device_pub_id.into()).await?; + // Check for existing .sdvolume file + if let Some(volume_file) = volume.read_volume_file().await? { + // If pub_id exists in database, use that volume record + if let Some(existing_volume) = library + .db + .volume() + .find_unique(volume::pub_id::equals(volume_file.pub_id.clone())) + .exec() + .await? + .map(Volume::from) + { + // Update volume with existing data + volume = Volume::merge_with_db(&volume, &existing_volume); + registry.update_volume(volume.clone()); + } + } + + // Create or update in database with sync + if volume.pub_id.is_none() { + volume = volume + .sync_db_create(&library, device_pub_id.into()) + .await?; + } else { + volume.sync_db_update(&library).await?; + } + + // Write .sdvolume file + volume.write_volume_file().await?; + + // Update registry with final state + registry.update_volume(volume.clone()); - // Spawn a background task to perform the speed test + // Spawn speed test let event_tx = self.event_tx.clone(); let mut volume = volume.clone(); tokio::spawn(async move { diff --git a/core/src/volume/error.rs b/core/src/volume/error.rs index ecc0313225ed..e62227f4e5e5 100644 --- a/core/src/volume/error.rs +++ b/core/src/volume/error.rs @@ -117,6 +117,25 @@ pub enum VolumeError { /// Resource exhausted #[error("Resource exhausted: {0}")] ResourceExhausted(String), + + /// Volume is not tracked + #[error("Volume is not tracked")] + NotTracked, + + /// Volume fingerprint is missing + #[error("Volume fingerprint is missing")] + MissingFingerprint, + + /// IO error + #[error("IO error: {0}")] + IoError(std::io::Error), + + /// Serialization error + #[error("Serialization error: {0}")] + SerializationError(serde_json::Error), + + #[error(transparent)] + Sync(#[from] sd_core_sync::Error), } /// Specific kinds of speed test errors diff --git a/core/src/volume/types.rs b/core/src/volume/types.rs index 91d4cb7430eb..4d3444f9a47f 100644 --- a/core/src/volume/types.rs +++ b/core/src/volume/types.rs @@ -1,11 +1,15 @@ use super::error::VolumeError; -use crate::volume::speed::SpeedTest; +use crate::library::Library; use sd_core_sync::DevicePubId; -use sd_prisma::prisma::{ - device, - volume::{self}, - PrismaClient, +use sd_prisma::{ + prisma::{ + device, + volume::{self}, + PrismaClient, + }, + prisma_sync, }; +use sd_sync::*; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use specta::Type; @@ -29,7 +33,7 @@ impl VolumeFingerprint { hasher.update(&volume.total_bytes_capacity.to_be_bytes()); hasher.update(volume.file_system.to_string().as_bytes()); // These are all properties that are unique to a volume and unlikely to change - // If a .spacedrive file is found in the volume, and is fingerprint does not match, + // If a `.sdvolume` file is found in the volume, and is fingerprint does not match, // but the `pub_id` is the same, we can update the values and regenerate the fingerprint // preserving the tracked instance of the volume Self(hasher.finalize().as_bytes().to_vec()) @@ -348,6 +352,199 @@ impl Volume { .await?; Ok(()) } + + /// Writes the .sdvolume file to the volume's root + pub async fn write_volume_file(&self) -> Result<(), VolumeError> { + if !self.is_mounted || self.read_only { + return Ok(()); // Skip if volume isn't mounted or is read-only + } + + let fingerprint = self + .fingerprint + .as_ref() + .ok_or(VolumeError::MissingFingerprint)?; + let pub_id = self.pub_id.as_ref().ok_or(VolumeError::NotTracked)?; + + let volume_file = SdVolumeFile { + pub_id: pub_id.clone(), + fingerprint: fingerprint.to_string(), + last_seen: chrono::Utc::now(), + }; + + let path = self.mount_point.join(".sdvolume"); + let file = tokio::fs::File::create(&path) + .await + .map_err(|e| VolumeError::IoError(e))?; + + serde_json::to_writer(file.into_std().await, &volume_file) + .map_err(|e| VolumeError::SerializationError(e))?; + + Ok(()) + } + + /// Reads the .sdvolume file from the volume's root if it exists + pub async fn read_volume_file(&self) -> Result, VolumeError> { + if !self.is_mounted { + return Ok(None); + } + + let path = self.mount_point.join(".sdvolume"); + if !path.exists() { + return Ok(None); + } + + let file = tokio::fs::File::open(&path) + .await + .map_err(|e| VolumeError::IoError(e))?; + + let volume_file = serde_json::from_reader(file.into_std().await) + .map_err(|e| VolumeError::SerializationError(e))?; + + Ok(Some(volume_file)) + } + + pub async fn sync_db_create( + &self, + library: &Library, + device_pub_id: Vec, + ) -> Result { + let Library { db, sync, .. } = library; + let pub_id = Uuid::now_v7().as_bytes().to_vec(); + + let device_id = db + .device() + .find_unique(device::pub_id::equals(device_pub_id.clone())) + .select(device::select!({ id })) + .exec() + .await? + .ok_or(VolumeError::DeviceNotFound(device_pub_id))? + .id; + + let (sync_params, db_params) = [ + sync_db_entry!(self.name.clone(), volume::name), + sync_db_entry!( + self.mount_point.to_str().unwrap_or_default().to_string(), + volume::mount_point + ), + sync_db_entry!(self.mount_type.to_string(), volume::mount_type), + sync_db_entry!( + self.total_bytes_capacity.to_string(), + volume::total_bytes_capacity + ), + sync_db_entry!( + self.total_bytes_available.to_string(), + volume::total_bytes_available + ), + sync_db_entry!(self.disk_type.to_string(), volume::disk_type), + sync_db_entry!(self.file_system.to_string(), volume::file_system), + sync_db_entry!(self.is_mounted, volume::is_mounted), + sync_db_entry!( + self.read_speed_mbps.unwrap_or(0) as i64, + volume::read_speed_mbps + ), + sync_db_entry!( + self.write_speed_mbps.unwrap_or(0) as i64, + volume::write_speed_mbps + ), + sync_db_entry!(self.read_only, volume::read_only), + sync_db_entry!( + self.error_status.clone().unwrap_or_default(), + volume::error_status + ), + ] + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + // Add device connection to db_params + let mut db_params = db_params; + db_params.push(volume::device::connect(device::id::equals(device_id))); + + let volume = sync + .write_op( + db, + sync.shared_create( + prisma_sync::volume::SyncId { + pub_id: pub_id.clone(), + }, + sync_params, + ), + db.volume().create(pub_id, db_params), + ) + .await?; + + Ok(volume.into()) + } + + pub async fn sync_db_update(&self, library: &Library) -> Result<(), VolumeError> { + let Library { db, sync, .. } = library; + let pub_id = self.pub_id.as_ref().ok_or(VolumeError::NotTracked)?; + + let (sync_params, db_params) = [ + sync_db_entry!(self.name.clone(), volume::name), + sync_db_entry!( + self.mount_point.to_str().unwrap_or_default().to_string(), + volume::mount_point + ), + sync_db_entry!(self.mount_type.to_string(), volume::mount_type), + sync_db_entry!( + self.total_bytes_capacity.to_string(), + volume::total_bytes_capacity + ), + sync_db_entry!( + self.total_bytes_available.to_string(), + volume::total_bytes_available + ), + sync_db_entry!(self.disk_type.to_string(), volume::disk_type), + sync_db_entry!(self.file_system.to_string(), volume::file_system), + sync_db_entry!(self.is_mounted, volume::is_mounted), + sync_db_entry!( + self.read_speed_mbps.unwrap_or(0) as i64, + volume::read_speed_mbps + ), + sync_db_entry!( + self.write_speed_mbps.unwrap_or(0) as i64, + volume::write_speed_mbps + ), + sync_db_entry!(self.read_only, volume::read_only), + sync_db_entry!( + self.error_status.clone().unwrap_or_default(), + volume::error_status + ), + ] + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + sync.write_op( + db, + sync.shared_update( + prisma_sync::volume::SyncId { + pub_id: pub_id.clone(), + }, + sync_params, + ), + db.volume() + .update(volume::pub_id::equals(pub_id.clone()), db_params), + ) + .await?; + + Ok(()) + } + + pub async fn sync_db_delete(&self, library: &Library) -> Result<(), VolumeError> { + let Library { db, sync, .. } = library; + let pub_id = self.pub_id.as_ref().ok_or(VolumeError::NotTracked)?; + + sync.write_op( + db, + sync.shared_delete(prisma_sync::volume::SyncId { + pub_id: pub_id.clone(), + }), + db.volume().delete(volume::pub_id::equals(pub_id.clone())), + ) + .await?; + + Ok(()) + } } /// Represents the type of physical storage device @@ -472,3 +669,10 @@ impl<'de> Deserialize<'de> for VolumeFingerprint { .map_err(serde::de::Error::custom) } } + +#[derive(Serialize, Deserialize, Debug)] +pub struct SdVolumeFile { + pub pub_id: Vec, + pub fingerprint: String, // Store as hex string + pub last_seen: chrono::DateTime, +} diff --git a/interface/app/$libraryId/overview/StorageBar.tsx b/interface/app/$libraryId/overview/StorageBar.tsx index d0684f899745..2099c679ad9a 100644 --- a/interface/app/$libraryId/overview/StorageBar.tsx +++ b/interface/app/$libraryId/overview/StorageBar.tsx @@ -1,10 +1,8 @@ import clsx from 'clsx'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Tooltip } from '@sd/ui'; import { useIsDark } from '~/hooks'; -const BARWIDTH = 690; - interface Section { name: string; value: bigint; @@ -19,19 +17,33 @@ interface StorageBarProps { const StorageBar: React.FC = ({ sections }) => { const isDark = useIsDark(); const [hoveredSectionIndex, setHoveredSectionIndex] = useState(null); + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); const totalSpace = sections.reduce((acc, section) => acc + section.value, 0n); - const getPercentage = (value: bigint) => { - if (value === 0n) return '0px'; + const getWidth = (value: bigint) => { + if (value === 0n) return '2px'; const percentage = Number((value * 100n) / totalSpace) / 100; - const pixvalue = BARWIDTH * percentage; - return `${pixvalue.toFixed(2)}px`; + return `${Math.max(2, containerWidth * percentage)}px`; }; return ( -
-
+
+
{sections.map((section, index) => { const isHovered = hoveredSectionIndex === index; @@ -39,13 +51,11 @@ const StorageBar: React.FC = ({ sections }) => {
= ({ sections }) => { })}
{sections.map((section, index) => (
setHoveredSectionIndex(index)} onMouseLeave={() => setHoveredSectionIndex(null)} > diff --git a/interface/app/$libraryId/overview/cards/DeviceList.tsx b/interface/app/$libraryId/overview/cards/DeviceList.tsx index 011a79d70d23..13b68839fb54 100644 --- a/interface/app/$libraryId/overview/cards/DeviceList.tsx +++ b/interface/app/$libraryId/overview/cards/DeviceList.tsx @@ -4,7 +4,7 @@ import { Device, HardwareModel, useLibraryQuery } from '@sd/client'; import { Button, buttonStyles, Tooltip } from '@sd/ui'; import { Icon, Icon as SdIcon } from '~/components'; import { useLocale } from '~/hooks'; -import { hardwareModelToIcon } from '~/util/hardware'; +import { hardwareModelAsNumberToIcon, hardwareModelToIcon } from '~/util/hardware'; const DeviceList = () => { const navigate = useNavigate(); @@ -24,14 +24,12 @@ const DeviceList = () => { > {device.hardware_model ? ( ) : ( - + )} {device.name} diff --git a/interface/app/$libraryId/overview/cards/FileKindStats.tsx b/interface/app/$libraryId/overview/cards/FileKindStats.tsx index fb89cf1a7653..ea26ac3cb0c2 100644 --- a/interface/app/$libraryId/overview/cards/FileKindStats.tsx +++ b/interface/app/$libraryId/overview/cards/FileKindStats.tsx @@ -20,7 +20,8 @@ const INFO_ICON_CLASSLIST = const TOTAL_FILES_CLASSLIST = 'flex items-center justify-between whitespace-nowrap text-sm font-medium text-ink-dull mt-2 px-1 font-plex'; const UNIDENTIFIED_FILES_CLASSLIST = 'relative flex items-center text-xs font-plex text-ink-faint'; -const BARS_CONTAINER_CLASSLIST = 'relative mt-2 flex grow flex-wrap items-end gap-1 self-stretch'; +const BARS_CONTAINER_CLASSLIST = + 'relative mt-[-100px] flex grow flex-wrap items-end gap-1 self-stretch'; const mapFractionalValue = (numerator: bigint, denominator: bigint, maxValue: bigint): string => { if (denominator === 0n) return '0'; @@ -75,7 +76,7 @@ const FileKindStats: React.FC = () => { const barsContainerRef = useRef(null); const iconsRef = useRef<{ [key: string]: HTMLImageElement }>({}); - const BAR_MAX_HEIGHT = 140n; + const BAR_MAX_HEIGHT = 160n; const BAR_COLOR_START = '#36A3FF'; const BAR_COLOR_END = '#004C99'; @@ -213,33 +214,35 @@ const FileKindStats: React.FC = () => { ) : ( <>
- -
- - {data?.total_identified_files - ? formatNumberWithCommas(data.total_identified_files) - : '0'}{' '} - -
+
+ +
+
+ +
+ + {data?.total_identified_files + ? formatNumberWithCommas(data.total_identified_files) + : '0'}{' '} + {t('total_files')} -
-
- -
- - - {data?.total_unidentified_files - ? formatNumberWithCommas(data.total_unidentified_files) - : '0'}{' '} - {t('unidentified_files')} - +
+ + + {data?.total_unidentified_files + ? formatNumberWithCommas(data.total_unidentified_files) + : '0'}{' '} + {t('unidentified_files')} + + +
diff --git a/interface/app/$libraryId/overview/cards/ItemsCard.tsx b/interface/app/$libraryId/overview/cards/ItemsCard.tsx index f7b3e07ee6f1..c6ac40524689 100644 --- a/interface/app/$libraryId/overview/cards/ItemsCard.tsx +++ b/interface/app/$libraryId/overview/cards/ItemsCard.tsx @@ -27,7 +27,7 @@ export const ItemsCard = ({ query, buttonText, buttonLink, - maxItems = 6 + maxItems = 20 }: ItemsCardProps) => { const navigate = useNavigate(); const { t } = useLocale(); diff --git a/interface/app/$libraryId/overview/cards/LibraryStats.tsx b/interface/app/$libraryId/overview/cards/LibraryStats.tsx index 172ca9064d1c..e7e32f5e3102 100644 --- a/interface/app/$libraryId/overview/cards/LibraryStats.tsx +++ b/interface/app/$libraryId/overview/cards/LibraryStats.tsx @@ -65,7 +65,7 @@ const StatItem = ({ title, bytes, isLoading, info }: StatItemProps) => { return (
@@ -204,8 +204,8 @@ const LibraryStats = () => {
) : ( - <> -
+
+
{Object.entries(libraryStats ?? {}) .sort( ([a], [b]) => @@ -230,10 +230,8 @@ const LibraryStats = () => { ); })}
-
- -
- + +
)} ); diff --git a/interface/app/$libraryId/overview/cards/RecentItems.tsx b/interface/app/$libraryId/overview/cards/RecentItems.tsx index 5375090a261b..99a64d6c4d55 100644 --- a/interface/app/$libraryId/overview/cards/RecentItems.tsx +++ b/interface/app/$libraryId/overview/cards/RecentItems.tsx @@ -6,7 +6,7 @@ const RecentFiles = () => { const recentItemsQuery = useLibraryQuery([ 'search.objects', { - take: 6, + take: 20, orderAndPagination: { orderOnly: { field: 'dateAccessed', value: 'Desc' } }, diff --git a/interface/app/$libraryId/overview/cards/RecentLocations.tsx b/interface/app/$libraryId/overview/cards/RecentLocations.tsx index 2497bf898351..e1f4888067d3 100644 --- a/interface/app/$libraryId/overview/cards/RecentLocations.tsx +++ b/interface/app/$libraryId/overview/cards/RecentLocations.tsx @@ -47,12 +47,12 @@ const RecentLocations = () => { className="flex items-center gap-3 rounded-md p-2.5 text-left hover:bg-app-selected/50" >
- +
arraysEqual(location.pub_id, l)) ? 'bg-green-500' - : 'bg-red-500' + : 'bg-app-selected' }`} />
@@ -69,16 +69,10 @@ const RecentLocations = () => {
-
e.stopPropagation()} - > - +
+ {humanizeSize(location.size_in_bytes).value} - + {t( `size_${humanizeSize(location.size_in_bytes).unit.toLowerCase()}` )} diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 246e9d0f718b..248a6adee4b3 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -1,9 +1,9 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { ArrowsOutCardinal, DotsThreeVertical, GearSix } from '@phosphor-icons/react'; import clsx from 'clsx'; -import { createElement, lazy, Suspense, useEffect } from 'react'; +import { createElement, lazy, Suspense, useEffect, useMemo } from 'react'; import { useSnapshot } from 'valtio'; -import { Button, Card, DropdownMenu } from '@sd/ui'; +import { Button, Card, CheckBox, DropdownMenu } from '@sd/ui'; import { useLocale } from '~/hooks'; import { CardConfig, defaultCards, overviewStore, type CardSize } from './store'; @@ -34,6 +34,12 @@ interface CardHeadingProps { function CardHeading({ title, onSizeChange, dragHandleProps }: CardHeadingProps) { const { t } = useLocale(); + const store = useSnapshot(overviewStore); + + const size = useMemo(() => { + return store.cards.find((card) => card.title === title)?.size; + }, [store.cards, title]); + return (
onSizeChange?.('small')}> + {t('small')} onSizeChange?.('medium')}> + {t('medium')} onSizeChange?.('large')}> + {t('large')} @@ -90,7 +99,7 @@ export function OverviewCard({ return ( @@ -161,6 +170,7 @@ export const Component = () => { > {store.cards.map((card) => ( handleCardToggle(card.id)}> + {card.title} ))} diff --git a/interface/app/$libraryId/settings/Sidebar.tsx b/interface/app/$libraryId/settings/Sidebar.tsx index 3a7b8daba818..6cab474b7419 100644 --- a/interface/app/$libraryId/settings/Sidebar.tsx +++ b/interface/app/$libraryId/settings/Sidebar.tsx @@ -137,7 +137,7 @@ export default () => { {t('clouds')} - + {t('keys')} diff --git a/interface/util/hardware.ts b/interface/util/hardware.ts index 99d6691ce1d6..e1b912e9f23c 100644 --- a/interface/util/hardware.ts +++ b/interface/util/hardware.ts @@ -20,3 +20,22 @@ export function hardwareModelToIcon(hardwareModel: HardwareModel) { return 'Laptop'; } } + +export function hardwareModelAsNumberToIcon(hardwareModel: number) { + switch (hardwareModel) { + case 1: + return 'SilverBox'; + case 2: + return 'Laptop'; + case 3: + return 'Laptop'; + case 4: + return 'MobileAndroid'; + case 5: + return 'MiniSilverBox'; + case 6: + return 'PC'; + default: + return 'Laptop'; + } +} diff --git a/packages/ui/src/CheckBox.tsx b/packages/ui/src/CheckBox.tsx index 971ff39c6735..39e55f688720 100644 --- a/packages/ui/src/CheckBox.tsx +++ b/packages/ui/src/CheckBox.tsx @@ -8,7 +8,7 @@ import { ComponentProps, forwardRef } from 'react'; const styles = cva( [ - 'form-check-input float-left mr-2 mt-1 size-4 appearance-none rounded-sm border border-gray-300 bg-white bg-contain bg-center bg-no-repeat align-top transition duration-200', + 'form-check-input float-left mr-2 mt-1 size-4 appearance-none rounded-sm border border-app-divider bg-app-selected bg-contain bg-center bg-no-repeat align-top transition duration-200', 'checked:border-accent checked:bg-accent checked:hover:bg-accent/80 focus:outline-none' ], { variants: {} } From 2da3ec691bc74826cb9d4640ab2834cfdbcabee6 Mon Sep 17 00:00:00 2001 From: James Pine Date: Tue, 5 Nov 2024 23:07:47 -0800 Subject: [PATCH 12/31] storage meter, volume alerts and ui tweaks --- .../Layout/Sidebar/sections/Local/index.tsx | 6 ++ .../app/$libraryId/overview/StorageBar.tsx | 5 +- .../overview/cards/FileKindStats.tsx | 7 +- .../$libraryId/overview/cards/ItemsCard.tsx | 8 +- .../overview/cards/LibraryStats.tsx | 4 +- .../overview/cards/StorageMeters.tsx | 92 +++++++++++++++++++ interface/app/$libraryId/overview/index.tsx | 7 +- interface/app/$libraryId/overview/store.tsx | 20 +++- 8 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 interface/app/$libraryId/overview/cards/StorageMeters.tsx diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index 11e7c6b1bc6c..17e82cbbc901 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -69,6 +69,12 @@ export default function LocalSection() { const volumeEvents = useLibrarySubscription(['volumes.events'], { onData: (data) => { + if ('VolumeAdded' in data) { + toast.success(`Volume mounted: ${data.VolumeAdded.name}`); + } + if ('VolumeRemoved' in data) { + toast.success(`Volume unmounted: ${data.VolumeRemoved.name}`); + } console.log('Volume event received:', data); volumesQuery.refetch(); } diff --git a/interface/app/$libraryId/overview/StorageBar.tsx b/interface/app/$libraryId/overview/StorageBar.tsx index 2099c679ad9a..943a73faac41 100644 --- a/interface/app/$libraryId/overview/StorageBar.tsx +++ b/interface/app/$libraryId/overview/StorageBar.tsx @@ -68,10 +68,7 @@ const StorageBar: React.FC = ({ sections }) => { })}
{sections.map((section, index) => ( diff --git a/interface/app/$libraryId/overview/cards/FileKindStats.tsx b/interface/app/$libraryId/overview/cards/FileKindStats.tsx index ea26ac3cb0c2..43fb702a4454 100644 --- a/interface/app/$libraryId/overview/cards/FileKindStats.tsx +++ b/interface/app/$libraryId/overview/cards/FileKindStats.tsx @@ -19,9 +19,8 @@ const INFO_ICON_CLASSLIST = 'inline size-3 text-ink-faint opacity-0 ml-1 transition-opacity duration-300 group-hover:opacity-70'; const TOTAL_FILES_CLASSLIST = 'flex items-center justify-between whitespace-nowrap text-sm font-medium text-ink-dull mt-2 px-1 font-plex'; -const UNIDENTIFIED_FILES_CLASSLIST = 'relative flex items-center text-xs font-plex text-ink-faint'; const BARS_CONTAINER_CLASSLIST = - 'relative mt-[-100px] flex grow flex-wrap items-end gap-1 self-stretch'; + 'relative mt-[-50px] flex grow flex-wrap items-end gap-1 self-stretch'; const mapFractionalValue = (numerator: bigint, denominator: bigint, maxValue: bigint): string => { if (denominator === 0n) return '0'; @@ -76,7 +75,7 @@ const FileKindStats: React.FC = () => { const barsContainerRef = useRef(null); const iconsRef = useRef<{ [key: string]: HTMLImageElement }>({}); - const BAR_MAX_HEIGHT = 160n; + const BAR_MAX_HEIGHT = 130n; const BAR_COLOR_START = '#36A3FF'; const BAR_COLOR_END = '#004C99'; @@ -233,7 +232,7 @@ const FileKindStats: React.FC = () => { {t('total_files')}
-
+
{data?.total_unidentified_files diff --git a/interface/app/$libraryId/overview/cards/ItemsCard.tsx b/interface/app/$libraryId/overview/cards/ItemsCard.tsx index c6ac40524689..378e437d7f76 100644 --- a/interface/app/$libraryId/overview/cards/ItemsCard.tsx +++ b/interface/app/$libraryId/overview/cards/ItemsCard.tsx @@ -73,9 +73,9 @@ export const ItemsCard = ({ handleWindowsGridShiftSelection: () => {} }} > - +
- + */} ); }; diff --git a/interface/app/$libraryId/overview/cards/LibraryStats.tsx b/interface/app/$libraryId/overview/cards/LibraryStats.tsx index e7e32f5e3102..18478ccfb502 100644 --- a/interface/app/$libraryId/overview/cards/LibraryStats.tsx +++ b/interface/app/$libraryId/overview/cards/LibraryStats.tsx @@ -65,7 +65,7 @@ const StatItem = ({ title, bytes, isLoading, info }: StatItemProps) => { return (
@@ -205,7 +205,7 @@ const LibraryStats = () => {
) : (
-
+
{Object.entries(libraryStats ?? {}) .sort( ([a], [b]) => diff --git a/interface/app/$libraryId/overview/cards/StorageMeters.tsx b/interface/app/$libraryId/overview/cards/StorageMeters.tsx new file mode 100644 index 000000000000..ecfc17c3d768 --- /dev/null +++ b/interface/app/$libraryId/overview/cards/StorageMeters.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { CircularProgress } from '@sd/ui'; +import { useIsDark, useLocale } from '~/hooks'; + +const StorageMeters = () => { + const [mounted, setMounted] = useState(false); + const isDark = useIsDark(); + const { t } = useLocale(); + + // Fake stats for demonstration + const stats = { + totalUsed: 72, // 72% of storage used + redundantData: 45, // 45% redundant data + compressible: 30 // 30% potentially compressible + }; + + useEffect(() => { + setMounted(true); + }, []); + + const trackColor = isDark ? '#252631' : '#efefef'; + + return ( +
+
+
+ = 90 + ? '#E14444' + : stats.totalUsed >= 75 + ? 'darkorange' + : stats.totalUsed >= 60 + ? 'yellow' + : '#2599FF' + } + fillColor="transparent" + trackStrokeColor={trackColor} + strokeLinecap="square" + className="flex items-center justify-center" + transition="stroke-dashoffset 1s ease 0s, stroke 1s ease" + > +
{stats.totalUsed}%
+
+ Storage Used +
+ +
+ +
{stats.redundantData}%
+
+ Redundant +
+ +
+ +
{stats.compressible}%
+
+ Compressible +
+
+
+ ); +}; + +export default StorageMeters; diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index 248a6adee4b3..d07aef3406ae 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -22,7 +22,8 @@ const CARD_COMPONENTS: Record = { 'device-list': lazy(() => import('./cards/DeviceList')), 'file-kind-stats': lazy(() => import('./cards/FileKindStats')), 'recent-files': lazy(() => import('./cards/RecentItems')), - 'recent-locations': lazy(() => import('./cards/RecentLocations')) + 'recent-locations': lazy(() => import('./cards/RecentLocations')), + 'storage-meters': lazy(() => import('./cards/StorageMeters')) }; interface CardHeadingProps { @@ -99,7 +100,7 @@ export function OverviewCard({ return ( @@ -185,7 +186,7 @@ export const Component = () => {
{store.cards .filter((card) => card.enabled) diff --git a/interface/app/$libraryId/overview/store.tsx b/interface/app/$libraryId/overview/store.tsx index 73576aecc87f..fbc65db76d82 100644 --- a/interface/app/$libraryId/overview/store.tsx +++ b/interface/app/$libraryId/overview/store.tsx @@ -52,6 +52,12 @@ export const defaultCards: CardConfig[] = [ enabled: true, size: 'medium', title: 'Recent Files' + }, + { + id: 'storage-meters', + enabled: true, + size: 'medium', + title: 'Storage Meters' } ]; @@ -60,4 +66,16 @@ export const state = proxy({ }); // Persist store -export const overviewStore = valtioPersist('sd-overview-layout', state); +export const overviewStore = valtioPersist('sd-overview-layout', state, { + saveFn: (data) => data, + + // Restore the cards with the default values while allowing new cards to be added + restoreFn: (stored) => ({ + ...state, + ...stored, + cards: defaultCards.map((defaultCard) => ({ + ...defaultCard, + ...stored.cards.find((card: CardConfig) => card.id === defaultCard.id) + })) + }) +}); From b41b92a6fe3c77dc50471d48b6a18f862e97cc67 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 6 Nov 2024 15:03:00 -0800 Subject: [PATCH 13/31] fix update lib stats + 0.5.0 version bump --- apps/desktop/src-tauri/Cargo.toml | 2 +- core/Cargo.toml | 2 +- core/src/api/libraries.rs | 16 +- core/src/library/statistics.rs | 226 +++++++++--------- .../overview/Layout/HorizontalScroll.tsx | 7 +- 5 files changed, 131 insertions(+), 122 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6963addb8c95..0eeebfba10d2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sd-desktop" -version = "0.4.2" +version = "0.5.0" authors = ["Spacedrive Technology Inc "] default-run = "sd-desktop" diff --git a/core/Cargo.toml b/core/Cargo.toml index e3bc221648d6..967553ab50e8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sd-core" -version = "0.4.2" +version = "0.5.0" authors = ["Spacedrive Technology Inc "] description = "Virtual distributed filesystem engine that powers Spacedrive." diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index 76c4a4301827..d7e5cfe6c819 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -1,7 +1,7 @@ use crate::{ api::CoreEvent, invalidate_query, - library::{Library, LibraryConfig, LibraryName}, + library::{update_library_statistics, Library, LibraryConfig, LibraryName}, location::{scan_location, LocationCreateArgs, ScanState}, util::MaybeUndefined, Node, @@ -644,13 +644,13 @@ async fn update_statistics_loop( while let Some(msg) = msg_stream.next().await { match msg { Message::Tick => { - // if last_received_at.elapsed() < FIVE_MINUTES { - // if let Err(e) = update_library_statistics(&node, &library).await { - // error!(?e, "Failed to update library statistics;"); - // } else { - // invalidate_query!(&library, "library.statistics"); - // } - // } + if last_received_at.elapsed() < FIVE_MINUTES { + if let Err(e) = update_library_statistics(&node, &library).await { + error!(?e, "Failed to update library statistics;"); + } else { + invalidate_query!(&library, "library.statistics"); + } + } } Message::Requested(instant) => { if instant - last_received_at > TWO_MINUTES { diff --git a/core/src/library/statistics.rs b/core/src/library/statistics.rs index 9b1d6c085fa9..9fe1cd770d90 100644 --- a/core/src/library/statistics.rs +++ b/core/src/library/statistics.rs @@ -1,110 +1,116 @@ -// use crate::{ -// api::utils::get_size, invalidate_query, library::Library, volume::os::get_volumes, Node, -// }; - -// use sd_prisma::prisma::{statistics, storage_statistics}; -// use sd_utils::db::size_in_bytes_from_db; - -// use chrono::Utc; -// use tracing::{error, info}; - -// use super::LibraryManagerError; - -// pub async fn update_library_statistics( -// node: &Node, -// library: &Library, -// ) -> Result { -// let (mut total_capacity, mut available_capacity) = library -// .db -// .storage_statistics() -// .find_many(vec![]) -// .select(storage_statistics::select!({ total_capacity available_capacity })) -// .exec() -// .await? -// .into_iter() -// .fold((0, 0), |(mut total, mut available), stat| { -// total += stat.total_capacity as u64; -// available += stat.available_capacity as u64; -// (total, available) -// }); - -// if total_capacity == 0 && available_capacity == 0 { -// // Failed to fetch storage statistics from database, so we compute from local volumes -// let volumes = get_volumes().await; - -// let mut local_total_capacity: u64 = 0; -// let mut local_available_capacity: u64 = 0; -// for volume in volumes { -// local_total_capacity += volume.total_bytes_capacity; -// local_available_capacity += volume.total_bytes_available; -// } - -// total_capacity = local_total_capacity; -// available_capacity = local_available_capacity; -// } - -// let total_bytes_used = total_capacity - available_capacity; - -// let library_db_size = get_size( -// node.config -// .data_directory() -// .join("libraries") -// .join(format!("{}.db", library.id)), -// ) -// .await -// .unwrap_or(0); - -// let total_library_bytes = library -// .db -// .location() -// .find_many(vec![]) -// .exec() -// .await -// .unwrap_or_else(|e| { -// error!(?e, "Failed to get locations;"); -// vec![] -// }) -// .into_iter() -// .map(|location| { -// location -// .size_in_bytes -// .map(|size| size_in_bytes_from_db(&size)) -// .unwrap_or(0) -// }) -// .sum::(); - -// let thumbnail_folder_size = get_size(node.config.data_directory().join("thumbnails")) -// .await -// .unwrap_or(0); - -// use statistics::*; -// let params = vec![ -// id::set(1), // Each library is a database so only one of these ever exists -// date_captured::set(Utc::now().into()), -// total_object_count::set(0), -// library_db_size::set(library_db_size.to_string()), -// total_library_bytes::set(total_library_bytes.to_string()), -// total_local_bytes_used::set(total_bytes_used.to_string()), -// total_local_bytes_capacity::set(total_capacity.to_string()), -// total_local_bytes_free::set(available_capacity.to_string()), -// total_library_preview_media_bytes::set(thumbnail_folder_size.to_string()), -// ]; - -// let stats = library -// .db -// .statistics() -// .upsert( -// // Each library is a database so only one of these ever exists -// statistics::id::equals(1), -// statistics::create(params.clone()), -// params, -// ) -// .exec() -// .await?; - -// info!(?stats, "Updated library statistics;"); - -// invalidate_query!(&library, "library.statistics"); - -// Ok(stats) -// } +use crate::{api::utils::get_size, invalidate_query, library::Library, Node}; + +use sd_prisma::prisma::{statistics, volume}; +use sd_utils::db::size_in_bytes_from_db; + +use chrono::Utc; +use tracing::{error, info}; + +use super::LibraryManagerError; + +pub async fn update_library_statistics( + node: &Node, + library: &Library, +) -> Result { + let (mut total_capacity, mut available_capacity) = library + .db + .volume() + .find_many(vec![]) + .select(volume::select!({ total_bytes_capacity total_bytes_available })) + .exec() + .await? + .into_iter() + .fold((0, 0), |(mut total, mut available), stat| { + total += stat + .total_bytes_capacity + .unwrap_or_else(|| "0".to_string()) + .parse::() + .unwrap_or(0); + available += stat + .total_bytes_available + .unwrap_or_else(|| "0".to_string()) + .parse::() + .unwrap_or(0); + (total, available) + }); + + // if total_capacity == 0 && available_capacity == 0 { + // // Failed to fetch storage statistics from database, so we compute from local volumes + // let volumes = get_volumes().await; + + // let mut local_total_capacity: u64 = 0; + // let mut local_available_capacity: u64 = 0; + // for volume in volumes { + // local_total_capacity += volume.total_bytes_capacity; + // local_available_capacity += volume.total_bytes_available; + // } + + // total_capacity = local_total_capacity; + // available_capacity = local_available_capacity; + // } + + let total_bytes_used = total_capacity - available_capacity; + + let library_db_size = get_size( + node.config + .data_directory() + .join("libraries") + .join(format!("{}.db", library.id)), + ) + .await + .unwrap_or(0); + + let total_library_bytes = library + .db + .location() + .find_many(vec![]) + .exec() + .await + .unwrap_or_else(|e| { + error!(?e, "Failed to get locations;"); + vec![] + }) + .into_iter() + .map(|location| { + location + .size_in_bytes + .map(|size| size_in_bytes_from_db(&size)) + .unwrap_or(0) + }) + .sum::(); + + let thumbnail_folder_size = get_size(node.config.data_directory().join("thumbnails")) + .await + .unwrap_or(0); + + use statistics::*; + let params = vec![ + id::set(1), // Each library is a database so only one of these ever exists + date_captured::set(Utc::now().into()), + total_object_count::set(0), + library_db_size::set(library_db_size.to_string()), + total_library_bytes::set(total_library_bytes.to_string()), + total_local_bytes_used::set(total_bytes_used.to_string()), + total_local_bytes_capacity::set(total_capacity.to_string()), + total_local_bytes_free::set(available_capacity.to_string()), + total_library_preview_media_bytes::set(thumbnail_folder_size.to_string()), + ]; + + let stats = library + .db + .statistics() + .upsert( + // Each library is a database so only one of these ever exists + statistics::id::equals(1), + statistics::create(params.clone()), + params, + ) + .exec() + .await?; + + info!(?stats, "Updated library statistics;"); + + invalidate_query!(&library, "library.statistics"); + + Ok(stats) +} diff --git a/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx index 6ef88229d7bb..7192653c5236 100644 --- a/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx +++ b/interface/app/$libraryId/overview/Layout/HorizontalScroll.tsx @@ -64,7 +64,7 @@ const HorizontalScroll = ({ children, className }: { children: ReactNode; classN
handleArrowOnClick('right')} - className={clsx('left-3', scroll === 0 && 'pointer-events-none opacity-0')} + className={clsx('left-0 -ml-1', scroll === 0 && 'pointer-events-none opacity-0')} > @@ -86,7 +86,10 @@ const HorizontalScroll = ({ children, className }: { children: ReactNode; classN {isContentOverflow && ( handleArrowOnClick('left')} - className={clsx('right-3', lastItemVisible && 'pointer-events-none opacity-0')} + className={clsx( + 'right-0 -mr-1', + lastItemVisible && 'pointer-events-none opacity-0' + )} > From a277d054428800dafd0bc6526396ae580cebee1c Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 6 Nov 2024 15:04:26 -0800 Subject: [PATCH 14/31] 0.5.0 lock --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bca936a04af5..05be733eae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9584,7 +9584,7 @@ dependencies = [ [[package]] name = "sd-core" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-channel", "async-recursion", @@ -9874,7 +9874,7 @@ dependencies = [ [[package]] name = "sd-desktop" -version = "0.4.2" +version = "0.5.0" dependencies = [ "axum", "axum-extra", From 0fa0a7413bf4aa47e208ee0f43e2a5e884d7cb0c Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 6 Nov 2024 22:42:21 -0800 Subject: [PATCH 15/31] some experimental cards, will be feature flagged before merge --- Cargo.lock | 307 ++++++++++++++---- core/Cargo.toml | 1 + core/src/llm/README.md | 211 ++++++++++++ core/src/llm/mod.rs | 76 +++++ core/src/llm/processes/example.ron | 105 ++++++ core/src/llm/processes/system.ron | 5 + core/src/volume/cloud/mod.rs | 58 ++++ core/src/volume/error.rs | 19 ++ core/src/volume/mod.rs | 1 + core/src/volume/types.rs | 76 ++++- .../$libraryId/overview/cards/SpaceWizard.tsx | 46 +++ .../app/$libraryId/overview/cards/SyncCTA.tsx | 32 ++ interface/app/$libraryId/overview/index.tsx | 107 ++++-- interface/app/$libraryId/overview/store.tsx | 16 +- .../app/$libraryId/peer/StarfieldEffect.tsx | 10 +- .../$libraryId/settings/client/general.tsx | 7 +- packages/client/src/core.ts | 11 +- 17 files changed, 987 insertions(+), 101 deletions(-) create mode 100644 core/src/llm/README.md create mode 100644 core/src/llm/mod.rs create mode 100644 core/src/llm/processes/example.ron create mode 100644 core/src/llm/processes/system.ron create mode 100644 core/src/volume/cloud/mod.rs create mode 100644 interface/app/$libraryId/overview/cards/SpaceWizard.tsx create mode 100644 interface/app/$libraryId/overview/cards/SyncCTA.tsx diff --git a/Cargo.lock b/Cargo.lock index 05be733eae59..5058964970ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,9 +601,9 @@ dependencies = [ "bytes", "futures-util", "http 1.1.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "itoa 1.0.11", "matchit", @@ -636,7 +636,7 @@ dependencies = [ "bytes", "futures-util", "http 1.1.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -659,7 +659,7 @@ dependencies = [ "futures-util", "headers", "http 1.1.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -729,6 +729,12 @@ dependencies = [ "failure", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -3133,7 +3139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" dependencies = [ "futures-io", - "rustls", + "rustls 0.23.15", "rustls-pki-types", ] @@ -3772,6 +3778,25 @@ dependencies = [ "syn 2.0.82", ] +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.6" @@ -4119,6 +4144,17 @@ dependencies = [ "itoa 1.0.11", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -4138,7 +4174,7 @@ dependencies = [ "bytes", "futures-util", "http 1.1.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -4171,7 +4207,7 @@ dependencies = [ "form_urlencoded", "futures", "http 1.1.0", - "hyper", + "hyper 1.5.0", "hyper-util", "percent-encoding", "sha1", @@ -4195,6 +4231,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa 1.0.11", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.5.0" @@ -4204,9 +4264,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", + "h2 0.4.6", "http 1.1.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa 1.0.11", @@ -4216,6 +4276,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.31", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.3" @@ -4224,14 +4298,14 @@ checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http 1.1.0", - "hyper", + "hyper 1.5.0", "hyper-util", - "rustls", + "rustls 0.23.15", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tower-service", - "webpki-roots", + "webpki-roots 0.26.6", ] [[package]] @@ -4242,7 +4316,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "native-tls", "tokio", @@ -4260,8 +4334,8 @@ dependencies = [ "futures-channel", "futures-util", "http 1.1.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.5.0", "pin-project-lite", "socket2", "tokio", @@ -4398,7 +4472,7 @@ dependencies = [ "futures", "http 1.1.0", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "log", "rand 0.8.5", @@ -4674,11 +4748,11 @@ dependencies = [ "anyhow", "erased_set", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "once_cell", "prometheus-client", - "reqwest", + "reqwest 0.12.8", "serde", "struct_iterable", "time", @@ -4714,7 +4788,7 @@ dependencies = [ "hostname 0.3.1", "http 1.1.0", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "igd-next", "iroh-base", @@ -4736,11 +4810,11 @@ dependencies = [ "rand 0.8.5", "rcgen 0.12.1", "regex", - "reqwest", + "reqwest 0.12.8", "ring 0.17.8", "rtnetlink 0.13.1", - "rustls", - "rustls-pemfile", + "rustls 0.23.15", + "rustls-pemfile 2.2.0", "rustls-webpki 0.102.8", "serde", "serde_with", @@ -4753,7 +4827,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tokio-rustls-acme", "tokio-stream", "tokio-tungstenite 0.21.0", @@ -4765,7 +4839,7 @@ dependencies = [ "tungstenite 0.21.0", "url", "watchable", - "webpki-roots", + "webpki-roots 0.26.6", "windows 0.51.1", "wmi", "x509-parser", @@ -4783,7 +4857,7 @@ dependencies = [ "iroh-quinn-udp", "pin-project-lite", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "socket2", "thiserror", "tokio", @@ -4800,7 +4874,7 @@ dependencies = [ "rand 0.8.5", "ring 0.17.8", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "rustls-platform-verifier", "slab", "thiserror", @@ -5556,7 +5630,7 @@ dependencies = [ "quinn", "rand 0.8.5", "ring 0.17.8", - "rustls", + "rustls 0.23.15", "socket2", "thiserror", "tokio", @@ -5681,7 +5755,7 @@ dependencies = [ "libp2p-identity", "rcgen 0.11.3", "ring 0.17.8", - "rustls", + "rustls 0.23.15", "rustls-webpki 0.101.7", "thiserror", "x509-parser", @@ -6767,6 +6841,26 @@ dependencies = [ "syn 2.0.82", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.15", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -8468,7 +8562,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "socket2", "thiserror", "tokio", @@ -8485,7 +8579,7 @@ dependencies = [ "rand 0.8.5", "ring 0.17.8", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "slab", "thiserror", "tinyvec", @@ -8866,6 +8960,47 @@ dependencies = [ "user-facing-errors", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.8" @@ -8879,12 +9014,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.4.6", "http 1.1.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.5.0", + "hyper-rustls 0.27.3", "hyper-tls", "hyper-util", "ipnet", @@ -8896,8 +9031,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-pemfile", + "rustls 0.23.15", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -8906,7 +9041,7 @@ dependencies = [ "system-configuration 0.6.1", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.0", "tokio-util", "tower-service", "url", @@ -8914,7 +9049,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 0.26.6", "windows-registry 0.2.0", ] @@ -8927,7 +9062,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.1.0", - "reqwest", + "reqwest 0.12.8", "serde", "thiserror", "tower-service", @@ -8944,9 +9079,9 @@ dependencies = [ "futures", "getrandom 0.2.15", "http 1.1.0", - "hyper", + "hyper 1.5.0", "parking_lot 0.11.2", - "reqwest", + "reqwest 0.12.8", "reqwest-middleware", "retry-policies", "tokio", @@ -9254,6 +9389,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.8", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.15" @@ -9278,12 +9425,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -9310,13 +9466,13 @@ dependencies = [ "jni 0.19.0", "log", "once_cell", - "rustls", + "rustls 0.23.15", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.102.8", "security-framework", "security-framework-sys", - "webpki-roots", + "webpki-roots 0.26.6", "winapi", ] @@ -9514,6 +9670,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + [[package]] name = "sd-actors" version = "0.1.0" @@ -9540,7 +9706,7 @@ dependencies = [ "ort", "ort-sys", "prisma-client-rust", - "reqwest", + "reqwest 0.12.8", "rmp-serde", "rmpv", "sd-core-file-path-helper", @@ -9605,9 +9771,9 @@ dependencies = [ "futures-concurrency", "hex", "hostname 0.4.0", - "http-body", + "http-body 1.0.1", "http-range", - "hyper", + "hyper 1.5.0", "hyper-util", "icrate", "image", @@ -9617,13 +9783,14 @@ dependencies = [ "mini-moka", "normpath", "notify", + "oauth2", "openssl", "openssl-sys", "pin-project-lite", "plist", "prisma-client-rust", "regex", - "reqwest", + "reqwest 0.12.8", "rmp-serde", "rmpv", "rspc", @@ -9694,12 +9861,12 @@ dependencies = [ "iroh-quinn", "paste", "quic-rpc", - "reqwest", + "reqwest 0.12.8", "reqwest-middleware", "reqwest-retry", "rmp-serde", "rspc", - "rustls", + "rustls 0.23.15", "rustls-platform-verifier", "sd-actors", "sd-cloud-schema", @@ -9881,7 +10048,7 @@ dependencies = [ "dbus", "futures", "http 1.1.0", - "hyper", + "hyper 1.5.0", "mimalloc", "opener", "prisma-client-rust", @@ -11451,7 +11618,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.8", "serde", "serde_json", "serde_repr", @@ -11638,7 +11805,7 @@ dependencies = [ "data-url", "http 1.1.0", "regex", - "reqwest", + "reqwest 0.12.8", "schemars", "serde", "serde_json", @@ -11704,7 +11871,7 @@ dependencies = [ "infer", "minisign-verify", "percent-encoding", - "reqwest", + "reqwest 0.12.8", "semver", "serde", "serde_json", @@ -12038,13 +12205,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls", + "rustls 0.23.15", "rustls-pki-types", "tokio", ] @@ -12064,17 +12241,17 @@ dependencies = [ "pem", "proc-macro2", "rcgen 0.12.1", - "reqwest", + "reqwest 0.12.8", "ring 0.17.8", - "rustls", + "rustls 0.23.15", "serde", "serde_json", "thiserror", "time", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "url", - "webpki-roots", + "webpki-roots 0.26.6", "x509-parser", ] @@ -12741,10 +12918,10 @@ dependencies = [ "base64 0.22.1", "log", "once_cell", - "rustls", + "rustls 0.23.15", "rustls-pki-types", "url", - "webpki-roots", + "webpki-roots 0.26.6", ] [[package]] @@ -13239,6 +13416,12 @@ dependencies = [ "libwebp-sys", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.6" diff --git a/core/Cargo.toml b/core/Cargo.toml index 967553ab50e8..fa4b5cdc7fdc 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -95,6 +95,7 @@ http-range = "0.1.5" hyper-util = { version = "0.1.9", features = ["tokio"] } int-enum = "0.5" # Update blocked due to API breaking changes mini-moka = "0.10.3" +oauth2 = "4.4" serde-hashkey = "0.4.5" serde_repr = "0.1.19" serde_with = "3.8" diff --git a/core/src/llm/README.md b/core/src/llm/README.md new file mode 100644 index 000000000000..d1a70bf2fbf2 --- /dev/null +++ b/core/src/llm/README.md @@ -0,0 +1,211 @@ +# AI Engine + +The AI Engine is a core module of Spacedrive that provides intelligent file system operations and context-aware assistance through configurable LLM-powered agents. It uses a flexible RON-based configuration system to define agent behaviors, tools, and workflows. + +## Overview + +The AI Engine module enables Spacedrive to perform intelligent operations on the file system by: + +- Processing natural language queries about files and directories +- Understanding file context and relationships +- Executing complex file operations through LLM reasoning +- Maintaining conversational context about file operations + +## Architecture + +### Core Components + +``` +ai-engine/ +├── agents/ +│ ├── directory_agent.rs # File system navigation agent +│ ├── context_agent.rs # Content understanding agent +│ └── mod.rs +├── tools/ +│ ├── spacedrive_fs.rs # File system operations +│ ├── content_analyzer.rs # File content analysis +│ └── mod.rs +├── config/ +│ ├── parser.rs # RON configuration parser +│ ├── validator.rs # Configuration validation +│ └── templates/ +│ └── directory_agent.ron +├── memory/ +│ ├── conversation.rs # Conversation state management +│ └── storage.rs # Memory backend implementations +└── mod.rs +``` + +### Agent Configuration + +Agents are configured using RON (Rust Object Notation) files that define: + +- Model parameters and provider settings +- Available tools and their parameters +- Workflow execution strategies +- Memory management +- Prompt templates +- Validation rules + +Example configuration for the Directory Agent: + +```ron +( + agent: ( + name: "DirectoryAgent", + description: "File system navigation and context evaluation agent", + model: ( + provider: "ollama", + name: "llama3.1-70b-instruct", + // ... other model settings + ), + // ... tool definitions, workflow config, etc. + ) +) +``` + +## Usage + +### Basic Integration + +```rust +use spacedrive_core::ai_engine::{Agent, AgentConfig}; + +async fn create_directory_agent() -> Result { + // Load configuration from RON file + let config = AgentConfig::from_file("config/directory_agent.ron")?; + + // Initialize agent + let agent = Agent::new(config).await?; + + // Execute queries + let response = agent.execute("Find all images in the Downloads folder").await?; + + Ok(agent) +} +``` + +### Custom Tool Implementation + +```rust +use spacedrive_core::ai_engine::tools::{Tool, ToolResult}; + +#[derive(Debug)] +struct SpacedriveFs; + +#[async_trait] +impl Tool for SpacedriveFs { + async fn execute(&self, params: HashMap) -> ToolResult { + // Implement file system operations + // ... + } +} +``` + +## Memory Management + +The AI Engine supports different memory backends: + +- In-Memory (default): Temporary storage for the session +- Redis: Distributed memory storage +- PostgreSQL: Persistent conversation history + +Configure memory settings in the agent's RON file: + +```ron +memory: ( + type: "Conversational", + storage: ( + type: "Redis", + connection_string: Some("redis://localhost:6379"), + ttl_seconds: Some(3600), + ), +) +``` + +## Error Handling + +The module implements comprehensive error handling: + +- Automatic retries with exponential backoff +- Fallback responses for failed operations +- Detailed error logging and reporting + +Configuration example: + +```ron +error_strategy: ( + max_retries: 3, + backoff_seconds: 2, + fallback_response: "Operation failed, please try again.", +) +``` + +## Development + +### Adding New Tools + +1. Create a new tool implementation in `tools/`: + +```rust +#[derive(Debug)] +pub struct NewTool; + +#[async_trait] +impl Tool for NewTool { + async fn execute(&self, params: HashMap) -> ToolResult { + // Tool implementation + } +} +``` + +2. Add tool configuration to agent RON file: + +```ron +tools: [ + ( + name: "new_tool", + description: "Description of the new tool", + required_params: [ + // Parameter definitions + ], + ), +] +``` + +### Testing + +Run the test suite: + +```bash +cargo test -p spacedrive-core ai_engine +``` + +Run specific agent tests: + +```bash +cargo test -p spacedrive-core ai_engine::agents::directory_agent +``` + +## Contributing + +When contributing to the AI Engine: + +1. Ensure all new tools implement the `Tool` trait +2. Add appropriate tests for new functionality +3. Update RON schema documentation +4. Follow Rust best practices and Spacedrive's coding style + +## Future Developments + +Planned features: + +- [ ] Additional LLM provider integrations +- [ ] Enhanced file content understanding +- [ ] Improved memory management systems +- [ ] Multi-agent collaboration +- [ ] Custom tool development framework + +## License + +This module is part of Spacedrive and is licensed under the same terms as the main project. diff --git a/core/src/llm/mod.rs b/core/src/llm/mod.rs new file mode 100644 index 000000000000..916e984374e3 --- /dev/null +++ b/core/src/llm/mod.rs @@ -0,0 +1,76 @@ +/// LLM Engine +/// +/// This module contains the LLM engine for the Spacedrive core. +/// It is responsible for generating and executing AI tasks. +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; + +pub mod agents; +pub mod config; +pub mod memory; +pub mod tools; + +// Core error type for LLM operations +#[derive(Error, Debug)] +pub enum LLMError { + #[error("Configuration error: {0}")] + Config(String), + #[error("Model error: {0}")] + Model(String), + #[error("Tool execution error: {0}")] + Tool(String), + #[error("Memory error: {0}")] + Memory(String), +} + +// Result type alias for LLM operations +pub type Result = std::result::Result; + +// Core agent configuration structures +#[derive(Debug, Deserialize, Serialize)] +pub struct AgentConfig { + pub name: String, + pub description: String, + pub model: ModelConfig, + pub tools: Vec, + pub workflow: WorkflowConfig, + pub memory: MemoryConfig, + pub prompts: PromptTemplates, + pub validation: ValidationConfig, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ModelConfig { + pub provider: String, + pub name: String, + pub temperature: f32, + pub max_tokens: usize, + pub system_prompt: String, +} + +// Core agent trait +#[async_trait] +pub trait Agent: Send + Sync { + async fn execute(&self, query: &str) -> Result; + async fn load_tools(&mut self) -> Result<()>; + async fn initialize_memory(&mut self) -> Result<()>; +} + +// Core tool trait +#[async_trait] +pub trait Tool: Send + Sync { + async fn execute( + &self, + params: HashMap, + ) -> Result; + fn name(&self) -> &str; + fn description(&self) -> &str; +} + +// Re-exports +pub use self::agents::DirectoryAgent; +pub use self::config::ConfigParser; +pub use self::memory::MemoryManager; +pub use self::tools::SpacedriveFs; diff --git a/core/src/llm/processes/example.ron b/core/src/llm/processes/example.ron new file mode 100644 index 000000000000..f669f44aa73d --- /dev/null +++ b/core/src/llm/processes/example.ron @@ -0,0 +1,105 @@ +// Agent Configuration Schema +( + // Core agent configuration + agent: ( + // The directory agent is used to navigate the file system and evaluate context + name: "DirectoryAgent", + description: "A configurable LLM-powered agent that can navigate the file system and evaluate context", + model: ( + provider: "ollama", // or "openai", "ollama", etc. + name: "llama3.1-70b-instruct", + temperature: 0.7, + max_tokens: 4096, + system_prompt: "{{system.ron}}", + ), + + // Tool definitions + tools: [ + ( + name: "spacedrive_fs", + description: "Search the file system for files and directories", + required_params: [ + ( + name: "path", + type: "String", + description: "The path to search for", + ), + ], + optional_params: [ + ( + name: "num_results", + type: "Integer", + default: 5, + description: "Number of results to return", + ), + ], + ), + ], + + // Workflow configuration + workflow: ( + // How the agent should process tasks + execution_strategy: "Sequential", // or "Parallel", "Adaptive" + max_steps: 10, + timeout_seconds: 300, + + // Error handling + error_strategy: ( + max_retries: 3, + backoff_seconds: 2, + fallback_response: "I encountered an error and couldn't complete the task.", + ), + ), + + // Memory configuration + memory: ( + type: "Conversational", // or "Episodic", "Semantic" + storage: ( + type: "InMemory", // or "Redis", "Postgres" + connection_string: None, + ttl_seconds: Some(3600), + ), + max_history_messages: 10, + ), + + // Prompt templates for different agent states + prompts: ( + task_planning: r#" + Given the following task: {task} + And access to these tools: {available_tools} + Create a plan to accomplish this task. + Plan: + "#, + + tool_selection: r#" + Based on the current step: {current_step} + And available tools: {available_tools} + Select the most appropriate tool. + Reasoning: + "#, + + result_synthesis: r#" + Given the tool results: {tool_results} + And the original task: {original_task} + Synthesize a response. + Response: + "#, + ), + + // Validation rules + validation: ( + required_fields: [ + "model.provider", + "model.name", + "workflow.execution_strategy", + ], + custom_validators: [ + ( + name: "validate_temperature", + condition: "model.temperature >= 0.0 && model.temperature <= 1.0", + error_message: "Temperature must be between 0.0 and 1.0", + ), + ], + ), + ), +) diff --git a/core/src/llm/processes/system.ron b/core/src/llm/processes/system.ron new file mode 100644 index 000000000000..737aa5b601c6 --- /dev/null +++ b/core/src/llm/processes/system.ron @@ -0,0 +1,5 @@ +( + system: ( + prompt: "You are a helpful AI assistant with access to various tools.", + ) +) diff --git a/core/src/volume/cloud/mod.rs b/core/src/volume/cloud/mod.rs new file mode 100644 index 000000000000..9aa2e92d00e7 --- /dev/null +++ b/core/src/volume/cloud/mod.rs @@ -0,0 +1,58 @@ +use super::{error::VolumeError, types::CloudProvider}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct CloudStorageInfo { + pub total_bytes_capacity: u64, + pub total_bytes_available: u64, + pub quota_info: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct QuotaInfo { + pub used: u64, + pub allocated: u64, + pub max: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub enum CloudCredentials { + OAuth { + access_token: String, + refresh_token: Option, + expires_at: Option, + }, + ApiKey(String), + Custom(serde_json::Value), +} + +#[async_trait] +pub trait CloudVolumeProvider: Send + Sync { + /// Get storage capacity and usage information + async fn get_storage_info(&self) -> Result; + + /// Check if the current credentials are valid + async fn is_authenticated(&self) -> bool; + + /// Attempt to authenticate with the provider + async fn authenticate(&self) -> Result<(), VolumeError>; + + /// Refresh authentication tokens if needed + async fn refresh_token(&self) -> Result<(), VolumeError>; +} + +// Factory function to create provider implementations +pub fn get_cloud_provider( + provider: &CloudProvider, + credentials: CloudCredentials, +) -> Result, VolumeError> { + match provider { + // CloudProvider::GoogleDrive => Ok(Box::new(GoogleDriveProvider::new(credentials))), + // CloudProvider::Dropbox => Ok(Box::new(DropboxProvider::new(credentials))), + // CloudProvider::OneDrive => Ok(Box::new(OneDriveProvider::new(credentials))), + // Add other providers as they're implemented + _ => Err(VolumeError::UnsupportedCloudProvider(provider.clone())), + } +} diff --git a/core/src/volume/error.rs b/core/src/volume/error.rs index e62227f4e5e5..14d703814c41 100644 --- a/core/src/volume/error.rs +++ b/core/src/volume/error.rs @@ -136,6 +136,9 @@ pub enum VolumeError { #[error(transparent)] Sync(#[from] sd_core_sync::Error), + + #[error(transparent)] + Cloud(#[from] CloudVolumeError), } /// Specific kinds of speed test errors @@ -326,3 +329,19 @@ impl fmt::Display for SpeedTestErrorKind { write!(f, "{}", kind_str) } } + +#[derive(Debug, Error)] +pub enum CloudVolumeError { + #[error("Authentication failed: {0}")] + AuthenticationError(String), + #[error("Rate limit exceeded")] + RateLimitExceeded, + #[error("Quota exceeded")] + QuotaExceeded, + #[error("API Error: {0}")] + ApiError(String), + #[error("Network Error: {0}")] + NetworkError(String), + // #[error("Unsupported cloud provider: {0}")] + // UnsupportedCloudProvider(CloudProvider), +} diff --git a/core/src/volume/mod.rs b/core/src/volume/mod.rs index 1f7e11861dce..ab3fb4181475 100644 --- a/core/src/volume/mod.rs +++ b/core/src/volume/mod.rs @@ -5,6 +5,7 @@ //! Volumes use a fingerprint to identify them as they sometimes are not persisted in the database //! pub(crate) mod actor; +// pub(crate) mod cloud; mod error; mod os; mod speed; diff --git a/core/src/volume/types.rs b/core/src/volume/types.rs index 4d3444f9a47f..5e529a21ab0f 100644 --- a/core/src/volume/types.rs +++ b/core/src/volume/types.rs @@ -1,4 +1,7 @@ -use super::error::VolumeError; +use super::{ + // cloud::CloudCredentials, + error::{CloudVolumeError, VolumeError}, +}; use crate::library::Library; use sd_core_sync::DevicePubId; use sd_prisma::{ @@ -545,6 +548,50 @@ impl Volume { Ok(()) } + + // pub async fn new_cloud_volume( + // provider: CloudProvider, + // credentials: CloudCredentials, + // ) -> Result { + // let provider_impl = match provider { + // // CloudProvider::GoogleDrive => Box::new(GoogleDriveProvider::new(credentials)), + // // CloudProvider::Dropbox => Box::new(DropboxProvider::new(credentials)), + // _ => return Err(CloudVolumeError::UnsupportedCloudProvider(provider)), + // }; + + // let storage_info = provider_impl.get_storage_info().await?; + + // Ok(Self { + // id: None, + // pub_id: None, + // device_id: None, + // name: format!("{} Cloud Storage", provider), + // mount_type: MountType::Cloud(provider), + // mount_point: PathBuf::from("/"), // Virtual root path + // mount_points: vec![], + // is_mounted: true, + // disk_type: DiskType::Virtual, + // file_system: FileSystem::Cloud, + // read_only: false, + // error_status: None, + // read_speed_mbps: None, + // write_speed_mbps: None, + // total_bytes_capacity: storage_info.total_bytes_capacity, + // total_bytes_available: storage_info.total_bytes_available, + // fingerprint: None, + // }) + // } + + // pub async fn refresh_cloud_storage_info(&mut self) -> Result<(), VolumeError> { + // if let MountType::Cloud(provider) = &self.mount_type { + // let provider_impl = get_cloud_provider(provider)?; + // let storage_info = provider_impl.get_storage_info().await?; + + // self.total_bytes_capacity = storage_info.total_bytes_capacity; + // self.total_bytes_available = storage_info.total_bytes_available; + // } + // Ok(()) + // } } /// Represents the type of physical storage device @@ -555,6 +602,8 @@ pub enum DiskType { SSD, /// Hard Disk Drive HDD, + /// Virtual disk type + Virtual, /// Unknown or virtual disk type Unknown, } @@ -610,6 +659,8 @@ pub enum MountType { Network, /// Virtual/container volume Virtual, + // Cloud mounted as a virtual volume + Cloud(CloudProvider), } impl MountType { @@ -624,6 +675,29 @@ impl MountType { } } +/// Represents the cloud storage provider +#[derive(Serialize, Deserialize, Debug, Clone, Type, Hash, PartialEq, Eq, Display)] +pub enum CloudProvider { + SpacedriveCloud, + GoogleDrive, + Dropbox, + OneDrive, + ICloud, + AmazonS3, + Mega, + Box, + pCloud, + Proton, + Sync, + Backblaze, + Wasabi, + DigitalOcean, + Azure, + OwnCloud, + NextCloud, + WebDAV, +} + /// Configuration options for volume operations #[derive(Debug, Clone)] pub struct VolumeOptions { diff --git a/interface/app/$libraryId/overview/cards/SpaceWizard.tsx b/interface/app/$libraryId/overview/cards/SpaceWizard.tsx new file mode 100644 index 000000000000..d78f9b137fea --- /dev/null +++ b/interface/app/$libraryId/overview/cards/SpaceWizard.tsx @@ -0,0 +1,46 @@ +import { Desktop, FilePlus, MagicWand } from '@phosphor-icons/react'; +import { useNavigate } from 'react-router'; +import { Device, HardwareModel, useLibraryQuery } from '@sd/client'; +import { Button, buttonStyles, TextArea, Tooltip } from '@sd/ui'; +import { Icon, Icon as SdIcon } from '~/components'; +import { useLocale } from '~/hooks'; +import { hardwareModelAsNumberToIcon, hardwareModelToIcon } from '~/util/hardware'; + +import StarfieldEffect from '../../peer/StarfieldEffect'; + +const SpaceWizard = () => { + const navigate = useNavigate(); + const { t } = useLocale(); + + return ( + <> +
+