From 9c0aec81670617ae41eb7da046ec624071edf5df Mon Sep 17 00:00:00 2001 From: nikec <43032218+niikeec@users.noreply.github.com> Date: Tue, 15 Aug 2023 10:23:41 +0200 Subject: [PATCH] [ENG-300] Explorer multi-select (#1197) * grid * Improved multi-select, grid list & view offset. Added gap option & app frame. * List view multi-select * Include multi-select in overview, fix page ref type * Add gap to options panel * Fix drag * Update categories z-index * going pretty well * fix a couple bugs * fix another bug :) * minor improvements * Separate grid activeItem * extra comments * um akshully don't ref during render * show thumbnails yay * cleanup * Clean up * Fix ranges * here it is * fix cols drag * don't enforce selecto context * explorer view selectable * Update index.tsx * Context menu support for multi-select (#1187) * here it is * stopPropagation * cut copy multiple --------- Co-authored-by: nikec * explorer view selectable * Update index.tsx * items Map * fix renamable * Update inspector * Hide tag assign if empty * fix merge * cleanup * fix un-rendered drag select * fix double click quick preview * update thumbnail * mostly handle multiple select in keybindings * fix ts * remove another todo * move useItemAs hooks to @sd/client * fix thumb controls * multi-select double click * cleaner? * smaller gap --------- Co-authored-by: Jamie Pine Co-authored-by: Brendan Allan Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com> Co-authored-by: Ericson "Fogo" Soares --- core/src/api/files.rs | 6 +- core/src/api/tags.rs | 39 + interface/app/$libraryId/Explorer/Context.tsx | 23 +- .../Explorer/ContextMenu/ConditionalItem.tsx | 45 + .../ContextMenu/FilePath/CutCopyItems.tsx | 131 +-- .../Explorer/ContextMenu/FilePath/Items.tsx | 345 ++++--- .../ContextMenu/FilePath/OpenWith.tsx | 40 +- .../Explorer/ContextMenu/FilePath/index.tsx | 77 -- .../Explorer/ContextMenu/Location/index.tsx | 32 - .../Explorer/ContextMenu/Object/Items.tsx | 122 ++- .../Explorer/ContextMenu/Object/index.tsx | 61 -- .../Explorer/ContextMenu/SharedItems.tsx | 192 ++-- .../Explorer/ContextMenu/context.tsx | 31 + .../$libraryId/Explorer/ContextMenu/index.tsx | 97 +- .../app/$libraryId/Explorer/CopyAsPath.tsx | 33 + .../Explorer/FilePath/DeleteDialog.tsx | 8 +- .../Explorer/FilePath/EraseDialog.tsx | 10 +- .../Explorer/FilePath/Thumb.module.scss | 9 + .../$libraryId/Explorer/FilePath/Thumb.tsx | 432 ++++---- .../$libraryId/Explorer/Inspector/index.tsx | 657 +++++++----- .../app/$libraryId/Explorer/OptionsPanel.tsx | 16 + .../$libraryId/Explorer/ParentContextMenu.tsx | 28 +- .../Explorer/QuickPreview/QuickPreview.tsx | 131 --- .../Explorer/QuickPreview/index.tsx | 6 +- .../Explorer/RevealInNativeExplorer.tsx | 33 + .../app/$libraryId/Explorer/View/GridList.tsx | 601 +++++++++++ .../app/$libraryId/Explorer/View/GridView.tsx | 81 +- .../app/$libraryId/Explorer/View/ListView.tsx | 947 +++++++++++++----- .../$libraryId/Explorer/View/MediaView.tsx | 57 +- .../app/$libraryId/Explorer/View/index.tsx | 330 +++--- .../app/$libraryId/Explorer/ViewContext.ts | 21 +- interface/app/$libraryId/Explorer/index.tsx | 66 +- interface/app/$libraryId/Explorer/store.ts | 31 +- .../app/$libraryId/Explorer/useExplorer.ts | 132 +++ interface/app/$libraryId/Explorer/util.ts | 5 + interface/app/$libraryId/Layout/index.tsx | 4 +- interface/app/$libraryId/TopBar/index.tsx | 41 +- interface/app/$libraryId/location/$id.tsx | 63 +- interface/app/$libraryId/node/$id.tsx | 24 +- .../app/$libraryId/overview/Categories.tsx | 2 +- interface/app/$libraryId/overview/data.ts | 4 +- interface/app/$libraryId/overview/index.tsx | 54 +- interface/app/$libraryId/search.tsx | 36 +- .../settings/library/tags/CreateDialog.tsx | 8 +- interface/app/$libraryId/tag/$id.tsx | 23 +- interface/app/style.scss | 14 + .../AssignTagMenuItems.tsx | 53 +- interface/components/GridList.tsx | 548 ++++------ interface/hooks/useKeyDeleteFile.tsx | 12 +- interface/package.json | 2 +- interface/util/index.tsx | 4 + packages/client/src/core.ts | 3 +- packages/client/src/lib/byte-size.ts | 2 +- packages/client/src/utils/explorerItem.ts | 54 +- pnpm-lock.yaml | Bin 918945 -> 918945 bytes 55 files changed, 3534 insertions(+), 2292 deletions(-) create mode 100644 interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx delete mode 100644 interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx delete mode 100644 interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx delete mode 100644 interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx create mode 100644 interface/app/$libraryId/Explorer/ContextMenu/context.tsx create mode 100644 interface/app/$libraryId/Explorer/CopyAsPath.tsx delete mode 100644 interface/app/$libraryId/Explorer/QuickPreview/QuickPreview.tsx create mode 100644 interface/app/$libraryId/Explorer/RevealInNativeExplorer.tsx create mode 100644 interface/app/$libraryId/Explorer/View/GridList.tsx create mode 100644 interface/app/$libraryId/Explorer/useExplorer.ts rename interface/{app/$libraryId/Explorer/ContextMenu/Object => components}/AssignTagMenuItems.tsx (69%) diff --git a/core/src/api/files.rs b/core/src/api/files.rs index d682d014c..add34c551 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -130,12 +130,12 @@ pub(crate) fn mount() -> AlphaRouter { }) .procedure("updateAccessTime", { R.with2(library()) - .mutation(|(_, library), id: i32| async move { + .mutation(|(_, library), ids: Vec| async move { library .db .object() - .update( - object::id::equals(id), + .update_many( + vec![object::id::in_vec(ids)], vec![object::date_accessed::set(Some(Utc::now().into()))], ) .exec() diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index dc10c1cf7..7b469813d 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use chrono::Utc; use rspc::{alpha::AlphaRouter, ErrorCode}; use sd_prisma::prisma_sync; @@ -36,6 +38,42 @@ pub(crate) fn mount() -> AlphaRouter { .await?) }) }) + .procedure("getWithObjects", { + R.with2(library()).query( + |(_, library), object_ids: Vec| async move { + let Library { db, .. } = library.as_ref(); + + let tags_with_objects = db + .tag() + .find_many(vec![tag::tag_objects::some(vec![ + tag_on_object::object_id::in_vec(object_ids.clone()), + ])]) + .select(tag::select!({ + id + tag_objects(vec![tag_on_object::object_id::in_vec(object_ids.clone())]): select { + object: select { + id + } + } + })) + .exec() + .await?; + + Ok(tags_with_objects + .into_iter() + .map(|tag| { + ( + tag.id, + tag.tag_objects + .into_iter() + .map(|rel| rel.object.id) + .collect::>(), + ) + }) + .collect::>()) + }, + ) + }) .procedure("get", { R.with2(library()) .query(|(_, library), tag_id: i32| async move { @@ -137,6 +175,7 @@ pub(crate) fn mount() -> AlphaRouter { } invalidate_query!(library, "tags.getForObject"); + invalidate_query!(library, "tags.getWithObjects"); Ok(()) }) diff --git a/interface/app/$libraryId/Explorer/Context.tsx b/interface/app/$libraryId/Explorer/Context.tsx index 8bde17d9a..64d503114 100644 --- a/interface/app/$libraryId/Explorer/Context.tsx +++ b/interface/app/$libraryId/Explorer/Context.tsx @@ -1,30 +1,11 @@ import { createContext, useContext } from 'react'; -import { FilePath, Location, NodeState, Tag } from '@sd/client'; - -export type ExplorerParent = - | { - type: 'Location'; - location: Location; - subPath?: FilePath; - } - | { - type: 'Tag'; - tag: Tag; - } - | { - type: 'Node'; - node: NodeState; - }; - -interface ExplorerContext { - parent?: ExplorerParent; -} +import { UseExplorer } from './useExplorer'; /** * Context that must wrap anything to do with the explorer. * This includes explorer views, the inspector, and top bar items. */ -export const ExplorerContext = createContext(null); +export const ExplorerContext = createContext(null); export const useExplorerContext = () => { const ctx = useContext(ExplorerContext); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx b/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx new file mode 100644 index 000000000..5074088d7 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from 'react'; + +type UseCondition = () => TProps | null; + +export class ConditionalItem { + // Named like a hook to please eslint + useCondition: UseCondition; + // Capital 'C' to please eslint + make rendering after destructuring easier + Component: React.FC; + + constructor(public args: { useCondition: UseCondition; Component: React.FC }) { + this.useCondition = args.useCondition; + this.Component = args.Component; + } +} + +export interface ConditionalGroupProps { + items: ConditionalItem[]; + children?: (children: ReactNode) => ReactNode; +} + +/** + * Takes an array of `ConditionalItem` and attempts to render them all, + * returning `null` if all conditions are `null`. + * + * @param items An array of `ConditionalItem` to render. + * @param children An optional render function that can be used to wrap the rendered items. + */ +export const Conditional = ({ items, children }: ConditionalGroupProps) => { + const itemConditions = items.map((item) => item.useCondition()); + + if (itemConditions.every((c) => c === null)) return null; + + const renderedItems = ( + <> + {itemConditions.map((props, i) => { + if (props === null) return null; + const { Component } = items[i]!; + return ; + })} + + ); + + return <>{children ? children(renderedItems) : renderedItems}; +}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx index 84fed0bd7..5119bf690 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx @@ -1,74 +1,81 @@ import { Copy, Scissors } from 'phosphor-react'; -import { FilePath, useLibraryMutation } from '@sd/client'; +import { useLibraryMutation } from '@sd/client'; import { ContextMenu, ModifierKeys } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; +import { isNonEmpty } from '~/util'; +import { useExplorerContext } from '../../Context'; import { getExplorerStore } from '../../store'; import { useExplorerSearchParams } from '../../util'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; -interface Props { - locationId: number; - filePath: FilePath; -} +export const CutCopyItems = new ConditionalItem({ + useCondition: () => { + const { parent } = useExplorerContext(); + const { selectedFilePaths } = useContextMenuContext(); -export const CutCopyItems = ({ locationId, filePath }: Props) => { - const keybind = useKeybindFactory(); - const [{ path }] = useExplorerSearchParams(); + if (parent?.type !== 'Location' || !isNonEmpty(selectedFilePaths)) return null; - const copyFiles = useLibraryMutation('files.copyFiles'); + return { locationId: parent.location.id, selectedFilePaths }; + }, + Component: ({ locationId, selectedFilePaths }) => { + const keybind = useKeybindFactory(); + const [{ path }] = useExplorerSearchParams(); - return ( - <> - { - getExplorerStore().cutCopyState = { - sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathId: filePath.id, - actionType: 'Cut', - active: true - }; - }} - icon={Scissors} - /> + const copyFiles = useLibraryMutation('files.copyFiles'); - { - getExplorerStore().cutCopyState = { - sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathId: filePath.id, - actionType: 'Copy', - active: true - }; - }} - icon={Copy} - /> + return ( + <> + { + getExplorerStore().cutCopyState = { + sourceParentPath: path ?? '/', + sourceLocationId: locationId, + sourcePathIds: selectedFilePaths.map((p) => p.id), + type: 'Cut' + }; + }} + icon={Scissors} + /> - { - try { - await copyFiles.mutateAsync({ - source_location_id: locationId, - sources_file_path_ids: [filePath.id], - target_location_id: locationId, - target_location_relative_directory_path: path ?? '/', - target_file_name_suffix: ' copy' - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to duplcate file, due to an error: ${error}` - }); - } - }} - /> - - ); -}; + { + getExplorerStore().cutCopyState = { + sourceParentPath: path ?? '/', + sourceLocationId: locationId, + sourcePathIds: selectedFilePaths.map((p) => p.id), + type: 'Copy' + }; + }} + icon={Copy} + /> + + { + try { + await copyFiles.mutateAsync({ + source_location_id: locationId, + sources_file_path_ids: selectedFilePaths.map((p) => p.id), + target_location_id: locationId, + target_location_relative_directory_path: path ?? '/', + target_file_name_suffix: ' copy' + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to duplcate file, due to an error: ${error}` + }); + } + }} + /> + + ); + } +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx index beeec7f2b..87e97f664 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx @@ -1,86 +1,99 @@ -import { ClipboardText, Image, Package, Trash, TrashSimple } from 'phosphor-react'; -import { FilePath, libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client'; +import { Image, Package, Trash, TrashSimple } from 'phosphor-react'; +import { libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client'; import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; +import { isNonEmpty } from '~/util'; import { usePlatform } from '~/util/Platform'; +import { useExplorerContext } from '../../Context'; +import { CopyAsPathBase } from '../../CopyAsPath'; import DeleteDialog from '../../FilePath/DeleteDialog'; import EraseDialog from '../../FilePath/EraseDialog'; +import { Conditional, ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; import OpenWith from './OpenWith'; export * from './CutCopyItems'; -interface FilePathProps { - filePath: FilePath; -} +export const Delete = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; -export const Delete = ({ filePath }: FilePathProps) => { - const keybind = useKeybindFactory(); + const locationId = selectedFilePaths[0].location_id; + if (locationId === null) return null; - const locationId = filePath.location_id; + return { selectedFilePaths, locationId }; + }, + Component: ({ selectedFilePaths, locationId }) => { + const keybind = useKeybindFactory(); - return ( - <> - {locationId != null && ( - - dialogManager.create((dp) => ( - - )) - } - /> - )} - - ); -}; - -export const CopyAsPath = ({ pathOrId }: { pathOrId: number | string }) => { - return ( - { - try { - const path = - typeof pathOrId === 'string' - ? pathOrId - : await libraryClient.query(['files.getPath', pathOrId]); - - if (path == null) throw new Error('No file path available'); - - navigator.clipboard.writeText(path); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to copy file path: ${error}` - }); + return ( + + dialogManager.create((dp) => ( + p.id)} + /> + )) } - }} + /> + ); + } +}); + +export const CopyAsPath = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null; + + return { selectedFilePaths }; + }, + Component: ({ selectedFilePaths }) => ( + libraryClient.query(['files.getPath', selectedFilePaths[0].id])} /> - ); -}; + ) +}); -export const Compress = (_: FilePathProps) => { - const keybind = useKeybindFactory(); +export const Compress = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; - return ( - - ); -}; + return { selectedFilePaths }; + }, + Component: ({ selectedFilePaths: _ }) => { + const keybind = useKeybindFactory(); -export const Crypto = (_: FilePathProps) => { - return ( - <> - {/* + ); + } +}); + +export const Crypto = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; + + return { selectedFilePaths }; + }, + Component: ({ selectedFilePaths: _ }) => { + return ( + <> + {/* { } }} /> */} - {/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */} - {/* { } }} /> */} - - ); -}; + + ); + } +}); -export const SecureDelete = ({ filePath }: FilePathProps) => { - const locationId = filePath.location_id; +export const SecureDelete = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; - return ( - <> - {locationId && ( - - dialogManager.create((dp) => ( - - )) - } - disabled - /> - )} - - ); -}; + const locationId = selectedFilePaths[0].location_id; + if (locationId === null) return null; -export const ParentFolderActions = ({ - filePath, - locationId -}: FilePathProps & { locationId: number }) => { - const fullRescan = useLibraryMutation('locations.fullRescan'); - const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation'); + return { locationId, selectedFilePaths }; + }, + Component: ({ locationId, selectedFilePaths }) => ( + + dialogManager.create((dp) => ( + + )) + } + disabled + /> + ) +}); - return ( - <> - { - try { - await fullRescan.mutateAsync({ - location_id: locationId, - reidentify_objects: false - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to rescan location, due to an error: ${error}` - }); - } - }} - label="Rescan Directory" - icon={Package} - /> - { - try { - await generateThumbnails.mutateAsync({ - id: locationId, - path: filePath.materialized_path ?? '/' - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to generate thumbnails, due to an error: ${error}` - }); - } - }} - label="Regen Thumbnails" - icon={Image} - /> - - ); -}; +export const ParentFolderActions = new ConditionalItem({ + useCondition: () => { + const { parent } = useExplorerContext(); -export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => { - const keybind = useKeybindFactory(); - const { platform, openFilePaths: openFilePath } = usePlatform(); - const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + if (parent?.type !== 'Location') return null; - const { library } = useLibraryContext(); + return { parent }; + }, + Component: ({ parent }) => { + const { selectedFilePaths } = useContextMenuContext(); + + const fullRescan = useLibraryMutation('locations.fullRescan'); + const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation'); - if (platform === 'web') return ; - else return ( <> - {openFilePath && ( + { + try { + await fullRescan.mutateAsync({ + location_id: parent.location.id, + reidentify_objects: false + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to rescan location, due to an error: ${error}` + }); + } + }} + label="Rescan Directory" + icon={Package} + /> + { + try { + await generateThumbnails.mutateAsync({ + id: parent.location.id, + path: selectedFilePaths[0]?.materialized_path ?? '/' + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to generate thumbnails, due to an error: ${error}` + }); + } + }} + label="Regen Thumbnails" + icon={Image} + /> + + ); + } +}); + +export const OpenOrDownload = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + const { openFilePaths } = usePlatform(); + + if (!openFilePaths || !isNonEmpty(selectedFilePaths)) return null; + + return { openFilePaths, selectedFilePaths }; + }, + Component: ({ openFilePaths, selectedFilePaths }) => { + const keybind = useKeybindFactory(); + const { platform } = usePlatform(); + const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + + const { library } = useLibraryContext(); + + if (platform === 'web') return ; + else + return ( + <> { - if (filePath.object_id) - updateAccessTime - .mutateAsync(filePath.object_id) - .catch(console.error); + if (selectedFilePaths.length < 1) return; + + updateAccessTime + .mutateAsync( + selectedFilePaths.map((p) => p.object_id!).filter(Boolean) + ) + .catch(console.error); try { - await openFilePath(library.uuid, [filePath.id]); + await openFilePaths( + library.uuid, + selectedFilePaths.map((p) => p.id) + ); } catch (error) { showAlertDialog({ title: 'Error', @@ -232,8 +271,8 @@ export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => { } }} /> - )} - - - ); -}; + + + ); + } +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx index fc8432b9a..9aeed7a17 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx @@ -1,21 +1,26 @@ import { useQuery } from '@tanstack/react-query'; import { Suspense } from 'react'; -import { FilePath, useLibraryContext } from '@sd/client'; +import { useLibraryContext } from '@sd/client'; import { ContextMenu } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { Platform, usePlatform } from '~/util/Platform'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; -export default (props: { filePath: FilePath }) => { - const { getFilePathOpenWithApps, openFilePathWith } = usePlatform(); +export default new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + const { getFilePathOpenWithApps, openFilePathWith } = usePlatform(); - if (!getFilePathOpenWithApps || !openFilePathWith) return null; - if (props.filePath.is_dir) return null; + if (!getFilePathOpenWithApps || !openFilePathWith) return null; + if (selectedFilePaths.some((p) => p.is_dir)) return null; - return ( + return { getFilePathOpenWithApps, openFilePathWith }; + }, + Component: ({ getFilePathOpenWithApps, openFilePathWith }) => ( { /> - ); -}; + ) +}); const Items = ({ - filePath, actions }: { - filePath: FilePath; actions: Required>; }) => { + const { selectedFilePaths } = useContextMenuContext(); + const { library } = useLibraryContext(); + const ids = selectedFilePaths.map((obj) => obj.id); + const items = useQuery( - ['openWith', filePath.id], - () => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]), + ['openWith', ids], + () => actions.getFilePathOpenWithApps(library.uuid, ids), { suspense: true } ); @@ -49,9 +56,10 @@ const Items = ({ key={id} onClick={async () => { try { - await actions.openFilePathWith(library.uuid, [ - [filePath.id, data.url] - ]); + await actions.openFilePathWith( + library.uuid, + ids.map((id) => [id, data.url]) + ); } catch (e) { console.error(e); showAlertDialog({ diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx deleted file mode 100644 index 11bd154a4..000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Plus } from 'phosphor-react'; -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { useExplorerContext } from '../../Context'; -import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '../../ContextMenu'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const filePath = data.item; - const { object } = filePath; - - const { parent } = useExplorerContext(); - - // const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false; - // const mountedKeys = useLibraryQuery(['keys.listMounted']); - // const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0; - - return ( - <> - - - - - - - - - - - - - - - {extra?.({ - object: filePath.object ?? undefined, - filePath: filePath - })} - - - - - - - - - - {object && } - - - - - - - - - {object && } - - {parent?.type === 'Location' && ( - - )} - - - - - - - - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx deleted file mode 100644 index 1f8281f2d..000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { ExtraFn, SharedItems } from '..'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const location = data.item; - - return ( - <> - - - - - - - - - - - {extra?.({ location })} - - - - - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx index eaf6fa3cf..eacf2089d 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx @@ -1,56 +1,92 @@ import { ArrowBendUpRight, TagSimple } from 'phosphor-react'; -import { FilePath, ObjectKind, Object as ObjectType, useLibraryMutation } from '@sd/client'; +import { useMemo } from 'react'; +import { ObjectKind, useLibraryMutation } from '@sd/client'; import { ContextMenu } from '@sd/ui'; import { showAlertDialog } from '~/components'; -import AssignTagMenuItems from './AssignTagMenuItems'; +import AssignTagMenuItems from '~/components/AssignTagMenuItems'; +import { isNonEmpty } from '~/util'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; -export const RemoveFromRecents = ({ object }: { object: ObjectType }) => { - const removeFromRecents = useLibraryMutation('files.removeAccessTime'); +export const RemoveFromRecents = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); - return ( - <> - {object.date_accessed !== null && ( - { - try { - await removeFromRecents.mutateAsync([object.id]); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to remove file from recents, due to an error: ${error}` - }); - } - }} - /> - )} - - ); -}; + if (!isNonEmpty(selectedObjects)) return null; -export const AssignTag = ({ object }: { object: ObjectType }) => ( - - - -); + return { selectedObjects }; + }, + + Component: ({ selectedObjects }) => { + const removeFromRecents = useLibraryMutation('files.removeAccessTime'); + + return ( + { + try { + await removeFromRecents.mutateAsync( + selectedObjects.map((object) => object.id) + ); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to remove file from recents, due to an error: ${error}` + }); + } + }} + /> + ); + } +}); + +export const AssignTag = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); + if (!isNonEmpty(selectedObjects)) return null; + + return { selectedObjects }; + }, + Component: ({ selectedObjects }) => ( + + + + ) +}); const ObjectConversions: Record = { [ObjectKind.Image]: ['PNG', 'WebP', 'Gif'], [ObjectKind.Video]: ['MP4', 'MOV', 'AVI'] }; -export const ConvertObject = ({ object, filePath }: { object: ObjectType; filePath: FilePath }) => { - const { kind } = object; +const ConvertableKinds = [ObjectKind.Image, ObjectKind.Video]; - return ( - <> - {kind !== null && [ObjectKind.Image, ObjectKind.Video].includes(kind as ObjectKind) && ( - - {ObjectConversions[kind]?.map((ext) => ( - - ))} - - )} - - ); -}; +export const ConvertObject = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); + + const kinds = useMemo(() => { + const set = new Set(); + + for (const o of selectedObjects) { + if (o.kind === null || !ConvertableKinds.includes(o.kind)) break; + set.add(o.kind); + } + + return [...set]; + }, [selectedObjects]); + + if (!isNonEmpty(kinds) || kinds.length > 1) return null; + + const [kind] = kinds; + + return { kind }; + }, + Component: ({ kind }) => ( + + {ObjectConversions[kind]?.map((ext) => ( + + ))} + + ) +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx deleted file mode 100644 index ecb281143..000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Plus } from 'phosphor-react'; -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '..'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const object = data.item; - const filePath = data.item.file_paths[0]; - - return ( - <> - {filePath && } - - - - - - - - - - {filePath && } - - - - {extra?.({ - object: object, - filePath: filePath - })} - - - - - - {(object || filePath) && } - - {object && } - - {filePath && ( - - - - - - - )} - - {filePath && ( - <> - - - - )} - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index dde848d97..734dd1e90 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -1,121 +1,143 @@ import { FileX, Share as ShareIcon } from 'phosphor-react'; import { useMemo } from 'react'; -import { ExplorerItem, FilePath, useLibraryContext } from '@sd/client'; import { ContextMenu, ModifierKeys } from '@sd/ui'; -import { useOperatingSystem } from '~/hooks'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; -import { usePlatform } from '~/util/Platform'; +import { isNonEmpty } from '~/util'; +import { Platform } from '~/util/Platform'; +import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer'; import { useExplorerViewContext } from '../ViewContext'; import { getExplorerStore, useExplorerStore } from '../store'; +import { ConditionalItem } from './ConditionalItem'; +import { useContextMenuContext } from './context'; -export const OpenQuickView = ({ item }: { item: ExplorerItem }) => { +export const OpenQuickView = () => { const keybind = useKeybindFactory(); + const { selectedItems } = useContextMenuContext(); return ( (getExplorerStore().quickViewObject = item)} + onClick={() => + // using [0] is not great + (getExplorerStore().quickViewObject = selectedItems[0]) + } /> ); }; -export const Details = () => { - const { showInspector } = useExplorerStore(); - const keybind = useKeybindFactory(); +export const Details = new ConditionalItem({ + useCondition: () => { + const { showInspector } = useExplorerStore(); + if (showInspector) return null; - return ( - <> - {!showInspector && ( - (getExplorerStore().showInspector = true)} - /> - )} - - ); -}; + return {}; + }, + Component: () => { + const keybind = useKeybindFactory(); -export const Rename = () => { - const explorerStore = useExplorerStore(); - const keybind = useKeybindFactory(); - const explorerView = useExplorerViewContext(); + return ( + (getExplorerStore().showInspector = true)} + /> + ); + } +}); - return ( - <> - {explorerStore.layoutMode !== 'media' && ( - explorerView.setIsRenaming(true)} - /> - )} - - ); -}; +export const Rename = new ConditionalItem({ + useCondition: () => { + const { selectedItems } = useContextMenuContext(); + const explorerStore = useExplorerStore(); -export const RevealInNativeExplorer = (props: { locationId: number } | { filePath: FilePath }) => { - const os = useOperatingSystem(); - const keybind = useKeybindFactory(); - const { revealItems } = usePlatform(); - const { library } = useLibraryContext(); + if (explorerStore.layoutMode === 'media' || selectedItems.length > 1) return null; - const osFileBrowserName = useMemo(() => { - const lookup: Record = { - macOS: 'Finder', - windows: 'Explorer' - }; + return {}; + }, + Component: () => { + const explorerView = useExplorerViewContext(); + const keybind = useKeybindFactory(); - return lookup[os] ?? 'file manager'; - }, [os]); + return ( + explorerView.setIsRenaming(true)} + /> + ); + } +}); - return ( - <> - {revealItems && ( - ( - console.log(props), - revealItems(library.uuid, [ - 'filePath' in props - ? { - FilePath: { - id: props.filePath.id - } - } - : { - Location: { - id: props.locationId - } - } - ]) - )} - /> - )} - - ); -}; +export const RevealInNativeExplorer = new ConditionalItem({ + useCondition: () => { + const { selectedItems } = useContextMenuContext(); -export const Deselect = () => { - const { cutCopyState } = useExplorerStore(); + const items = useMemo(() => { + const array: Parameters>[1] = []; - return ( + for (const item of selectedItems) { + switch (item.type) { + case 'Path': { + array.push({ + FilePath: { id: item.item.id } + }); + break; + } + case 'Object': { + // this isn't good but it's the current behaviour + const filePath = item.item.file_paths[0]; + if (filePath) + array.push({ + FilePath: { + id: filePath.id + } + }); + else return []; + break; + } + case 'Location': { + array.push({ + Location: { + id: item.item.id + } + }); + break; + } + } + } + + return array; + }, [selectedItems]); + + if (!isNonEmpty(items)) return null; + + return { items }; + }, + Component: ({ items }) => +}); + +export const Deselect = new ConditionalItem({ + useCondition: () => { + const { cutCopyState } = useExplorerStore(); + + if (cutCopyState.type === 'Idle') return null; + + return {}; + }, + Component: () => (