[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 <nikec.job@gmail.com>

* 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 <ijamespine@me.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
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 <ericson.ds999@gmail.com>
This commit is contained in:
nikec 2023-08-15 10:23:41 +02:00 committed by GitHub
parent 3d51c60900
commit 9c0aec8167
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 3541 additions and 2299 deletions

View file

@ -130,12 +130,12 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
.procedure("updateAccessTime", {
R.with2(library())
.mutation(|(_, library), id: i32| async move {
.mutation(|(_, library), ids: Vec<i32>| 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()

View file

@ -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<Ctx> {
.await?)
})
})
.procedure("getWithObjects", {
R.with2(library()).query(
|(_, library), object_ids: Vec<object::id::Type>| 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::<Vec<_>>(),
)
})
.collect::<BTreeMap<_, _>>())
},
)
})
.procedure("get", {
R.with2(library())
.query(|(_, library), tag_id: i32| async move {
@ -137,6 +175,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}
invalidate_query!(library, "tags.getForObject");
invalidate_query!(library, "tags.getWithObjects");
Ok(())
})

View file

@ -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<ExplorerContext | null>(null);
export const ExplorerContext = createContext<UseExplorer | null>(null);
export const useExplorerContext = () => {
const ctx = useContext(ExplorerContext);

View file

@ -0,0 +1,45 @@
import { ReactNode } from 'react';
type UseCondition<TProps extends object> = () => TProps | null;
export class ConditionalItem<TProps extends object> {
// Named like a hook to please eslint
useCondition: UseCondition<TProps>;
// Capital 'C' to please eslint + make rendering after destructuring easier
Component: React.FC<TProps>;
constructor(public args: { useCondition: UseCondition<TProps>; Component: React.FC<TProps> }) {
this.useCondition = args.useCondition;
this.Component = args.Component;
}
}
export interface ConditionalGroupProps {
items: ConditionalItem<any>[];
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 <Component key={i} {...props} />;
})}
</>
);
return <>{children ? children(renderedItems) : renderedItems}</>;
};

View file

@ -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 (
<>
<ContextMenu.Item
label="Cut"
keybind={keybind([ModifierKeys.Control], ['X'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathId: filePath.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
const copyFiles = useLibraryMutation('files.copyFiles');
<ContextMenu.Item
label="Copy"
keybind={keybind([ModifierKeys.Control], ['C'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathId: filePath.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
return (
<>
<ContextMenu.Item
label="Cut"
keybind={keybind([ModifierKeys.Control], ['X'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathIds: selectedFilePaths.map((p) => p.id),
type: 'Cut'
};
}}
icon={Scissors}
/>
<ContextMenu.Item
label="Duplicate"
keybind={keybind([ModifierKeys.Control], ['D'])}
onClick={async () => {
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}`
});
}
}}
/>
</>
);
};
<ContextMenu.Item
label="Copy"
keybind={keybind([ModifierKeys.Control], ['C'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathIds: selectedFilePaths.map((p) => p.id),
type: 'Copy'
};
}}
icon={Copy}
/>
<ContextMenu.Item
label="Duplicate"
keybind={keybind([ModifierKeys.Control], ['D'])}
onClick={async () => {
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}`
});
}
}}
/>
</>
);
}
});

View file

@ -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 && (
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind={keybind([ModifierKeys.Control], ['Delete'])}
onClick={() =>
dialogManager.create((dp) => (
<DeleteDialog {...dp} location_id={locationId} path_id={filePath.id} />
))
}
/>
)}
</>
);
};
export const CopyAsPath = ({ pathOrId }: { pathOrId: number | string }) => {
return (
<ContextMenu.Item
label="Copy as path"
icon={ClipboardText}
onClick={async () => {
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 (
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind={keybind([ModifierKeys.Control], ['Delete'])}
onClick={() =>
dialogManager.create((dp) => (
<DeleteDialog
{...dp}
locationId={locationId}
pathIds={selectedFilePaths.map((p) => p.id)}
/>
))
}
}}
/>
);
}
});
export const CopyAsPath = new ConditionalItem({
useCondition: () => {
const { selectedFilePaths } = useContextMenuContext();
if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null;
return { selectedFilePaths };
},
Component: ({ selectedFilePaths }) => (
<CopyAsPathBase
getPath={() => 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 (
<ContextMenu.Item
label="Compress"
icon={Package}
keybind={keybind([ModifierKeys.Control], ['B'])}
disabled
/>
);
};
return { selectedFilePaths };
},
Component: ({ selectedFilePaths: _ }) => {
const keybind = useKeybindFactory();
export const Crypto = (_: FilePathProps) => {
return (
<>
{/* <ContextMenu.Item
return (
<ContextMenu.Item
label="Compress"
icon={Package}
keybind={keybind([ModifierKeys.Control], ['B'])}
disabled
/>
);
}
});
export const Crypto = new ConditionalItem({
useCondition: () => {
const { selectedFilePaths } = useContextMenuContext();
if (!isNonEmpty(selectedFilePaths)) return null;
return { selectedFilePaths };
},
Component: ({ selectedFilePaths: _ }) => {
return (
<>
{/* <ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
@ -106,8 +119,8 @@ export const Crypto = (_: FilePathProps) => {
}
}}
/> */}
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
{/* <ContextMenu.Item
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
{/* <ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
@ -128,102 +141,128 @@ export const Crypto = (_: FilePathProps) => {
}
}}
/> */}
</>
);
};
</>
);
}
});
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 && (
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() =>
dialogManager.create((dp) => (
<EraseDialog {...dp} location_id={locationId} path_id={filePath.id} />
))
}
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 }) => (
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() =>
dialogManager.create((dp) => (
<EraseDialog {...dp} locationId={locationId} filePaths={selectedFilePaths} />
))
}
disabled
/>
)
});
return (
<>
<ContextMenu.Item
onClick={async () => {
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}
/>
<ContextMenu.Item
onClick={async () => {
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 <ContextMenu.Item label="Download" />;
else
return (
<>
{openFilePath && (
<ContextMenu.Item
onClick={async () => {
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}
/>
<ContextMenu.Item
onClick={async () => {
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 <ContextMenu.Item label="Download" />;
else
return (
<>
<ContextMenu.Item
label="Open"
keybind={keybind([ModifierKeys.Control], ['O'])}
onClick={async () => {
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 }) => {
}
}}
/>
)}
<OpenWith filePath={filePath} />
</>
);
};
<Conditional items={[OpenWith]} />
</>
);
}
});

View file

@ -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 }) => (
<ContextMenu.SubMenu label="Open with">
<Suspense>
<Items
filePath={props.filePath}
actions={{
getFilePathOpenWithApps,
openFilePathWith
@ -23,21 +28,23 @@ export default (props: { filePath: FilePath }) => {
/>
</Suspense>
</ContextMenu.SubMenu>
);
};
)
});
const Items = ({
filePath,
actions
}: {
filePath: FilePath;
actions: Required<Pick<Platform, 'getFilePathOpenWithApps' | 'openFilePathWith'>>;
}) => {
const { selectedFilePaths } = useContextMenuContext();
const { library } = useLibraryContext();
const ids = selectedFilePaths.map((obj) => obj.id);
const items = useQuery<unknown>(
['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({

View file

@ -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<ExplorerItem, { type: 'Path' }>;
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 (
<>
<FilePathItems.OpenOrDownload filePath={filePath} />
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
<SharedItems.RevealInNativeExplorer filePath={filePath} />
<SharedItems.Rename />
{extra?.({
object: filePath.object ?? undefined,
filePath: filePath
})}
<SharedItems.Deselect />
<ContextMenu.Separator />
<SharedItems.Share />
<ContextMenu.Separator />
{object && <ObjectItems.AssignTag object={object} />}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<FilePathItems.CopyAsPath pathOrId={filePath.id} />
<FilePathItems.Crypto filePath={filePath} />
<FilePathItems.Compress filePath={filePath} />
{object && <ObjectItems.ConvertObject filePath={filePath} object={object} />}
{parent?.type === 'Location' && (
<FilePathItems.ParentFolderActions
filePath={filePath}
locationId={parent.location.id}
/>
)}
<FilePathItems.SecureDelete filePath={filePath} />
</ContextMenu.SubMenu>
<ContextMenu.Separator />
<FilePathItems.Delete filePath={filePath} />
</>
);
};

View file

@ -1,32 +0,0 @@
import { ExplorerItem } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { ExtraFn, SharedItems } from '..';
interface Props {
data: Extract<ExplorerItem, { type: 'Location' }>;
extra?: ExtraFn;
}
export default ({ data, extra }: Props) => {
const location = data.item;
return (
<>
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
<SharedItems.RevealInNativeExplorer locationId={location.id} />
{extra?.({ location })}
<ContextMenu.Separator />
<SharedItems.Share />
</>
);
};

View file

@ -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 && (
<ContextMenu.Item
label="Remove from recents"
onClick={async () => {
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 }) => (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={object.id} />
</ContextMenu.SubMenu>
);
return { selectedObjects };
},
Component: ({ selectedObjects }) => {
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
return (
<ContextMenu.Item
label="Remove From Recents"
onClick={async () => {
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 }) => (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objects={selectedObjects} />
</ContextMenu.SubMenu>
)
});
const ObjectConversions: Record<number, string[]> = {
[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) && (
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
{ObjectConversions[kind]?.map((ext) => (
<ContextMenu.Item key={ext} label={ext} disabled />
))}
</ContextMenu.SubMenu>
)}
</>
);
};
export const ConvertObject = new ConditionalItem({
useCondition: () => {
const { selectedObjects } = useContextMenuContext();
const kinds = useMemo(() => {
const set = new Set<ObjectKind>();
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 }) => (
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
{ObjectConversions[kind]?.map((ext) => (
<ContextMenu.Item key={ext} label={ext} disabled />
))}
</ContextMenu.SubMenu>
)
});

View file

@ -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<ExplorerItem, { type: 'Object' }>;
extra?: ExtraFn;
}
export default ({ data, extra }: Props) => {
const object = data.item;
const filePath = data.item.file_paths[0];
return (
<>
{filePath && <FilePathItems.OpenOrDownload filePath={filePath} />}
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
{filePath && <SharedItems.RevealInNativeExplorer filePath={filePath} />}
<SharedItems.Rename />
{extra?.({
object: object,
filePath: filePath
})}
<ContextMenu.Separator />
<SharedItems.Share />
{(object || filePath) && <ContextMenu.Separator />}
{object && <ObjectItems.AssignTag object={object} />}
{filePath && (
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<FilePathItems.CopyAsPath pathOrId={filePath.id} />
<FilePathItems.Crypto filePath={filePath} />
<FilePathItems.Compress filePath={filePath} />
<ObjectItems.ConvertObject filePath={filePath} object={object} />
</ContextMenu.SubMenu>
)}
{filePath && (
<>
<ContextMenu.Separator />
<FilePathItems.Delete filePath={filePath} />
</>
)}
</>
);
};

View file

@ -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 (
<ContextMenu.Item
label="Quick view"
keybind={keybind([], [' '])}
onClick={() => (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 && (
<ContextMenu.Item
label="Details"
keybind={keybind([ModifierKeys.Control], ['I'])}
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
)}
</>
);
};
return {};
},
Component: () => {
const keybind = useKeybindFactory();
export const Rename = () => {
const explorerStore = useExplorerStore();
const keybind = useKeybindFactory();
const explorerView = useExplorerViewContext();
return (
<ContextMenu.Item
label="Details"
keybind={keybind([ModifierKeys.Control], ['I'])}
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
);
}
});
return (
<>
{explorerStore.layoutMode !== 'media' && (
<ContextMenu.Item
label="Rename"
keybind={keybind([], ['Enter'])}
onClick={() => 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<string, string> = {
macOS: 'Finder',
windows: 'Explorer'
};
return {};
},
Component: () => {
const explorerView = useExplorerViewContext();
const keybind = useKeybindFactory();
return lookup[os] ?? 'file manager';
}, [os]);
return (
<ContextMenu.Item
label="Rename"
keybind={keybind([], ['Enter'])}
onClick={() => explorerView.setIsRenaming(true)}
/>
);
}
});
return (
<>
{revealItems && (
<ContextMenu.Item
label={`Reveal in ${osFileBrowserName}`}
keybind={keybind([ModifierKeys.Control], ['Y'])}
onClick={() => (
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<NonNullable<Platform['revealItems']>>[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 }) => <RevealInNativeExplorerBase items={items} />
});
export const Deselect = new ConditionalItem({
useCondition: () => {
const { cutCopyState } = useExplorerStore();
if (cutCopyState.type === 'Idle') return null;
return {};
},
Component: () => (
<ContextMenu.Item
label="Deselect"
hidden={!cutCopyState.active}
icon={FileX}
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
type: 'Idle'
};
}}
icon={FileX}
/>
);
};
)
});
export const Share = () => {
return (

View file

@ -0,0 +1,31 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { ExplorerItem, FilePath, Object, useItemsAsFilePaths, useItemsAsObjects } from '@sd/client';
import { NonEmptyArray } from '~/util';
const ContextMenuContext = createContext<{
selectedItems: NonEmptyArray<ExplorerItem>;
selectedFilePaths: FilePath[];
selectedObjects: Object[];
} | null>(null);
export const ContextMenuContextProvider = ({
selectedItems,
children
}: PropsWithChildren<{
selectedItems: NonEmptyArray<ExplorerItem>;
}>) => {
const selectedFilePaths = useItemsAsFilePaths(selectedItems);
const selectedObjects = useItemsAsObjects(selectedItems);
return (
<ContextMenuContext.Provider value={{ selectedItems, selectedFilePaths, selectedObjects }}>
{children}
</ContextMenuContext.Provider>
);
};
export const useContextMenuContext = () => {
const context = useContext(ContextMenuContext);
if (!context) throw new Error('ContextMenuContext.Provider not found');
return context;
};

View file

@ -1,26 +1,85 @@
import { ReactNode } from 'react';
import { ExplorerItem, FilePath, Location, Object } from '@sd/client';
import FilePathCM from './FilePath';
import LocationCM from './Location';
import ObjectCM from './Object';
import { Plus } from 'phosphor-react';
import { ReactNode, useMemo } from 'react';
import { ContextMenu } from '@sd/ui';
import { isNonEmpty } from '~/util';
import { useExplorerContext } from '../Context';
import { Conditional, ConditionalGroupProps } from './ConditionalItem';
import * as FilePathItems from './FilePath/Items';
import * as ObjectItems from './Object/Items';
import * as SharedItems from './SharedItems';
import { ContextMenuContextProvider } from './context';
export * as SharedItems from './SharedItems';
export * as FilePathItems from './FilePath/Items';
export * as ObjectItems from './Object/Items';
export type ExtraFn = (a: {
object?: Object;
filePath?: FilePath;
location?: Location;
}) => ReactNode;
const Items = ({ children }: { children?: () => ReactNode }) => (
<>
<Conditional items={[FilePathItems.OpenOrDownload]} />
<SharedItems.OpenQuickView />
export default ({ item, extra }: { item: ExplorerItem; extra?: ExtraFn }) => {
switch (item.type) {
case 'Path':
return <FilePathCM data={item} extra={extra} />;
case 'Object':
return <ObjectCM data={item} extra={extra} />;
case 'Location':
return <LocationCM data={item} extra={extra} />;
}
<SeparatedConditional items={[SharedItems.Details]} />
<ContextMenu.Separator />
<Conditional
items={[
SharedItems.RevealInNativeExplorer,
SharedItems.Rename,
FilePathItems.CutCopyItems,
SharedItems.Deselect
]}
/>
{children?.()}
<ContextMenu.Separator />
<SharedItems.Share />
<SeparatedConditional items={[ObjectItems.AssignTag]} />
<Conditional
items={[
FilePathItems.CopyAsPath,
FilePathItems.Crypto,
FilePathItems.Compress,
ObjectItems.ConvertObject,
FilePathItems.ParentFolderActions,
FilePathItems.SecureDelete
]}
>
{(items) => (
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
{items}
</ContextMenu.SubMenu>
)}
</Conditional>
<SeparatedConditional items={[FilePathItems.Delete]} />
</>
);
export default ({ children }: { children?: () => ReactNode }) => {
const explorer = useExplorerContext();
const selectedItems = useMemo(() => [...explorer.selectedItems], [explorer.selectedItems]);
if (!isNonEmpty(selectedItems)) return null;
return (
<ContextMenuContextProvider selectedItems={selectedItems}>
<Items>{children}</Items>
</ContextMenuContextProvider>
);
};
/**
* A `Conditional` that inserts a `<ContextMenu.Separator />` above its items.
*/
const SeparatedConditional = ({ items, children }: ConditionalGroupProps) => (
<Conditional items={items}>
{(c) => (
<>
<ContextMenu.Separator />
{children ? children(c) : c}
</>
)}
</Conditional>
);

View file

@ -0,0 +1,33 @@
import { ClipboardText } from 'phosphor-react';
import { ContextMenu } from '@sd/ui';
import { showAlertDialog } from '~/components';
export const CopyAsPathBase = (
props: { path: string } | { getPath: () => Promise<string | null> }
) => {
return (
<ContextMenu.Item
label="Copy as path"
icon={ClipboardText}
onClick={async () => {
try {
const path = 'path' in props ? props.path : await props.getPath();
{
/* 'path' in props
? props.path
: await libraryClient.query(['files.getPath', props.filePath.id]); */
}
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}`
});
}
}}
/>
);
};

View file

@ -3,8 +3,8 @@ import { CheckBox, Dialog, Tooltip, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm } from '@sd/ui/src/forms';
interface Props extends UseDialogProps {
location_id: number;
path_id: number;
locationId: number;
pathIds: number[];
}
export default (props: Props) => {
@ -17,8 +17,8 @@ export default (props: Props) => {
form={form}
onSubmit={form.handleSubmit(() =>
deleteFile.mutateAsync({
location_id: props.location_id,
file_path_ids: [props.path_id]
location_id: props.locationId,
file_path_ids: props.pathIds
})
)}
dialog={useDialog(props)}

View file

@ -1,11 +1,11 @@
import { useState } from 'react';
import { useLibraryMutation } from '@sd/client';
import { FilePath, useLibraryMutation } from '@sd/client';
import { Dialog, Slider, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm, z } from '@sd/ui/src/forms';
interface Props extends UseDialogProps {
location_id: number;
path_id: number;
locationId: number;
filePaths: FilePath[];
}
const schema = z.object({
@ -29,8 +29,8 @@ export default (props: Props) => {
form={form}
onSubmit={form.handleSubmit((data) =>
eraseFile.mutateAsync({
location_id: props.location_id,
file_path_ids: [props.path_id],
location_id: props.locationId,
file_path_ids: props.filePaths.map((p) => p.id),
passes: data.passes.toString()
})
)}

View file

@ -6,3 +6,12 @@
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.checkersLight {
background-image: linear-gradient(45deg, #e2e2e2 25%, transparent 25%),
linear-gradient(-45deg, #e2e2e2 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e2e2 75%),
linear-gradient(-45deg, transparent 75%, #e2e2e2 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}

View file

@ -1,6 +1,14 @@
import { getIcon, iconNames } from '@sd/assets/util';
import clsx from 'clsx';
import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
ImgHTMLAttributes,
VideoHTMLAttributes,
memo,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { ExplorerItem, getItemFilePath, getItemLocation, useLibraryContext } from '@sd/client';
import { PDFViewer, TEXTViewer } from '~/components';
import { useCallbackToWatchResize, useIsDark } from '~/hooks';
@ -11,177 +19,101 @@ import { getExplorerStore } from '../store';
import { useExplorerItemData } from '../util';
import classes from './Thumb.module.scss';
interface ThumbnailProps {
src: string;
cover?: boolean;
onLoad?: () => void;
onError?: () => void;
decoding?: ImgHTMLAttributes<HTMLImageElement>['decoding'];
className?: string;
crossOrigin?: ImgHTMLAttributes<HTMLImageElement>['crossOrigin'];
videoBarsSize?: number;
videoExtension?: string;
}
const THUMB_TYPE = {
ICON: 'icon',
ORIGINAL: 'original',
THUMBNAIL: 'thumbnail'
} as const;
const Thumbnail = memo(
({ crossOrigin, videoBarsSize, videoExtension, ...props }: ThumbnailProps) => {
const ref = useRef<HTMLImageElement>(null);
const [size, setSize] = useState<null | { width: number; height: number }>(null);
useCallbackToWatchResize(
(rect) => {
const { width, height } = rect;
setSize((width && height && { width, height }) || null);
},
[],
ref
);
return (
<>
<img
// Order matter for crossOrigin attr
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
{...(crossOrigin ? { crossOrigin } : {})}
src={props.src}
ref={ref}
style={
videoBarsSize
? size && size.height >= size.width
? {
borderLeftWidth: videoBarsSize,
borderRightWidth: videoBarsSize
}
: {
borderTopWidth: videoBarsSize,
borderBottomWidth: videoBarsSize
}
: {}
}
onLoad={props.onLoad}
onError={() => {
props.onError?.();
setSize(null);
}}
decoding={props.decoding}
className={props.className}
draggable={false}
/>
{videoExtension && (
<div
style={
props.cover
? {}
: size
? {
marginTop: Math.floor(size.height / 2) - 2,
marginLeft: Math.floor(size.width / 2) - 2
}
: { display: 'none' }
}
className={clsx(
props.cover
? 'bottom-1 right-1'
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
'absolute rounded !text-white',
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
)}
>
{videoExtension}
</div>
)}
</>
);
}
);
enum ThumbType {
Icon,
Original,
Thumbnail,
Location
}
type ThumbType = (typeof THUMB_TYPE)[keyof typeof THUMB_TYPE];
export interface ThumbProps {
data: ExplorerItem;
size: null | number;
cover?: boolean;
className?: string;
loadOriginal?: boolean;
size?: number;
cover?: boolean;
frame?: boolean;
mediaControls?: boolean;
pauseVideo?: boolean;
className?: string;
childClassName?: string | ((type: ThumbType) => string | undefined);
}
function FileThumb({ size, cover, ...props }: ThumbProps) {
export const FileThumb = memo((props: ThumbProps) => {
const isDark = useIsDark();
const platform = usePlatform();
const itemData = useExplorerItemData(props.data);
const locationData = getItemLocation(props.data);
const filePath = getItemFilePath(props.data);
const { library } = useLibraryContext();
const [src, setSrc] = useState<null | string>(null);
const [loaded, setLoaded] = useState<boolean>(false);
const [thumbType, setThumbType] = useState(ThumbType.Icon);
const { parent } = useExplorerContext();
const { library } = useLibraryContext();
const [src, setSrc] = useState<string>();
const [loaded, setLoaded] = useState<boolean>(false);
const [thumbType, setThumbType] = useState<ThumbType>('icon');
const childClassName = 'max-h-full max-w-full object-contain';
const frameClassName = clsx(
'rounded-sm border-2 border-app-line bg-app-darkBox',
isDark ? classes.checkers : classes.checkersLight
);
const onLoad = () => setLoaded(true);
const onError = () => {
setLoaded(false);
setThumbType((prevThumbType) =>
prevThumbType === 'original' && itemData.hasLocalThumbnail ? 'thumbnail' : 'icon'
);
};
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
// thus avoiding improper thumb types changes
useLayoutEffect(() => {
// Reset src when item changes, to allow detection of yet not updated src
setSrc(null);
setSrc(undefined);
setLoaded(false);
if (locationData) {
setThumbType(ThumbType.Location);
} else if (props.loadOriginal) {
setThumbType(ThumbType.Original);
} else if (itemData.hasLocalThumbnail) {
setThumbType(ThumbType.Thumbnail);
} else {
setThumbType(ThumbType.Icon);
}
}, [props.loadOriginal, locationData, itemData]);
if (props.loadOriginal) setThumbType('original');
else if (itemData.hasLocalThumbnail) setThumbType('thumbnail');
else setThumbType('icon');
}, [props.loadOriginal, itemData]);
useEffect(() => {
const {
casId,
kind,
isDir,
extension,
locationId: itemLocationId,
thumbnailKey
} = itemData;
const locationId =
itemLocationId ?? (parent?.type === 'Location' ? parent.location.id : null);
itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null);
switch (thumbType) {
case ThumbType.Original:
if (locationId) {
case 'original':
if (locationId === null) setThumbType('thumbnail');
else {
setSrc(
platform.getFileUrl(
library.uuid,
locationId,
filePath?.id || props.data.item.id,
// Workaround Linux webview not supporting playing video and audio through custom protocol urls
kind == 'Video' || kind == 'Audio'
itemData.kind == 'Video' || itemData.kind == 'Audio'
)
);
} else {
setThumbType(ThumbType.Thumbnail);
}
break;
case ThumbType.Thumbnail:
if (casId && thumbnailKey) {
setSrc(platform.getThumbnailUrlByThumbKey(thumbnailKey));
} else {
setThumbType(ThumbType.Icon);
}
break;
case ThumbType.Location:
setSrc(getIcon('Folder', isDark, extension, true));
case 'thumbnail':
if (!itemData.casId || !itemData.thumbnailKey) setThumbType('icon');
else setSrc(platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey));
break;
default:
if (isDir !== null) setSrc(getIcon(kind, isDark, extension, isDir));
setSrc(
getIcon(
itemData.isDir ? 'Folder' : itemData.kind,
isDark,
itemData.extension,
itemData.isDir
)
);
break;
}
}, [
@ -195,54 +127,50 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
parent
]);
const onLoad = () => setLoaded(true);
const onError = () => {
setLoaded(false);
setThumbType((prevThumbType) => {
return prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
? ThumbType.Thumbnail
: ThumbType.Icon;
});
};
const { kind, extension } = itemData;
const childClassName = 'max-h-full max-w-full object-contain';
return (
<div
style={{
visibility: loaded ? 'visible' : 'hidden',
...(size ? { maxWidth: size, width: size - 10, height: size } : {})
...(props.size
? { maxWidth: props.size, width: props.size, height: props.size }
: {})
}}
className={clsx(
'relative flex shrink-0 items-center justify-center',
size &&
kind !== 'Video' &&
thumbType !== ThumbType.Icon &&
'border-2 border-transparent',
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
loaded ? 'visible' : 'invisible',
!props.size && 'h-full w-full',
props.cover && 'overflow-hidden',
props.className
)}
>
{(() => {
if (src == null) return null;
if (!src) return;
const className = clsx(
childClassName,
typeof props.childClassName === 'function'
? props.childClassName(thumbType)
: props.childClassName
);
switch (thumbType) {
case ThumbType.Original:
switch (extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind) {
case 'original': {
switch (itemData.extension === 'pdf' ? 'PDF' : itemData.kind) {
case 'PDF':
if (!pdfViewerEnabled()) return;
return (
<PDFViewer
src={src}
onLoad={onLoad}
onError={onError}
className={clsx(
'h-full w-full border-0',
childClassName,
props.className
'h-full w-full',
className,
props.frame && frameClassName
)}
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
/>
);
case 'Text':
return (
<TEXTViewer
@ -250,37 +178,41 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
onLoad={onLoad}
onError={onError}
className={clsx(
'h-full w-full border-0 font-mono px-4',
!props.mediaControls ? 'overflow-hidden' : 'overflow-auto',
childClassName,
props.className
'h-full w-full px-4 font-mono',
!props.mediaControls
? 'overflow-hidden'
: 'overflow-auto',
className,
props.frame && [frameClassName, '!bg-none']
)}
crossOrigin="anonymous"
/>
);
case 'Video':
return (
<Video
src={src}
onLoad={onLoad}
onLoadedData={onLoad}
onError={onError}
paused={props.pauseVideo}
controls={props.mediaControls}
className={clsx(
childClassName,
size && 'rounded border-x-0 border-black',
props.className
)}
className={clsx(className, props.frame && frameClassName)}
/>
);
case 'Audio':
return (
<>
<img
src={getIcon(iconNames.Audio, isDark, extension)}
src={getIcon(
iconNames.Audio,
isDark,
itemData.extension
)}
onLoad={onLoad}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
decoding={props.size ? 'async' : 'sync'}
className={childClassName}
draggable={false}
/>
{props.mediaControls && (
@ -299,50 +231,45 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
</>
);
}
}
// eslint-disable-next-line no-fallthrough
case ThumbType.Thumbnail:
case 'thumbnail':
return (
<Thumbnail
src={src}
cover={cover}
cover={props.cover}
onLoad={onLoad}
onError={onError}
decoding={size ? 'async' : 'sync'}
decoding={props.size ? 'async' : 'sync'}
className={clsx(
cover
props.cover
? 'min-h-full min-w-full object-cover object-center'
: childClassName,
kind === 'Video' ? 'rounded' : 'rounded-sm',
ThumbType.Original || [
classes.checkers,
'shadow shadow-black/30'
],
size &&
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
props.className
: className,
props.frame &&
(itemData.kind !== 'Video' || thumbType == 'original')
? frameClassName
: null
)}
crossOrigin={ThumbType.Original && 'anonymous'} // Here it is ok, because it is not a react attr
videoBarsSize={
(kind === 'Video' && size && Math.floor(size / 10)) || 0
}
videoExtension={
(kind === 'Video' &&
(cover || size == null || size > 80) &&
extension) ||
''
crossOrigin={thumbType !== 'original' ? 'anonymous' : undefined} // Here it is ok, because it is not a react attr
videoBars={itemData.kind === 'Video' && !props.cover}
extension={
itemData.extension && itemData.kind === 'Video'
? itemData.extension
: undefined
}
/>
);
default:
return (
<img
src={src}
onLoad={onLoad}
onError={() => setLoaded(false)}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
decoding={props.size ? 'async' : 'sync'}
className={childClassName}
draggable={false}
/>
);
@ -350,53 +277,120 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
})()}
</div>
);
});
interface ThumbnailProps extends ImgHTMLAttributes<HTMLImageElement> {
cover?: boolean;
videoBars?: boolean;
extension?: string;
}
export default memo(FileThumb);
const Thumbnail = memo(
({
crossOrigin,
videoBars,
extension,
cover,
onError,
className,
...props
}: ThumbnailProps) => {
const ref = useRef<HTMLImageElement>(null);
interface VideoProps {
src: string;
const [size, setSize] = useState<{ width: number; height: number }>();
useCallbackToWatchResize(({ width, height }) => setSize({ width, height }), [], ref);
const videoBarSize = (size: number) => Math.floor(size / 10);
return (
<>
<img
// Order matter for crossOrigin attr
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
{...(crossOrigin ? { crossOrigin } : {})}
ref={ref}
onError={(e) => {
onError?.(e);
setSize(undefined);
}}
draggable={false}
className={clsx(className, videoBars && 'rounded border-black')}
style={
videoBars
? size
? size.height >= size.width
? {
borderLeftWidth: videoBarSize(size.height),
borderRightWidth: videoBarSize(size.height)
}
: {
borderTopWidth: videoBarSize(size.width),
borderBottomWidth: videoBarSize(size.width)
}
: {}
: {}
}
{...props}
/>
{(cover || (size && size.width > 80)) && extension && (
<div
style={{
...(!cover &&
size && {
marginTop: Math.floor(size.height / 2) - 2,
marginLeft: Math.floor(size.width / 2) - 2
})
}}
className={clsx(
'absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
cover
? 'bottom-1 right-1'
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full'
)}
>
{extension}
</div>
)}
</>
);
}
);
interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
paused?: boolean;
controls?: boolean;
className?: string;
onLoad?: () => void;
onError?: () => void;
}
const Video = memo(({ src, paused, controls, className, onLoad, onError }: VideoProps) => {
const video = useRef<HTMLVideoElement>(null);
const Video = memo(({ paused, ...props }: VideoProps) => {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (video.current) {
paused ? video.current.pause() : video.current.play();
}
if (!ref.current) return;
paused ? ref.current.pause() : ref.current.play();
}, [paused]);
return (
<video
// Order matter for crossOrigin attr
crossOrigin="anonymous"
ref={video}
src={src}
onError={onError}
ref={ref}
autoPlay={!paused}
onVolumeChange={(e) => {
const video = e.target as HTMLVideoElement;
getExplorerStore().mediaPlayerVolume = video.volume;
}}
controls={controls}
onCanPlay={(e) => {
const video = e.target as HTMLVideoElement;
// Why not use the element's attribute? Because React...
// https://github.com/facebook/react/issues/10389
video.loop = !controls;
video.muted = !controls;
video.loop = !props.controls;
video.muted = !props.controls;
video.volume = getExplorerStore().mediaPlayerVolume;
}}
className={className}
playsInline
onLoadedData={onLoad}
draggable={false}
{...props}
>
<p>Video preview is not supported.</p>
</video>

View file

@ -1,4 +1,3 @@
// import types from '../../constants/file-types.json';
import { Image, Image_Light } from '@sd/assets/icons';
import clsx from 'clsx';
import dayjs from 'dayjs';
@ -7,278 +6,464 @@ import {
CircleWavyCheck,
Clock,
Cube,
Eraser,
FolderOpen,
Hash,
Icon,
Link,
Lock,
Path,
Snowflake
} from 'phosphor-react';
import { HTMLAttributes, useEffect, useState } from 'react';
import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
ExplorerItem,
Location,
ObjectKind,
Tag,
byteSize,
bytesToNumber,
getItemFilePath,
getItemObject,
isPath,
useItemsAsObjects,
useLibraryQuery
} from '@sd/client';
import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui';
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
import { useIsDark } from '~/hooks';
import AssignTagMenuItems from '../ContextMenu/Object/AssignTagMenuItems';
import FileThumb from '../FilePath/Thumb';
import { useExplorerStore } from '../store.js';
import { isNonEmpty } from '~/util';
import { stringify } from '~/util/uuid';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { useExplorerStore } from '../store';
import FavoriteButton from './FavoriteButton';
import Note from './Note';
export const InfoPill = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
export const MetaContainer = tw.div`flex flex-col px-4 py-1.5`;
export const MetaContainer = tw.div`flex flex-col px-4 py-2 gap-1`;
export const MetaTitle = tw.h5`text-xs font-bold`;
export const MetaKeyName = tw.h5`text-xs flex-shrink-0 flex-wrap-0`;
export const MetaValue = tw.p`text-xs break-all text-ink truncate`;
const MetaTextLine = tw.div`flex items-center my-0.5 text-xs text-ink-dull`;
const InspectorIcon = ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
);
const DATE_FORMAT = 'D MMM YYYY';
interface Props extends HTMLAttributes<HTMLDivElement> {
context?: Location | Tag;
data?: ExplorerItem;
showThumbnail?: boolean;
}
export const Inspector = ({ data, context, showThumbnail = true, ...props }: Props) => {
export const Inspector = ({ showThumbnail = true, ...props }: Props) => {
const explorer = useExplorerContext();
const isDark = useIsDark();
const objectData = data ? getItemObject(data) : null;
const filePathData = data ? getItemFilePath(data) : null;
const explorerStore = useExplorerStore();
const isDir = data?.type === 'Path' ? data.item.is_dir : false;
// this prevents the inspector from fetching data when the user is navigating quickly
const [readyToFetch, setReadyToFetch] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
setReadyToFetch(true);
}, 350);
return () => clearTimeout(timeout);
}, [data?.item.id]);
// this is causing LAG
const tags = useLibraryQuery(['tags.getForObject', objectData?.id || -1], {
enabled: readyToFetch
});
const fullObjectData = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], {
enabled: readyToFetch && objectData?.id !== undefined
});
const item = data?.item;
const { data: fileFullPath } = useLibraryQuery(['files.getPath', item?.id || -1]);
// map array of numbers into string
const pub_id = fullObjectData?.data?.pub_id.map((n: number) => n.toString(16)).join('');
const selectedItems = useMemo(() => [...explorer.selectedItems], [explorer.selectedItems]);
return (
<div {...props}>
{item ? (
<>
{showThumbnail && (
<div className="mb-2 aspect-square">
<FileThumb
pauseVideo={!!explorerStore.quickViewObject}
loadOriginal
size={null}
data={data}
className="mx-auto"
/>
</div>
{showThumbnail && (
<div className="relative mb-2 flex aspect-square items-center justify-center px-2">
{isNonEmpty(selectedItems) ? (
<Thumbnails items={selectedItems} />
) : (
<img src={isDark ? Image : Image_Light} />
)}
<div className="flex w-full select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">
<h3 className="truncate px-3 pb-1 pt-2 text-base font-bold">
{filePathData?.name}
{filePathData?.extension && `.${filePathData.extension}`}
</h3>
{objectData && (
<div className="mx-3 mb-0.5 mt-1 flex flex-row space-x-0.5">
<Tooltip label="Favorite">
<FavoriteButton data={objectData} />
</Tooltip>
<Tooltip label="Encrypt">
<Button size="icon">
<Lock className="h-[18px] w-[18px]" />
</Button>
</Tooltip>
<Tooltip label="Share">
<Button size="icon">
<Link className="h-[18px] w-[18px]" />
</Button>
</Tooltip>
</div>
)}
{isPath(data) && context && 'path' in context && (
<MetaContainer>
<MetaTitle>URI</MetaTitle>
<MetaValue>
{`${context.path}/${data.item.materialized_path}${
data.item.name
}${data.item.is_dir ? `.${data.item.extension}` : '/'}`}
</MetaValue>
</MetaContainer>
)}
<Divider />
<MetaContainer>
<div className="flex flex-wrap gap-1 overflow-hidden">
<InfoPill>
{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]}
</InfoPill>
{filePathData?.extension && (
<InfoPill>{filePathData.extension}</InfoPill>
)}
{tags.data?.map((tag) => (
<Tooltip
key={tag.id}
label={tag.name || ''}
className="flex overflow-hidden"
>
<InfoPill
className="truncate !text-white"
style={{ backgroundColor: tag.color + 'CC' }}
>
{tag.name}
</InfoPill>
</Tooltip>
))}
{objectData?.id && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objectId={objectData.id} />
</DropdownMenu.Root>
)}
</div>
</MetaContainer>
<Divider />
<MetaContainer className="!flex-row space-x-2">
{filePathData?.size_in_bytes_bytes && (
<MetaTextLine>
<InspectorIcon component={Cube} />
<span className="mr-1.5">Size</span>
<MetaValue>
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
</MetaValue>
</MetaTextLine>
)}
{fullObjectData.data?.media_data?.duration_seconds && (
<MetaTextLine>
<InspectorIcon component={Clock} />
<span className="mr-1.5">Duration</span>
<MetaValue>
{fullObjectData.data.media_data.duration_seconds}
</MetaValue>
</MetaTextLine>
)}
</MetaContainer>
<Divider />
<MetaContainer>
<Tooltip label={dayjs(item.date_created).format('h:mm:ss a')}>
<MetaTextLine>
<InspectorIcon component={Clock} />
<MetaKeyName className="mr-1.5">Created</MetaKeyName>
<MetaValue>
{dayjs(item.date_created).format('MMM Do YYYY')}
</MetaValue>
</MetaTextLine>
</Tooltip>
{filePathData && (
<Tooltip
label={dayjs(filePathData.date_indexed).format('h:mm:ss a')}
>
<MetaTextLine>
<InspectorIcon component={Barcode} />
<MetaKeyName className="mr-1.5">Indexed</MetaKeyName>
<MetaValue>
{dayjs(filePathData?.date_indexed).format(
'MMM Do YYYY'
)}
</MetaValue>
</MetaTextLine>
</Tooltip>
)}
{fileFullPath && (
<Tooltip label={fileFullPath}>
<MetaTextLine>
<InspectorIcon component={Path} />
<MetaKeyName className="mr-1.5">Path</MetaKeyName>
<MetaValue>{fileFullPath}</MetaValue>
</MetaTextLine>
</Tooltip>
)}
</MetaContainer>
{!isDir && objectData && (
<>
<Note data={objectData} />
<Divider />
<MetaContainer>
<Tooltip label={filePathData?.cas_id || ''}>
<MetaTextLine>
<InspectorIcon component={Snowflake} />
<MetaKeyName className="mr-1.5">Content ID</MetaKeyName>
<MetaValue>{filePathData?.cas_id || ''}</MetaValue>
</MetaTextLine>
</Tooltip>
{filePathData?.integrity_checksum && (
<Tooltip label={filePathData?.integrity_checksum || ''}>
<MetaTextLine>
<InspectorIcon component={CircleWavyCheck} />
<MetaKeyName className="mr-1.5">
Checksum
</MetaKeyName>
<MetaValue>
{filePathData?.integrity_checksum}
</MetaValue>
</MetaTextLine>
</Tooltip>
)}
{pub_id && (
<Tooltip label={pub_id || ''}>
<MetaTextLine>
<InspectorIcon component={Hash} />
<MetaKeyName className="mr-1.5">
Object ID
</MetaKeyName>
<MetaValue>{pub_id}</MetaValue>
</MetaTextLine>
</Tooltip>
)}
</MetaContainer>
</>
)}
</div>
</>
) : (
<div className="flex w-full flex-col items-center justify-center">
<img src={isDark ? Image : Image_Light} />
<div
className="mt-[15px] flex h-[390px] w-[245px] select-text items-center justify-center
rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10"
>
<p className="text-sm text-ink-dull">Nothing selected</p>
</div>
</div>
)}
<div className="flex select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">
{!isNonEmpty(selectedItems) ? (
<div className="flex h-[390px] items-center justify-center text-sm text-ink-dull">
Nothing selected
</div>
) : selectedItems.length === 1 ? (
<SingleItemMetadata item={selectedItems[0]} />
) : (
<MultiItemMetadata items={selectedItems} />
)}
</div>
</div>
);
};
const Thumbnails = ({ items }: { items: ExplorerItem[] }) => {
const explorerStore = useExplorerStore();
const lastThreeItems = items.slice(-3).reverse();
return (
<>
{lastThreeItems.map((item, i, thumbs) => (
<FileThumb
key={item.item.id}
loadOriginal
data={item}
className={clsx(
thumbs.length > 1 && '!absolute',
i === 0 && thumbs.length > 1 && 'z-30 !h-[76%] !w-[76%]',
i === 1 && 'z-20 !h-[80%] !w-[80%] rotate-[-5deg]',
i === 2 && 'z-10 !h-[84%] !w-[84%] rotate-[7deg]'
)}
pauseVideo={!!explorerStore.quickViewObject || thumbs.length > 1}
frame={thumbs.length > 1}
childClassName={(type) =>
type !== 'icon' && thumbs.length > 1
? 'shadow-md shadow-app-shade'
: undefined
}
/>
))}
</>
);
};
const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
const filePathData = getItemFilePath(item);
const objectData = getItemObject(item);
const isDir = item.type === 'Path' && item.item.is_dir;
const readyToFetch = useIsFetchReady(item);
const tags = useLibraryQuery(['tags.getForObject', objectData?.id || -1], {
enabled: readyToFetch && !!objectData
});
const object = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], {
enabled: readyToFetch && !!objectData
});
const fileFullPath = useLibraryQuery(['files.getPath', filePathData?.id || -1], {
enabled: readyToFetch && !!filePathData
});
const pubId = useMemo(
() => (object?.data?.pub_id ? stringify(object.data.pub_id) : null),
[object?.data?.pub_id]
);
const formatDate = (date: string | null | undefined) => date && dayjs(date).format(DATE_FORMAT);
return (
<>
<h3 className="truncate px-3 pb-1 pt-2 text-base font-bold">
{filePathData?.name}
{filePathData?.extension && `.${filePathData.extension}`}
</h3>
{objectData && (
<div className="mx-3 mb-0.5 mt-1 flex flex-row space-x-0.5">
<Tooltip label="Favorite">
<FavoriteButton data={objectData} />
</Tooltip>
<Tooltip label="Encrypt">
<Button size="icon">
<Lock className="h-[18px] w-[18px]" />
</Button>
</Tooltip>
<Tooltip label="Share">
<Button size="icon">
<Link className="h-[18px] w-[18px]" />
</Button>
</Tooltip>
</div>
)}
<Divider />
<MetaContainer>
<MetaData
icon={Cube}
label="Size"
value={`${byteSize(filePathData?.size_in_bytes_bytes)}`}
/>
<MetaData icon={Clock} label="Created" value={formatDate(item.item.date_created)} />
<MetaData
icon={Eraser}
label="Modified"
value={formatDate(filePathData?.date_modified)}
/>
<MetaData
icon={Barcode}
label="Indexed"
value={formatDate(filePathData?.date_indexed)}
/>
<MetaData
icon={FolderOpen}
label="Accessed"
value={formatDate(objectData?.date_accessed)}
/>
<MetaData
icon={Path}
label="Path"
value={fileFullPath.data}
onClick={() => {
// TODO: Add toast notification
fileFullPath.data && navigator.clipboard.writeText(fileFullPath.data);
}}
/>
</MetaContainer>
<Divider />
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
<InfoPill>{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]}</InfoPill>
{filePathData?.extension && <InfoPill>{filePathData.extension}</InfoPill>}
{tags.data?.map((tag) => (
<Tooltip key={tag.id} label={tag.name || ''} className="flex overflow-hidden">
<InfoPill
className="truncate !text-white"
style={{ backgroundColor: tag.color + 'CC' }}
>
{tag.name}
</InfoPill>
</Tooltip>
))}
{objectData && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objects={[objectData]} />
</DropdownMenu.Root>
)}
</MetaContainer>
{!isDir && objectData && (
<>
<Note data={objectData} />
<Divider />
<MetaContainer>
<MetaData
icon={Snowflake}
label="Content ID"
value={filePathData?.cas_id}
/>
{filePathData?.integrity_checksum && (
<MetaData
icon={CircleWavyCheck}
label="Checksum"
value={filePathData.integrity_checksum}
/>
)}
<MetaData icon={Hash} label="Object ID" value={pubId} />
</MetaContainer>
</>
)}
</>
);
};
type MetadataDate = Date | { from: Date; to: Date } | null;
const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
const explorerStore = useExplorerStore();
const selectedObjects = useItemsAsObjects(items);
const readyToFetch = useIsFetchReady(items);
const tags = useLibraryQuery(['tags.list'], {
enabled: readyToFetch && !explorerStore.isDragging,
suspense: true
});
const tagsWithObjects = useLibraryQuery(
['tags.getWithObjects', selectedObjects.map(({ id }) => id)],
{ enabled: readyToFetch && !explorerStore.isDragging }
);
const formatDate = (metadataDate: MetadataDate) => {
if (!metadataDate) return;
if (metadataDate instanceof Date) return dayjs(metadataDate).format(DATE_FORMAT);
const { from, to } = metadataDate;
const sameMonth = from.getMonth() === to.getMonth();
const sameYear = from.getFullYear() === to.getFullYear();
const format = ['D', !sameMonth && 'MMM', !sameYear && 'YYYY'].filter(Boolean).join(' ');
return `${dayjs(from).format(format)} - ${dayjs(to).format(DATE_FORMAT)}`;
};
const getDate = useCallback((metadataDate: MetadataDate, date: Date) => {
date.setHours(0, 0, 0, 0);
if (!metadataDate) {
metadataDate = date;
} else if (metadataDate instanceof Date && date.getTime() !== metadataDate.getTime()) {
metadataDate = { from: metadataDate, to: date };
} else if ('from' in metadataDate && date < metadataDate.from) {
metadataDate.from = date;
} else if ('to' in metadataDate && date > metadataDate.to) {
metadataDate.to = date;
}
return metadataDate;
}, []);
const metadata = useMemo(
() =>
items.reduce(
(metadata, item) => {
const filePathData = getItemFilePath(item);
const objectData = getItemObject(item);
if (filePathData?.size_in_bytes_bytes) {
metadata.size += bytesToNumber(filePathData.size_in_bytes_bytes);
}
if (filePathData?.date_created) {
metadata.created = getDate(
metadata.created,
new Date(filePathData.date_created)
);
}
if (filePathData?.date_modified) {
metadata.modified = getDate(
metadata.modified,
new Date(filePathData.date_modified)
);
}
if (filePathData?.date_indexed) {
metadata.indexed = getDate(
metadata.indexed,
new Date(filePathData.date_indexed)
);
}
if (objectData?.date_accessed) {
metadata.accessed = getDate(
metadata.accessed,
new Date(objectData.date_accessed)
);
}
const kind =
item.type === 'Path' && item.item.is_dir
? 'Folder'
: ObjectKind[objectData?.kind || 0];
if (kind) {
const kindItems = metadata.kinds.get(kind);
if (!kindItems) metadata.kinds.set(kind, [item]);
else metadata.kinds.set(kind, [...kindItems, item]);
}
return metadata;
},
{ size: BigInt(0), indexed: null, kinds: new Map() } as {
size: bigint;
created: MetadataDate;
modified: MetadataDate;
indexed: MetadataDate;
accessed: MetadataDate;
kinds: Map<string, ExplorerItem[]>;
}
),
[items, getDate]
);
return (
<>
<MetaContainer>
<MetaData icon={Cube} label="Size" value={`${byteSize(metadata.size)}`} />
<MetaData icon={Clock} label="Created" value={formatDate(metadata.created)} />
<MetaData icon={Eraser} label="Modified" value={formatDate(metadata.modified)} />
<MetaData icon={Barcode} label="Indexed" value={formatDate(metadata.indexed)} />
<MetaData
icon={FolderOpen}
label="Accessed"
value={formatDate(metadata.accessed)}
/>
</MetaContainer>
<Divider />
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
{[...metadata.kinds].map(([kind, items]) => (
<InfoPill key={kind}>{`${kind} (${items.length})`}</InfoPill>
))}
{tags.data?.map((tag) => {
const objectsWithTag = tagsWithObjects.data?.[tag.id] || [];
if (objectsWithTag.length === 0) return null;
return (
<Tooltip key={tag.id} label={tag.name} className="flex overflow-hidden">
<InfoPill
className="truncate !text-white"
style={{
backgroundColor: tag.color + 'CC',
opacity:
objectsWithTag.length === selectedObjects.length ? 1 : 0.5
}}
>
{tag.name} ({objectsWithTag.length})
</InfoPill>
</Tooltip>
);
})}
{isNonEmpty(selectedObjects) && (
<DropdownMenu.Root
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
side="left"
sideOffset={5}
alignOffset={-10}
>
<AssignTagMenuItems objects={selectedObjects} />
</DropdownMenu.Root>
)}
</MetaContainer>
</>
);
};
interface MetaDataProps {
icon: Icon;
label: string;
value: ReactNode;
onClick?: () => void;
}
const MetaData = ({ icon: Icon, label, value, onClick }: MetaDataProps) => {
return (
<div className="flex items-center text-xs text-ink-dull" onClick={onClick}>
<Icon weight="bold" className="mr-2 shrink-0" />
<span className="mr-2 flex-1 whitespace-nowrap">{label}</span>
<Tooltip label={value} asChild>
<span className="truncate break-all text-ink">{value || '--'}</span>
</Tooltip>
</div>
);
};
const useIsFetchReady = (item: ExplorerItem | ExplorerItem[]) => {
const [readyToFetch, setReadyToFetch] = useState(false);
useEffect(() => {
setReadyToFetch(false);
const timeout = setTimeout(() => setReadyToFetch(true), 350);
return () => clearTimeout(timeout);
}, [item]);
return readyToFetch;
};

View file

@ -49,6 +49,22 @@ export default () => {
)}
</div>
)}
{explorerStore.layoutMode === 'grid' && (
<div>
<Subheading>Gap</Subheading>
<Slider
onValueChange={([val]) => {
if (val) getExplorerStore().gridGap = val;
}}
defaultValue={[explorerStore.gridGap]}
max={16}
min={4}
step={4}
/>
</div>
)}
{(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && (
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">

View file

@ -6,8 +6,8 @@ import { showAlertDialog } from '~/components';
import { useOperatingSystem } from '~/hooks';
import { keybindForOs } from '~/util/keybinds';
import { useExplorerContext } from './Context';
import { SharedItems } from './ContextMenu';
import { CopyAsPath } from './ContextMenu/FilePath/Items';
import { CopyAsPathBase } from './CopyAsPath';
import { RevealInNativeExplorerBase } from './RevealInNativeExplorer';
import { getExplorerStore, useExplorerStore } from './store';
import { useExplorerSearchParams } from './util';
@ -27,23 +27,25 @@ export default (props: PropsWithChildren) => {
return (
<CM.Root trigger={props.children}>
{parent?.type === 'Location' && cutCopyState.active && (
{parent?.type === 'Location' && cutCopyState.type !== 'Idle' && (
<>
<CM.Item
label="Paste"
keybind={keybind([ModifierKeys.Control], ['V'])}
onClick={async () => {
const path = currentPath ?? '/';
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
const { type, sourcePathIds, sourceParentPath, sourceLocationId } =
cutCopyState;
const sameLocation =
sourceLocationId === parent.location.id &&
sourceParentPath === path;
try {
if (actionType == 'Copy') {
if (type == 'Copy') {
await copyFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
sources_file_path_ids: [...sourcePathIds],
target_location_id: parent.location.id,
target_location_relative_directory_path: path,
target_file_name_suffix: sameLocation ? ' copy' : null
@ -56,7 +58,7 @@ export default (props: PropsWithChildren) => {
} else {
await cutFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
sources_file_path_ids: [...sourcePathIds],
target_location_id: parent.location.id,
target_location_relative_directory_path: path
});
@ -64,7 +66,7 @@ export default (props: PropsWithChildren) => {
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
value: `Failed to ${type.toLowerCase()} file, due to an error: ${error}`
});
}
}}
@ -75,8 +77,7 @@ export default (props: PropsWithChildren) => {
label="Deselect"
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
type: 'Idle'
};
}}
icon={FileX}
@ -103,10 +104,11 @@ export default (props: PropsWithChildren) => {
{parent?.type === 'Location' && (
<>
<SharedItems.RevealInNativeExplorer locationId={parent.location.id} />
<RevealInNativeExplorerBase
items={[{ Location: { id: parent.location.id } }]}
/>
<CM.SubMenu label="More actions..." icon={Plus}>
<CopyAsPath pathOrId={`${parent.location.path}${currentPath ?? ''}`} />
<CopyAsPathBase path={`${parent.location.path}${currentPath ?? ''}`} />
<CM.Item
onClick={async () => {

View file

@ -1,131 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { animated, useTransition } from '@react-spring/web';
import { X } from 'phosphor-react';
import { useEffect, useRef, useState } from 'react';
import { subscribeKey } from 'valtio/utils';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import FileThumb from '../FilePath/Thumb';
import { getExplorerStore } from '../store';
const AnimatedDialogOverlay = animated(Dialog.Overlay);
const AnimatedDialogContent = animated(Dialog.Content);
export interface QuickPreviewProps extends Dialog.DialogProps {
transformOrigin?: string;
}
export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
const explorerItem = useRef<null | ExplorerItem>(null);
const explorerStore = getExplorerStore();
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* The useEffect hook with subscribe is used here, instead of useExplorerStore, because when
* explorerStore.quickViewObject is set to null the component will not close immediately.
* Instead, it will enter the beginning of the close transition and it must continue to display
* content for a few more seconds due to the ongoing animation. To handle this, the open state
* is decoupled from the store state, by assigning references to the required store properties
* to render the component in the subscribe callback.
*/
useEffect(
() =>
subscribeKey(explorerStore, 'quickViewObject', () => {
const { quickViewObject } = explorerStore;
if (quickViewObject != null) {
setIsOpen(true);
explorerItem.current = quickViewObject;
} else {
setIsOpen(false);
}
}),
[explorerStore]
);
const transitions = useTransition(isOpen, {
from: {
opacity: 0,
transform: `translateY(20px) scale(0.9)`,
transformOrigin: transformOrigin || 'center top'
},
enter: { opacity: 1, transform: `translateY(0px) scale(1)` },
leave: { opacity: 0, transform: `translateY(40px) scale(0.9)` },
config: { mass: 0.2, tension: 300, friction: 20, bounce: 0 }
});
return (
<>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) explorerStore.quickViewObject = null;
}}
>
{transitions((styles, show) => {
if (!show || explorerItem.current == null) return null;
const { item } = explorerItem.current;
return (
<>
<Dialog.Portal forceMount>
<AnimatedDialogOverlay
style={{
opacity: styles.opacity
}}
className="z-49 absolute inset-0 m-[1px] grid place-items-center overflow-y-auto rounded-xl bg-app/50"
/>
<AnimatedDialogContent
style={styles}
className="!pointer-events-none absolute inset-0 z-50 grid h-screen place-items-center"
>
<div className="!pointer-events-auto flex h-5/6 max-h-screen w-11/12 flex-col rounded-md border border-app-line bg-app-box text-ink shadow-app-shade">
<nav className="relative flex w-full flex-row">
<Dialog.Close
asChild
className="absolute m-2"
aria-label="Close"
>
<Button
size="icon"
variant="outline"
className="flex flex-row"
>
<X
weight="bold"
className=" h-3 w-3 text-ink-faint"
/>
<span className="ml-1 text-tiny font-medium text-ink-faint">
ESC
</span>
</Button>
</Dialog.Close>
<Dialog.Title className="mx-auto my-2 font-bold">
Preview -{' '}
<span className="inline-block max-w-xs truncate align-sub text-sm text-ink-dull">
{'name' in item && item.name
? item.name
: 'Unkown Object'}
</span>
</Dialog.Title>
</nav>
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
<FileThumb
size={0}
data={explorerItem.current}
className="w-full"
loadOriginal
mediaControls
/>
</div>
</div>
</AnimatedDialogContent>
</Dialog.Portal>
</>
);
})}
</Dialog.Root>
</>
);
}

View file

@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import { subscribeKey } from 'valtio/utils';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import FileThumb from '../FilePath/Thumb';
import { FileThumb } from '../FilePath/Thumb';
import { getExplorerStore } from '../store';
const AnimatedDialogOverlay = animated(Dialog.Overlay);
@ -106,15 +106,13 @@ export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
<span className="inline-block max-w-xs truncate align-sub text-sm text-ink-dull">
{'name' in item && item.name
? item.name
: 'Unkown Object'}
: 'Unknown Object'}
</span>
</Dialog.Title>
</nav>
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
<FileThumb
size={0}
data={explorerItem.current}
className="w-full"
loadOriginal
mediaControls
/>

View file

@ -0,0 +1,33 @@
import { useLibraryContext } from '@sd/client';
import { ContextMenu, ModifierKeys } from '@sd/ui';
import { useOperatingSystem } from '~/hooks';
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
import { NonEmptyArray } from '~/util';
import { Platform, usePlatform } from '~/util/Platform';
const lookup: Record<string, string> = {
macOS: 'Finder',
windows: 'Explorer'
};
export type RevealItems = NonEmptyArray<
Parameters<NonNullable<Platform['revealItems']>>[1][number]
>;
export const RevealInNativeExplorerBase = (props: { items: RevealItems }) => {
const os = useOperatingSystem();
const keybind = useKeybindFactory();
const { library } = useLibraryContext();
const { revealItems } = usePlatform();
if (!revealItems) return null;
const osFileBrowserName = lookup[os] ?? 'file manager';
return (
<ContextMenu.Item
label={`Reveal in ${osFileBrowserName}`}
keybind={keybind([ModifierKeys.Control], ['Y'])}
onClick={() => revealItems(library.uuid, props.items)}
/>
);
};

View file

@ -0,0 +1,601 @@
import { ReactNode, createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import Selecto from 'react-selecto';
import { useKey } from 'rooks';
import { ExplorerItem } from '@sd/client';
import { GridList, useGridList } from '~/components';
import { useOperatingSystem } from '~/hooks';
import { useExplorerContext } from '../Context';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerStore, isCut, useExplorerStore } from '../store';
import { ExplorerItemHash } from '../useExplorer';
import { explorerItemHash } from '../util';
const SelectoContext = createContext<{
selecto: React.RefObject<Selecto>;
selectoUnSelected: React.MutableRefObject<Set<ExplorerItemHash>>;
} | null>(null);
type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode;
const GridListItem = (props: {
index: number;
item: ExplorerItem;
children: RenderItem;
onMouseDown: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}) => {
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const selecto = useContext(SelectoContext);
const cut = isCut(props.item.item.id);
const selected = useMemo(
// Even though this checks object equality, it should still be safe since `selectedItems`
// will be re-calculated before this memo runs.
() => explorer.selectedItems.has(props.item),
[explorer.selectedItems, props.item]
);
const hash = explorerItemHash(props.item);
useEffect(() => {
if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(hash)) return;
if (!selected) {
selecto.selectoUnSelected.current.delete(hash);
return;
}
const element = document.querySelector(`[data-selectable-id="${hash}"]`);
if (!element) return;
selecto.selectoUnSelected.current.delete(hash);
selecto.selecto.current.setSelectedTargets([
...selecto.selecto.current.getSelectedTargets(),
element as HTMLElement
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!selecto) return;
return () => {
const element = document.querySelector(`[data-selectable-id="${hash}"]`);
if (selected && !element) selecto.selectoUnSelected.current.add(hash);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]);
return (
<div
className="h-full w-full"
data-selectable=""
data-selectable-index={props.index}
data-selectable-id={hash}
onMouseDown={props.onMouseDown}
onContextMenu={(e) => {
if (explorerView.selectable && !explorer.selectedItems.has(props.item)) {
explorer.resetSelectedItems([props.item]);
selecto?.selecto.current?.setSelectedTargets([e.currentTarget]);
}
}}
>
{props.children({ item: props.item, selected, cut })}
</div>
);
};
const CHROME_REGEX = /Chrome/;
export default ({ children }: { children: RenderItem }) => {
const os = useOperatingSystem();
const isChrome = CHROME_REGEX.test(navigator.userAgent);
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const selecto = useRef<Selecto>(null);
const selectoUnSelected = useRef<Set<ExplorerItemHash>>(new Set());
const selectoFirstColumn = useRef<number | undefined>();
const selectoLastColumn = useRef<number | undefined>();
const [dragFromThumbnail, setDragFromThumbnail] = useState(false);
const itemDetailsHeight =
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
const grid = useGridList({
ref: explorerView.ref,
count: explorer.items?.length ?? 0,
overscan: explorer.overscan,
onLoadMore: explorer.loadMore,
rowsBeforeLoadMore: explorer.rowsBeforeLoadMore,
size:
explorerStore.layoutMode === 'grid'
? { width: explorerStore.gridItemSize, height: itemHeight }
: undefined,
columns: explorerStore.layoutMode === 'media' ? explorerStore.mediaColumns : undefined,
getItemId: (index) => {
const item = explorer.items?.[index];
return item ? explorerItemHash(item) : undefined;
},
getItemData: (index) => explorer.items?.[index],
padding: explorerView.padding || explorerStore.layoutMode === 'grid' ? 12 : undefined,
gap:
explorerView.gap ||
(explorerStore.layoutMode === 'grid' ? explorerStore.gridGap : undefined),
top: explorerView.top
});
function getElementId(element: Element) {
return element.getAttribute('data-selectable-id') as ExplorerItemHash | null;
}
function getElementIndex(element: Element) {
const index = element.getAttribute('data-selectable-index');
return index ? Number(index) : null;
}
function getElementItem(element: Element) {
const index = getElementIndex(element);
if (index === null) return null;
return grid.getItem(index) ?? null;
}
useEffect(
() => {
const element = explorer.scrollRef.current;
if (!element) return;
const handleScroll = () => {
selecto.current?.checkScroll();
selecto.current?.findSelectableTargets();
};
element.addEventListener('scroll', handleScroll);
return () => element.removeEventListener('scroll', handleScroll);
},
// explorer.scrollRef is a stable reference so this only actually runs once
[explorer.scrollRef]
);
useEffect(() => {
if (!selecto.current) return;
const set = new Set(explorer.selectedItemHashes.value);
if (set.size === 0) return;
const items = [...document.querySelectorAll('[data-selectable]')].filter((item) => {
const id = getElementId(item);
if (id === null) return;
const selected = set.has(id);
if (selected) set.delete(id);
return selected;
});
selectoUnSelected.current = set;
selecto.current.setSelectedTargets(items as HTMLElement[]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [grid.columnCount, explorer.items]);
// The item that further selection will move from (shift + arrow for example).
// This used to be calculated from the last item of selectedItems,
// but Set ordering isn't reliable.
// Ref bc we never actually render this.
const activeItem = useRef<ExplorerItem | null>(null);
useEffect(() => {
if (explorer.selectedItems.size !== 0) return;
selectoUnSelected.current = new Set();
// Accessing refs during render is bad
activeItem.current = null;
}, [explorer.selectedItems]);
useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => {
if (explorer.selectedItems.size > 0) e.preventDefault();
if (!explorerView.selectable) return;
const lastItem = activeItem.current;
if (!lastItem) return;
const lastItemIndex = explorer.items?.findIndex((item) => item === lastItem);
if (lastItemIndex === undefined || lastItemIndex === -1) return;
const gridItem = grid.getItem(lastItemIndex);
if (!gridItem) return;
const currentIndex = gridItem.index;
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowUp':
newIndex -= grid.columnCount;
break;
case 'ArrowDown':
newIndex += grid.columnCount;
break;
case 'ArrowRight':
if (grid.columnCount === (currentIndex % grid.columnCount) + 1) return;
newIndex += 1;
break;
case 'ArrowLeft':
if (currentIndex % grid.columnCount === 0) return;
newIndex -= 1;
break;
}
const newSelectedItem = grid.getItem(newIndex);
if (!newSelectedItem?.data) return;
if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]);
else {
const selectedItemDom = document.querySelector(
`[data-selectable-id="${explorerItemHash(newSelectedItem.data)}"]`
);
if (!selectedItemDom) return;
if (e.shiftKey) {
if (!explorer.selectedItems.has(newSelectedItem.data)) {
explorer.addSelectedItem(newSelectedItem.data);
selecto.current?.setSelectedTargets([
...(selecto.current?.getSelectedTargets() || []),
selectedItemDom as HTMLElement
]);
}
} else {
explorer.resetSelectedItems([newSelectedItem.data]);
selecto.current?.setSelectedTargets([selectedItemDom as HTMLElement]);
if (selectoUnSelected.current.size > 0) selectoUnSelected.current = new Set();
}
}
activeItem.current = newSelectedItem.data;
if (
explorer.scrollRef.current &&
explorerView.ref.current &&
(e.key === 'ArrowUp' || e.key === 'ArrowDown')
) {
const paddingTop = parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop);
const viewRect = explorerView.ref.current.getBoundingClientRect();
const itemRect = newSelectedItem.rect;
const itemTop = itemRect.top + viewRect.top;
const itemBottom = itemRect.bottom + viewRect.top;
const scrollRect = explorer.scrollRef.current.getBoundingClientRect();
const scrollTop = paddingTop + (explorerView.top || 0) + 1;
const scrollBottom = scrollRect.height - (os !== 'windows' && os !== 'browser' ? 2 : 1);
if (itemTop < scrollTop) {
explorer.scrollRef.current.scrollBy({
top:
itemTop -
scrollTop -
(newSelectedItem.row === 0 ? grid.padding.y : 0) -
(newSelectedItem.row !== 0 ? grid.gap.y / 2 : 0),
behavior: 'smooth'
});
} else if (itemBottom > scrollBottom) {
explorer.scrollRef.current.scrollBy({
top:
itemBottom -
scrollBottom +
(newSelectedItem.row === grid.rowCount - 1 ? grid.padding.y : 0) +
(newSelectedItem.row !== grid.rowCount - 1 ? grid.gap.y / 2 : 0),
behavior: 'smooth'
});
}
}
});
return (
<SelectoContext.Provider value={selecto.current ? { selecto, selectoUnSelected } : null}>
{explorer.allowMultiSelect && (
<Selecto
ref={selecto}
boundContainer={
explorerView.ref.current
? {
element: explorerView.ref.current,
top: false,
bottom: false
}
: undefined
}
selectableTargets={['[data-selectable]']}
toggleContinueSelect="shift"
hitRate={0}
// selectFromInside={explorerStore.layoutMode === 'media'}
onDragStart={(e) => {
getExplorerStore().isDragging = true;
if ((e.inputEvent as MouseEvent).target instanceof HTMLImageElement) {
setDragFromThumbnail(true);
}
}}
onDragEnd={() => {
getExplorerStore().isDragging = false;
selectoFirstColumn.current = undefined;
selectoLastColumn.current = undefined;
setDragFromThumbnail(false);
const allSelected = selecto.current?.getSelectedTargets() ?? [];
// Sets active item to selected item with least index.
// Might seem kinda weird but it's the same behaviour as Finder.
activeItem.current =
allSelected.reduce((least, current) => {
const currentItem = getElementItem(current);
if (!currentItem) return least;
if (!least) return currentItem;
return currentItem.index < least.index ? currentItem : least;
}, null as ReturnType<typeof getElementItem>)?.data ?? null;
}}
onScroll={({ direction }) => {
selecto.current?.findSelectableTargets();
explorer.scrollRef.current?.scrollBy(
(direction[0] || 0) * 10,
(direction[1] || 0) * 10
);
}}
scrollOptions={{
container: { current: explorer.scrollRef.current },
throttleTime: isChrome || dragFromThumbnail ? 30 : 10000
}}
onSelect={(e) => {
const inputEvent = e.inputEvent as MouseEvent;
if (inputEvent.type === 'mousedown') {
const el = inputEvent.shiftKey
? e.added[0] || e.removed[0]
: e.selected[0];
if (!el) return;
const item = getElementItem(el);
if (!item?.data) return;
if (!inputEvent.shiftKey) {
if (explorer.selectedItems.has(item.data)) {
selecto.current?.setSelectedTargets(e.beforeSelected);
} else {
selectoUnSelected.current = new Set();
explorer.resetSelectedItems([item.data]);
}
return;
}
if (e.added[0]) explorer.addSelectedItem(item.data);
else explorer.removeSelectedItem(item.data);
} else if (inputEvent.type === 'mousemove') {
const unselectedItems: ExplorerItemHash[] = [];
e.added.forEach((el) => {
const item = getElementItem(el);
if (!item?.data) return;
explorer.addSelectedItem(item.data);
});
e.removed.forEach((el) => {
const item = getElementItem(el);
if (!item?.data || typeof item.id === 'number') return;
if (document.contains(el)) explorer.removeSelectedItem(item.data);
else unselectedItems.push(item.id);
});
const dragDirection = {
x: inputEvent.x === e.rect.left ? 'left' : 'right',
y: inputEvent.y === e.rect.bottom ? 'down' : 'up'
} as const;
const dragStart = {
x: dragDirection.x === 'right' ? e.rect.left : e.rect.right,
y: dragDirection.y === 'down' ? e.rect.top : e.rect.bottom
};
const dragEnd = { x: inputEvent.x, y: inputEvent.y };
const columns = new Set<number>();
const elements = [...e.added, ...e.removed];
const items = elements.reduce((items, el) => {
const item = getElementItem(el);
if (!item) return items;
columns.add(item.column);
return [...items, item];
}, [] as NonNullable<ReturnType<typeof getElementItem>>[]);
if (columns.size > 1) {
items.sort((a, b) => a.column - b.column);
const firstItem =
dragDirection.x === 'right'
? items[0]
: items[items.length - 1];
const lastItem =
dragDirection.x === 'right'
? items[items.length - 1]
: items[0];
if (firstItem && lastItem) {
selectoFirstColumn.current = firstItem.column;
selectoLastColumn.current = lastItem.column;
}
} else if (columns.size === 1) {
const column = [...columns.values()][0]!;
items.sort((a, b) => a.row - b.row);
const itemRect = elements[0]?.getBoundingClientRect();
const inDragArea =
itemRect &&
(dragDirection.x === 'right'
? dragEnd.x >= itemRect.left
: dragEnd.x <= itemRect.right);
if (
column !== selectoLastColumn.current ||
(column === selectoLastColumn.current && !inDragArea)
) {
const firstItem =
dragDirection.y === 'down'
? items[0]
: items[items.length - 1];
if (firstItem) {
const viewRectTop =
explorerView.ref.current?.getBoundingClientRect().top ??
0;
const itemTop = firstItem.rect.top + viewRectTop;
const itemBottom = firstItem.rect.bottom + viewRectTop;
if (
dragDirection.y === 'down'
? dragStart.y < itemTop
: dragStart.y > itemBottom
) {
const dragHeight = Math.abs(
dragStart.y -
(dragDirection.y === 'down'
? itemTop
: itemBottom)
);
let itemsInDragCount =
(dragHeight - grid.gap.y) /
(grid.virtualItemHeight + grid.gap.y);
if (itemsInDragCount > 1) {
itemsInDragCount = Math.ceil(itemsInDragCount);
} else {
itemsInDragCount = Math.round(itemsInDragCount);
}
[...Array(itemsInDragCount)].forEach((_, i) => {
const index =
dragDirection.y === 'down'
? itemsInDragCount - i
: i + 1;
const itemIndex =
firstItem.index +
(dragDirection.y === 'down' ? -index : index) *
grid.columnCount;
const item = explorer.items?.[itemIndex];
if (item) {
if (inputEvent.shiftKey) {
if (explorer.selectedItems.has(item))
explorer.removeSelectedItem(item);
else {
explorer.addSelectedItem(item);
if (inDragArea)
unselectedItems.push(
explorerItemHash(item)
);
}
} else if (!inDragArea)
explorer.removeSelectedItem(item);
else {
explorer.addSelectedItem(item);
if (inDragArea)
unselectedItems.push(
explorerItemHash(item)
);
}
}
});
}
}
if (!inDragArea && column === selectoFirstColumn.current) {
selectoFirstColumn.current = undefined;
selectoLastColumn.current = undefined;
} else {
selectoLastColumn.current = column;
if (selectoFirstColumn.current === undefined) {
selectoFirstColumn.current = column;
}
}
}
}
if (unselectedItems.length > 0) {
selectoUnSelected.current = new Set([
...selectoUnSelected.current,
...unselectedItems
]);
}
}
}}
/>
)}
<GridList grid={grid} scrollRef={explorer.scrollRef}>
{(index) => {
const item = explorer.items?.[index];
if (!item) return null;
return (
<GridListItem
index={index}
item={item}
onMouseDown={(e) => {
e.stopPropagation();
if (!explorerView.selectable) return;
const item = grid.getItem(index);
if (!item?.data) return;
if (!explorer.allowMultiSelect) {
explorer.resetSelectedItems([item.data]);
} else {
selectoFirstColumn.current = item.column;
selectoLastColumn.current = item.column;
}
activeItem.current = item.data;
}}
>
{children}
</GridListItem>
);
}}
</GridList>
</SelectoContext.Provider>
);
};

View file

@ -1,47 +1,47 @@
import clsx from 'clsx';
import { memo } from 'react';
import { ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client';
import { GridList } from '~/components';
import { ViewItem } from '.';
import FileThumb from '../FilePath/Thumb';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import { isCut, useExplorerStore } from '../store';
import { useExplorerStore } from '../store';
import GridList from './GridList';
import RenamableItemText from './RenamableItemText';
interface GridViewItemProps {
data: ExplorerItem;
selected: boolean;
index: number;
isRenaming: boolean;
cut: boolean;
renamable: boolean;
}
const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewItemProps) => {
const GridViewItem = memo(({ data, selected, cut, isRenaming, renamable }: GridViewItemProps) => {
const filePathData = getItemFilePath(data);
const location = getItemLocation(data);
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const { showBytesInGridView, gridItemSize } = useExplorerStore();
const showSize =
!filePathData?.is_dir &&
!location &&
explorerStore.showBytesInGridView &&
(!explorerView.isRenaming || (explorerView.isRenaming && !selected));
showBytesInGridView &&
(!isRenaming || (isRenaming && !selected));
return (
<ViewItem data={data} className="h-full w-full" {...props}>
<div className={clsx('mb-1 rounded-lg ', selected && 'bg-app-selectedItem')}>
<FileThumb
data={data}
size={explorerStore.gridItemSize}
className={clsx('mx-auto', cut && 'opacity-60')}
/>
<ViewItem data={data} className="h-full w-full">
<div
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
>
<FileThumb data={data} frame className={clsx('px-2 py-1', cut && 'opacity-60')} />
</div>
<div className="flex flex-col justify-center">
<RenamableItemText
item={data}
selected={selected}
style={{ maxHeight: explorerStore.gridItemSize / 3 }}
style={{ maxHeight: gridItemSize / 3 }}
disabled={!renamable}
/>
{showSize && filePathData?.size_in_bytes_bytes && (
<span
@ -58,45 +58,20 @@ const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewIte
});
export default () => {
const explorerStore = useExplorerStore();
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const itemDetailsHeight =
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
return (
<GridList
scrollRef={explorerView.scrollRef}
count={explorerView.items?.length || 100}
size={{ width: explorerStore.gridItemSize, height: itemHeight }}
padding={12}
selectable={!!explorerView.items}
selected={explorerView.selected}
onSelectedChange={explorerView.onSelectedChange}
overscan={explorerView.overscan}
onLoadMore={explorerView.onLoadMore}
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
top={explorerView.top}
preventSelection={explorerView.isRenaming || !explorerView.selectable}
preventContextMenuSelection={explorerView.contextMenu === undefined}
>
{({ index, item: Item }) => {
const item = explorerView.items?.[index];
if (!item) return null;
const isSelected = Array.isArray(explorerView.selected)
? explorerView.selected.includes(item.item.id)
: explorerView.selected === item.item.id;
const cut = isCut(item.item.id);
return (
<Item selected={isSelected} id={item.item.id}>
<GridViewItem data={item} selected={isSelected} index={index} cut={cut} />
</Item>
);
}}
<GridList>
{({ item, selected, cut }) => (
<GridViewItem
data={item}
selected={selected}
cut={cut}
isRenaming={explorerView.isRenaming}
renamable={explorer.selectedItems.size === 1}
/>
)}
</GridList>
);
};

File diff suppressed because it is too large Load diff

View file

@ -3,19 +3,18 @@ import { ArrowsOutSimple } from 'phosphor-react';
import { memo } from 'react';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import { GridList } from '~/components';
import { ViewItem } from '.';
import FileThumb from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import { FileThumb } from '../FilePath/Thumb';
import { getExplorerStore, useExplorerStore } from '../store';
import GridList from './GridList';
interface MediaViewItemProps {
data: ExplorerItem;
index: number;
selected: boolean;
cut: boolean;
}
const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
const explorerStore = useExplorerStore();
return (
@ -33,10 +32,12 @@ const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
)}
>
<FileThumb
size={0}
data={data}
cover={explorerStore.mediaAspectSquare}
className="!rounded-none"
className={clsx(
!explorerStore.mediaAspectSquare && 'px-1',
cut && 'opacity-60'
)}
/>
<Button
@ -53,45 +54,11 @@ const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
});
export default () => {
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
return (
<GridList
scrollRef={explorerView.scrollRef}
count={explorerView.items?.length || 100}
columns={explorerStore.mediaColumns}
selected={explorerView.selected}
onSelectedChange={explorerView.onSelectedChange}
overscan={explorerView.overscan}
onLoadMore={explorerView.onLoadMore}
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
top={explorerView.top}
preventSelection={!explorerView.selectable}
preventContextMenuSelection={explorerView.contextMenu === undefined}
>
{({ index, item: Item }) => {
if (!explorerView.items) {
return (
<Item className="!p-px">
<div className="h-full animate-pulse bg-app-box" />
</Item>
);
}
const item = explorerView.items[index];
if (!item) return null;
const isSelected = Array.isArray(explorerView.selected)
? explorerView.selected.includes(item.item.id)
: explorerView.selected === item.item.id;
return (
<Item selectable selected={isSelected} index={index} id={item.item.id}>
<MediaViewItem data={item} index={index} selected={isSelected} />
</Item>
);
}}
<GridList>
{({ item, selected, cut }) => (
<MediaViewItem data={item} selected={selected} cut={cut} />
)}
</GridList>
);
};

View file

@ -8,16 +8,19 @@ import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { createPortal } from 'react-dom';
import { createSearchParams, useNavigate } from 'react-router-dom';
import {
ExplorerItem,
getExplorerItemData,
FilePath,
Location,
Object,
getItemFilePath,
getItemLocation,
getItemObject,
isPath,
useLibraryContext,
useLibraryMutation
@ -25,17 +28,13 @@ import {
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { useOperatingSystem } from '~/hooks';
import { isNonEmpty } from '~/util';
import { usePlatform } from '~/util/Platform';
import CreateDialog from '../../settings/library/tags/CreateDialog';
import { useExplorerContext } from '../Context';
import { QuickPreview } from '../QuickPreview';
import { useQuickPreviewContext } from '../QuickPreview/Context';
import {
ExplorerViewContext,
ExplorerViewSelection,
ExplorerViewSelectionChange,
ViewContext,
useExplorerViewContext
} from '../ViewContext';
import { ExplorerViewContext, ViewContext, useExplorerViewContext } from '../ViewContext';
import { useExplorerConfigStore } from '../config';
import { getExplorerStore, useExplorerStore } from '../store';
import GridView from './GridView';
@ -47,49 +46,98 @@ interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement
}
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const { library } = useLibraryContext();
const navigate = useNavigate();
const { openFilePaths } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(data);
const location = getItemLocation(data);
const explorerConfig = useExplorerConfigStore();
const onDoubleClick = () => {
if (location) {
const navigate = useNavigate();
const { library } = useLibraryContext();
const { openFilePaths } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
function updateList<T = FilePath | Location>(list: T[], item: T, push: boolean) {
return !push ? [item, ...list] : [...list, item];
}
const onDoubleClick = async () => {
const selectedItems = [...explorer.selectedItems].reduce(
(items, item) => {
const sameAsClicked = data.item.id === item.item.id;
switch (item.type) {
case 'Path':
case 'Object': {
const filePath = getItemFilePath(item);
if (filePath) {
if (isPath(item) && item.item.is_dir) {
items.dirs = updateList(items.dirs, filePath, !sameAsClicked);
} else items.paths = updateList(items.paths, filePath, !sameAsClicked);
}
break;
}
case 'Location': {
items.locations = updateList(items.locations, item.item, !sameAsClicked);
}
}
return items;
},
{
paths: [],
dirs: [],
locations: []
} as { paths: FilePath[]; dirs: FilePath[]; locations: Location[] }
);
if (selectedItems.paths.length > 0 && !explorerView.isRenaming) {
if (explorerConfig.openOnDoubleClick && openFilePaths) {
updateAccessTime
.mutateAsync(
selectedItems.paths.map(({ object_id }) => object_id!).filter(Boolean)
)
.catch(console.error);
try {
await openFilePaths(
library.uuid,
selectedItems.paths.map(({ id }) => id)
);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to open file, due to an error: ${error}`
});
}
} else if (!explorerConfig.openOnDoubleClick) {
if (data.type !== 'Location' && !(isPath(data) && data.item.is_dir)) {
getExplorerStore().quickViewObject = data;
return;
}
}
}
if (selectedItems.dirs.length > 0) {
const item = selectedItems.dirs[0];
if (!item) return;
navigate({
pathname: `/${library.uuid}/location/${location.id}`,
pathname: `../location/${item.location_id}`,
search: createSearchParams({
path: `${item.materialized_path}${item.name}/`
}).toString()
});
} else if (selectedItems.locations.length > 0) {
const location = selectedItems.locations[0];
if (!location) return;
navigate({
pathname: `../location/${location.id}`,
search: createSearchParams({
path: `/`
}).toString()
});
} else if (isPath(data) && data.item.is_dir) {
navigate({
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
search: createSearchParams({
path: `${data.item.materialized_path}${data.item.name}/`
}).toString()
});
} else if (
openFilePaths &&
filePath &&
explorerConfig.openOnDoubleClick &&
!explorerView.isRenaming
) {
if (data.type === 'Path' && data.item.object_id) {
updateAccessTime.mutate(data.item.object_id);
}
openFilePaths(library.uuid, [filePath.id]);
} else {
const { kind } = getExplorerItemData(data);
if (['Video', 'Image', 'Audio'].includes(kind)) {
getExplorerStore().quickViewObject = data;
}
}
};
@ -103,88 +151,79 @@ export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
onOpenChange={explorerView.setIsContextMenuOpen}
disabled={explorerView.contextMenu === undefined}
asChild={false}
onMouseDown={(e) => e.stopPropagation()}
>
{explorerView.contextMenu}
</ContextMenu.Root>
);
};
export interface ExplorerViewProps<T extends ExplorerViewSelection = ExplorerViewSelection>
export interface ExplorerViewProps
extends Omit<
ExplorerViewContext<T>,
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
ExplorerViewContext,
'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref'
> {
className?: string;
style?: React.CSSProperties;
emptyNotice?: JSX.Element;
}
export default memo(
<T extends ExplorerViewSelection>({
className,
emptyNotice,
...contextProps
}: ExplorerViewProps<T>) => {
const { layoutMode } = useExplorerStore();
export default memo(({ className, style, emptyNotice, ...contextProps }: ExplorerViewProps) => {
const explorer = useExplorerContext();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const quickPreviewCtx = useQuickPreviewContext();
useKeyDownHandlers({
items: contextProps.items,
selected: contextProps.selected,
isRenaming
});
const { layoutMode } = useExplorerStore();
const quickPreviewCtx = useQuickPreviewContext();
const ref = useRef<HTMLDivElement>(null);
return (
<>
<div
className={clsx('h-full w-full', className)}
onMouseDown={() =>
contextProps.onSelectedChange?.(
(Array.isArray(contextProps.selected)
? []
: undefined) as ExplorerViewSelectionChange<T>
)
}
>
{contextProps.items === null ||
(contextProps.items && contextProps.items.length > 0) ? (
<ViewContext.Provider
value={
{
...contextProps,
multiSelect: Array.isArray(contextProps.selected),
selectable: !isContextMenuOpen,
setIsContextMenuOpen,
isRenaming,
setIsRenaming
} as ExplorerViewContext
}
>
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'rows' && <ListView />}
{layoutMode === 'media' && <MediaView />}
</ViewContext.Provider>
) : (
emptyNotice
)}
</div>
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
{quickPreviewCtx.ref && createPortal(<QuickPreview />, quickPreviewCtx.ref)}
</>
);
}
) as <T extends ExplorerViewSelection>(props: ExplorerViewProps<T>) => JSX.Element;
useKeyDownHandlers({
isRenaming
});
export const EmptyNotice = ({
icon,
message
}: {
icon?: Icon | ReactNode;
message?: ReactNode;
}) => {
return (
<>
<div
ref={ref}
style={style}
className={clsx('h-full w-full', className)}
onMouseDown={(e) => {
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
explorer.resetSelectedItems();
}}
>
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
<ViewContext.Provider
value={
{
...contextProps,
selectable:
explorer.selectable && !isContextMenuOpen && !isRenaming,
setIsContextMenuOpen,
isRenaming,
setIsRenaming,
ref
} as ExplorerViewContext
}
>
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'rows' && <ListView />}
{layoutMode === 'media' && <MediaView />}
</ViewContext.Provider>
) : (
emptyNotice
)}
</div>
{quickPreviewCtx.ref && createPortal(<QuickPreview />, quickPreviewCtx.ref)}
</>
);
});
export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNode }) => {
const { layoutMode } = useExplorerStore();
const emptyNoticeIcon = (icon?: Icon) => {
@ -202,64 +241,71 @@ export const EmptyNotice = ({
return (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{icon
? isValidElement(icon)
? icon
: emptyNoticeIcon(icon as Icon)
{props.icon
? isValidElement(props.icon)
? props.icon
: emptyNoticeIcon(props.icon as Icon)
: emptyNoticeIcon()}
<p className="mt-5 text-sm font-medium">
{message !== undefined ? message : 'This list is empty'}
{props.message !== undefined ? props.message : 'This list is empty'}
</p>
</div>
);
};
const useKeyDownHandlers = ({
items,
selected,
isRenaming
}: Pick<ExplorerViewProps, 'items' | 'selected'> & { isRenaming: boolean }) => {
const useKeyDownHandlers = ({ isRenaming }: { isRenaming: boolean }) => {
const explorer = useExplorerContext();
const os = useOperatingSystem();
const { library } = useLibraryContext();
const { openFilePaths } = usePlatform();
const selectedItem = useMemo(
() =>
items?.find(
(item) => item.item.id === (Array.isArray(selected) ? selected[0] : selected)
),
[items, selected]
);
const itemPath = selectedItem ? getItemFilePath(selectedItem) : null;
const handleNewTag = useCallback(
async (event: KeyboardEvent) => {
const objects: Object[] = [];
for (const item of explorer.selectedItems) {
const object = getItemObject(item);
if (!object) return;
objects.push(object);
}
if (
itemPath == null ||
!isNonEmpty(objects) ||
event.key.toUpperCase() !== 'N' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
)
return;
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
dialogManager.create((dp) => <CreateDialog {...dp} objects={objects} />);
},
[os, itemPath]
[os, explorer.selectedItems]
);
const handleOpenShortcut = useCallback(
async (event: KeyboardEvent) => {
if (
itemPath == null ||
openFilePaths == null ||
event.key.toUpperCase() !== 'O' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
event.code.toUpperCase() !== 'O' ||
!event.getModifierState(
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
) ||
!openFilePaths
)
return;
const paths: number[] = [];
for (const item of explorer.selectedItems) {
const path = getItemFilePath(item);
if (!path) return;
paths.push(path.id);
}
if (!isNonEmpty(paths)) return;
try {
await openFilePaths(library.uuid, [itemPath.id]);
await openFilePaths(library.uuid, paths);
} catch (error) {
showAlertDialog({
title: 'Error',
@ -267,21 +313,23 @@ const useKeyDownHandlers = ({
});
}
},
[os, itemPath, library.uuid, openFilePaths]
[os, library.uuid, openFilePaths, explorer.selectedItems]
);
const handleOpenQuickPreview = useCallback(
async (event: KeyboardEvent) => {
if (event.key !== ' ') return;
if (!getExplorerStore().quickViewObject) {
if (selectedItem) {
getExplorerStore().quickViewObject = selectedItem;
}
// ENG-973 - Don't use Set -> Array -> First Item
const items = [...explorer.selectedItems];
if (!isNonEmpty(items)) return;
getExplorerStore().quickViewObject = items[0];
} else {
getExplorerStore().quickViewObject = null;
}
},
[selectedItem]
[explorer.selectedItems]
);
const handleExplorerShortcut = useCallback(

View file

@ -1,30 +1,19 @@
import { ReactNode, RefObject, createContext, useContext } from 'react';
import { ExplorerItem } from '@sd/client';
export type ExplorerViewSelection = number | number[];
export type ExplorerViewSelection = number | Set<number>;
export interface ExplorerViewContext<T extends ExplorerViewSelection = ExplorerViewSelection> {
items: ExplorerItem[] | null;
scrollRef: RefObject<HTMLDivElement>;
selected?: T;
onSelectedChange?: (selected: ExplorerViewSelectionChange<T>) => void;
overscan?: number;
onLoadMore?: () => void;
rowsBeforeLoadMore?: number;
export interface ExplorerViewContext {
ref: RefObject<HTMLDivElement>;
top?: number;
multiSelect?: boolean;
contextMenu?: ReactNode;
setIsContextMenuOpen?: (isOpen: boolean) => void;
isRenaming: boolean;
setIsRenaming: (isRenaming: boolean) => void;
selectable?: boolean;
padding?: number | { x?: number; y?: number };
gap?: number | { x?: number; y?: number };
selectable: boolean;
}
export type ExplorerViewSelectionChange<T extends ExplorerViewSelection> = T extends number[]
? number[]
: number | undefined;
export const ViewContext = createContext<ExplorerViewContext | null>(null);
export const useExplorerViewContext = () => {

View file

@ -1,7 +1,6 @@
import { FolderNotchOpen } from 'phosphor-react';
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { ExplorerItem, useLibrarySubscription } from '@sd/client';
import { useKeyDeleteFile } from '~/hooks';
import { PropsWithChildren, ReactNode } from 'react';
import { useLibrarySubscription } from '@sd/client';
import { TOP_BAR_HEIGHT } from '../TopBar';
import { useExplorerContext } from './Context';
import ContextMenu from './ContextMenu';
@ -10,34 +9,23 @@ import { Inspector } from './Inspector';
import ExplorerContextMenu from './ParentContextMenu';
import View, { EmptyNotice, ExplorerViewProps } from './View';
import { useExplorerStore } from './store';
import { useExplorerSearchParams } from './util';
interface Props {
items: ExplorerItem[] | null;
onLoadMore?(): void;
emptyNotice?: ExplorerViewProps['emptyNotice'];
contextMenu?: (item: ExplorerItem) => ReactNode;
contextMenu?: () => ReactNode;
}
export default function Explorer(props: Props) {
const INSPECTOR_WIDTH = 260;
const INSPECTOR_WIDTH = 260;
/**
* This component is used in a few routes and acts as the reference demonstration of how to combine
* all the elements of the explorer except for the context, which must be used in the parent component.
*/
export default function Explorer(props: PropsWithChildren<Props>) {
const explorerStore = useExplorerStore();
const explorer = useExplorerContext();
const [{ path }] = useExplorerSearchParams();
const scrollRef = useRef<HTMLDivElement>(null);
const [selectedItemId, setSelectedItemId] = useState<number>();
const selectedItem = useMemo(
() =>
selectedItemId
? props.items?.find((item) => item.item.id === selectedItemId)
: undefined,
[selectedItemId, props.items]
);
// Can we put this somewhere else -_-
useLibrarySubscription(['jobs.newThumbnail'], {
onStarted: () => {
console.log('Started RSPC subscription new thumbnail');
@ -50,47 +38,24 @@ export default function Explorer(props: Props) {
}
});
const ctx = useExplorerContext();
useKeyDeleteFile(
selectedItem || null,
ctx.parent?.type === 'Location' ? ctx.parent.location.id : null
);
useEffect(() => setSelectedItemId(undefined), [path]);
return (
<>
<ExplorerContextMenu>
<div className="flex-1 overflow-hidden">
<div
ref={scrollRef}
ref={explorer.scrollRef}
className="custom-scroll explorer-scroll relative h-screen overflow-x-hidden"
style={{
paddingTop: TOP_BAR_HEIGHT,
paddingRight: explorerStore.showInspector ? INSPECTOR_WIDTH : 0
}}
>
{props.items && props.items.length > 0 && <DismissibleNotice />}
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
<View
items={props.items}
scrollRef={scrollRef}
onLoadMore={props.onLoadMore}
rowsBeforeLoadMore={5}
selected={selectedItemId}
onSelectedChange={setSelectedItemId}
contextMenu={
selectedItem ? (
props.contextMenu ? (
props.contextMenu(selectedItem)
) : (
<ContextMenu item={selectedItem} />
)
) : null
}
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice || (
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
@ -104,7 +69,6 @@ export default function Explorer(props: Props) {
{explorerStore.showInspector && (
<Inspector
data={selectedItem}
className="custom-scroll inspector-scroll absolute inset-y-0 right-0 pb-4 pl-1.5 pr-1"
style={{ paddingTop: TOP_BAR_HEIGHT + 16, width: INSPECTOR_WIDTH }}
/>

View file

@ -26,6 +26,17 @@ export type CutCopyType = 'Cut' | 'Copy';
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
type CutCopyState =
| {
type: 'Idle';
}
| {
type: 'Cut' | 'Copy';
sourceParentPath: string; // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
sourceLocationId: number;
sourcePathIds: number[];
};
const state = {
layoutMode: 'grid' as ExplorerLayoutMode,
gridItemSize: 110,
@ -34,21 +45,16 @@ const state = {
tagAssignMode: false,
showInspector: false,
mediaPlayerVolume: 0.7,
multiSelectIndexes: [] as number[],
newThumbnails: proxySet() as Set<string>,
cutCopyState: {
sourceParentPath: '', // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
sourceLocationId: 0,
sourcePathId: 0,
actionType: 'Cut',
active: false
},
cutCopyState: { type: 'Idle' } as CutCopyState,
quickViewObject: null as ExplorerItem | null,
mediaColumns: 8,
mediaAspectSquare: false,
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
orderByDirection: 'Desc' as SortOrder,
groupBy: 'none'
groupBy: 'none',
isDragging: false,
gridGap: 8
};
export function flattenThumbnailKey(thumbKey: string[]) {
@ -78,9 +84,6 @@ export function getExplorerStore() {
}
export function isCut(id: number) {
return (
explorerStore.cutCopyState.active &&
explorerStore.cutCopyState.actionType === 'Cut' &&
explorerStore.cutCopyState.sourcePathId === id
);
const state = explorerStore.cutCopyState;
return state.type === 'Cut' && state.sourcePathIds.includes(id);
}

View file

@ -0,0 +1,132 @@
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { ExplorerItem, FilePath, Location, NodeState, Tag } from '@sd/client';
import { explorerItemHash } from './util';
export type ExplorerParent =
| {
type: 'Location';
location: Location;
subPath?: FilePath;
}
| {
type: 'Tag';
tag: Tag;
}
| {
type: 'Node';
node: NodeState;
};
export interface UseExplorerProps {
items: ExplorerItem[] | null;
parent?: ExplorerParent;
loadMore?: () => void;
scrollRef?: RefObject<HTMLDivElement>;
/**
* @defaultValue `true`
*/
allowMultiSelect?: boolean;
/**
* @defaultValue `5`
*/
rowsBeforeLoadMore?: number;
overscan?: number;
/**
* @defaultValue `true`
*/
selectable?: boolean;
}
export type ExplorerItemMeta = {
type: 'Location' | 'Path' | 'Object';
id: number;
};
export type ExplorerItemHash = `${ExplorerItemMeta['type']}:${ExplorerItemMeta['id']}`;
/**
* Controls top-level config and state for the explorer.
* View- and inspector-specific state is not handled here.
*/
export function useExplorer(props: UseExplorerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
return {
// Default values
allowMultiSelect: true,
rowsBeforeLoadMore: 5,
selectable: true,
scrollRef,
// Provided values
...props,
// Selected items
...useSelectedItems(props.items)
};
}
export type UseExplorer = ReturnType<typeof useExplorer>;
function useSelectedItems(items: ExplorerItem[] | null) {
// Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings
// WeakMap ensures that ExplorerItems aren't held onto after they're evicted from cache
const itemHashesWeakMap = useRef(new WeakMap<ExplorerItem, ExplorerItemHash>());
// Store hashes of items instead as objects are unique by reference but we
// still need to differentate between item variants
const [selectedItemHashes, setSelectedItemHashes] = useState(() => ({
value: new Set<ExplorerItemHash>()
}));
const updateHashes = useCallback(
() => setSelectedItemHashes((h) => ({ ...h })),
[setSelectedItemHashes]
);
const itemsMap = useMemo(
() =>
(items ?? []).reduce((items, item) => {
const hash = itemHashesWeakMap.current.get(item) ?? explorerItemHash(item);
itemHashesWeakMap.current.set(item, hash);
items.set(hash, item);
return items;
}, new Map<ExplorerItemHash, ExplorerItem>()),
[items]
);
const selectedItems = useMemo(
() =>
[...selectedItemHashes.value].reduce((items, hash) => {
const item = itemsMap.get(hash);
if (item) items.add(item);
return items;
}, new Set<ExplorerItem>()),
[itemsMap, selectedItemHashes]
);
return {
selectedItems,
selectedItemHashes,
addSelectedItem: useCallback(
(item: ExplorerItem) => {
selectedItemHashes.value.add(explorerItemHash(item));
updateHashes();
},
[selectedItemHashes.value, updateHashes]
),
removeSelectedItem: useCallback(
(item: ExplorerItem) => {
selectedItemHashes.value.delete(explorerItemHash(item));
updateHashes();
},
[selectedItemHashes.value, updateHashes]
),
resetSelectedItems: useCallback(
(items?: ExplorerItem[]) => {
selectedItemHashes.value.clear();
items?.forEach((item) => selectedItemHashes.value.add(explorerItemHash(item)));
updateHashes();
},
[selectedItemHashes.value, updateHashes]
)
};
}

View file

@ -3,6 +3,7 @@ import { ExplorerItem, FilePathSearchOrdering, getExplorerItemData } from '@sd/c
import { ExplorerParamsSchema } from '~/app/route-schemas';
import { useZodSearchParams } from '~/hooks';
import { flattenThumbnailKey, useExplorerStore } from './store';
import { ExplorerItemHash } from './useExplorer';
export function useExplorerOrder(): FilePathSearchOrdering | undefined {
const explorerStore = useExplorerStore();
@ -47,3 +48,7 @@ export function useExplorerItemData(explorerItem: ExplorerItem) {
return itemData;
}, [explorerItem, newThumbnail]);
}
export function explorerItemHash(item: ExplorerItem): ExplorerItemHash {
return `${item.type}:${item.item.id}`;
}

View file

@ -47,9 +47,9 @@ const Layout = () => {
className={clsx(
// App level styles
'flex h-screen cursor-default select-none overflow-hidden text-ink',
os === 'browser' && 'border-t border-app-line/50 bg-app',
os === 'browser' && 'bg-app',
os === 'macOS' && 'has-blur-effects rounded-[10px]',
os !== 'browser' && os !== 'windows' && 'border border-app-frame'
os !== 'browser' && os !== 'windows' && 'frame border border-transparent'
)}
onContextMenu={(e) => {
// TODO: allow this on some UI text at least / disable default browser context menu

View file

@ -1,4 +1,6 @@
import clsx from 'clsx';
import { Ref } from 'react';
import { useExplorerStore } from '../Explorer/store';
import { NavigationButtons } from './NavigationButtons';
import SearchBar from './SearchBar';
@ -9,23 +11,28 @@ interface Props {
rightRef?: Ref<HTMLDivElement>;
}
const TopBar = (props: Props) => (
<div
data-tauri-drag-region
className="
duration-250 top-bar-blur absolute left-0 top-0 z-50 flex
h-[46px] w-full flex-row items-center justify-center overflow-hidden
border-b border-sidebar-divider bg-app/90 px-3.5
transition-[background-color,border-color] ease-out
"
>
<div data-tauri-drag-region className="flex flex-1 flex-row items-center">
<NavigationButtons />
<div ref={props.leftRef} />
const TopBar = (props: Props) => {
const { isDragging } = useExplorerStore();
return (
<div
data-tauri-drag-region
className={clsx(
'duration-250 top-bar-blur absolute left-0 top-0 z-50 flex',
'h-[46px] w-full flex-row items-center justify-center overflow-hidden',
'border-b border-sidebar-divider bg-app/90 px-3.5',
'transition-[background-color,border-color] ease-out',
isDragging && 'pointer-events-none'
)}
>
<div data-tauri-drag-region className="flex flex-1 flex-row items-center">
<NavigationButtons />
<div ref={props.leftRef} />
</div>
<SearchBar />
<div className="flex-1" ref={props.rightRef} />
</div>
<SearchBar />
<div className="flex-1" ref={props.rightRef} />
</div>
);
);
};
export default TopBar;

View file

@ -1,5 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import {
useLibraryContext,
useLibraryQuery,
@ -8,18 +8,19 @@ import {
} from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Folder } from '~/components';
import { useZodRouteParams } from '~/hooks';
import { useKeyDeleteFile, useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import ContextMenu, { FilePathItems } from '../Explorer/ContextMenu';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
import { useExplorer } from '../Explorer/useExplorer';
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
import { TopBarPortal } from '../TopBar/Portal';
import LocationOptions from './LocationOptions';
export const Component = () => {
const [{ path }] = useExplorerSearchParams();
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const location = useLibraryQuery(['locations.get', locationId]);
@ -37,17 +38,27 @@ export const Component = () => {
const { items, loadMore } = useItems({ locationId });
const explorer = useExplorer({
items,
loadMore,
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined
});
useEffect(() => {
// Using .call to silence eslint exhaustive deps warning.
// If clearSelectedItems referenced 'this' then this wouldn't work
explorer.resetSelectedItems.call(undefined);
}, [explorer.resetSelectedItems, path]);
useKeyDeleteFile(explorer.selectedItems, location.data?.id);
return (
<ExplorerContext.Provider
value={{
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined
}}
>
<ExplorerContext.Provider value={explorer}>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -67,25 +78,7 @@ export const Component = () => {
right={<DefaultTopBarOptions />}
/>
<Explorer
items={items}
onLoadMore={loadMore}
contextMenu={(item) => (
<ContextMenu
item={item}
extra={({ filePath }) => (
<>
{filePath && location.data && (
<FilePathItems.CutCopyItems
locationId={location.data.id}
filePath={filePath}
/>
)}
</>
)}
/>
)}
/>
<Explorer />
</ExplorerContext.Provider>
);
};
@ -130,11 +123,11 @@ const useItems = ({ locationId }: { locationId: number }) => {
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) || null, [query.data]);
function loadMore() {
const loadMore = useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage();
query.fetchNextPage.call(undefined);
}
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
return { query, items, loadMore };
};

View file

@ -5,6 +5,7 @@ import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer } from '../Explorer/useExplorer';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
@ -14,17 +15,18 @@ export const Component = () => {
const nodeState = useBridgeQuery(['nodeState']);
const explorer = useExplorer({
items: query.data || null,
parent: nodeState.data
? {
type: 'Node',
node: nodeState.data
}
: undefined
});
return (
<ExplorerContext.Provider
value={{
parent: nodeState.data
? {
type: 'Node',
node: nodeState.data
}
: undefined
}}
>
<ExplorerContext.Provider value={explorer}>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -42,7 +44,7 @@ export const Component = () => {
right={<DefaultTopBarOptions />}
/>
<Explorer items={query.data || []} />
<Explorer />
</ExplorerContext.Provider>
);
};

View file

@ -71,7 +71,7 @@ export const Categories = (props: { selected: Category; onSelectedChanged(c: Cat
return (
<Sticky
scrollElement={pageRef.current || undefined}
stickyClassName="z-10 !top-[46px]"
stickyClassName="z-20 !top-[46px]"
topOffset={-46}
>
<div className="relative flex bg-app/90 px-3 py-1.5 backdrop-blur">

View file

@ -103,12 +103,12 @@ export function useItems(category: Category) {
return isObjectQuery
? {
items: objectsItems,
items: objectsItems ?? null,
query: objectsQuery,
loadMore
}
: {
items: pathsItems,
items: pathsItems ?? null,
query: pathsQuery,
loadMore
};

View file

@ -1,14 +1,16 @@
import { getIcon } from '@sd/assets/util';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import 'react-loading-skeleton/dist/skeleton.css';
import { Category } from '@sd/client';
import { useIsDark } from '../../../hooks';
import { ExplorerContext } from '../Explorer/Context';
import ContextMenu, { ObjectItems } from '../Explorer/ContextMenu';
import { Conditional } from '../Explorer/ContextMenu/ConditionalItem';
import { Inspector } from '../Explorer/Inspector';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import View from '../Explorer/View';
import { useExplorerStore } from '../Explorer/store';
import { useExplorer } from '../Explorer/useExplorer';
import { usePageLayoutContext } from '../PageLayout/Context';
import { TopBarPortal } from '../TopBar/Portal';
import Statistics from '../overview/Statistics';
@ -43,24 +45,23 @@ export const Component = () => {
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
const { items, query, loadMore } = useItems(selectedCategory);
const { items, loadMore } = useItems(selectedCategory);
const [selectedItemId, setSelectedItemId] = useState<number>();
const selectedItem = useMemo(
() => (selectedItemId ? items?.find((item) => item.item.id === selectedItemId) : undefined),
[selectedItemId, items]
);
const explorer = useExplorer({
items,
loadMore,
scrollRef: page.ref
});
useEffect(() => {
if (page?.ref.current) {
const { scrollTop } = page.ref.current;
if (scrollTop > 100) page.ref.current.scrollTo({ top: 100 });
}
}, [selectedCategory, page?.ref]);
if (!page.ref.current) return;
const { scrollTop } = page.ref.current;
if (scrollTop > 100) page.ref.current.scrollTo({ top: 100 });
}, [selectedCategory, page.ref]);
return (
<ExplorerContext.Provider value={{}}>
<ExplorerContext.Provider value={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Statistics />
@ -69,28 +70,12 @@ export const Component = () => {
<div className="flex flex-1">
<View
items={query.isLoading ? null : items || []}
// TODO: Fix this type here.
scrollRef={page?.ref as any}
onLoadMore={loadMore}
rowsBeforeLoadMore={5}
selected={selectedItemId}
onSelectedChange={setSelectedItemId}
top={68}
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
contextMenu={
selectedItem ? (
<ContextMenu
item={selectedItem}
extra={({ object }) => (
<>
{object && (
<ObjectItems.RemoveFromRecents object={object} />
)}
</>
)}
/>
) : null
<ContextMenu>
{() => <Conditional items={[ObjectItems.RemoveFromRecents]} />}
</ContextMenu>
}
emptyNotice={
<div className="flex h-full flex-col items-center justify-center text-white">
@ -111,9 +96,8 @@ export const Component = () => {
{explorerStore.showInspector && (
<Inspector
data={selectedItem}
showThumbnail={explorerStore.layoutMode !== 'media'}
className="custom-scroll inspector-scroll sticky top-[68px] h-auto w-[260px] shrink-0 self-start bg-app pb-4 pl-1.5 pr-1"
className="custom-scroll inspector-scroll sticky top-[68px] h-full w-[260px] shrink-0 bg-app pb-4 pl-1.5 pr-1"
/>
)}
</div>

View file

@ -6,7 +6,9 @@ import { useZodSearchParams } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContext } from './Explorer/Context';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { EmptyNotice } from './Explorer/View';
import { getExplorerStore, useExplorerStore } from './Explorer/store';
import { useExplorer } from './Explorer/useExplorer';
import { TopBarPortal } from './TopBar/Portal';
const SearchExplorer = memo((props: { args: SearchParams }) => {
@ -21,31 +23,35 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
});
const items = useMemo(() => {
const items = query.data?.items;
const items = query.data?.items ?? null;
if (explorerStore.layoutMode !== 'media') return items;
return items?.filter((item) => {
const { kind } = getExplorerItemData(item);
return kind === 'Video' || kind === 'Image';
});
return (
items?.filter((item) => {
const { kind } = getExplorerItemData(item);
return kind === 'Video' || kind === 'Image';
}) || null
);
}, [query.data, explorerStore.layoutMode]);
const explorer = useExplorer({
items
});
return (
<>
{items && items.length > 0 ? (
<ExplorerContext.Provider value={{}}>
{search ? (
<ExplorerContext.Provider value={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer items={items} />
<Explorer
emptyNotice={<EmptyNotice message={`No results found for "${search}"`} />}
/>
</ExplorerContext.Provider>
) : (
<div className="flex flex-1 flex-col items-center justify-center">
{!search && (
<MagnifyingGlass size={110} className="mb-5 text-ink-faint" opacity={0.3} />
)}
<p className="text-xs text-ink-faint">
{search ? `No results found for "${search}"` : 'Search for files...'}
</p>
<MagnifyingGlass size={110} className="mb-5 text-ink-faint" opacity={0.3} />
<p className="text-xs text-ink-faint">Search for files...</p>
</div>
)}
</>
@ -58,7 +64,7 @@ export const Component = () => {
const search = useDeferredValue(searchParams);
return (
<Suspense fallback="LOADING FIRST RENDER">
<Suspense>
<SearchExplorer args={search} />
</Suspense>
);

View file

@ -1,4 +1,4 @@
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { Object, useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { Dialog, InputField, UseDialogProps, useDialog, useZodForm, z } from '@sd/ui';
import { ColorPicker } from '~/components';
@ -7,7 +7,7 @@ const schema = z.object({
color: z.string()
});
export default (props: UseDialogProps & { assignToObject?: number }) => {
export default (props: UseDialogProps & { objects?: Object[] }) => {
const submitPlausibleEvent = usePlausibleEvent();
const form = useZodForm({
@ -24,10 +24,10 @@ export default (props: UseDialogProps & { assignToObject?: number }) => {
submitPlausibleEvent({ event: { type: 'tagCreate' } });
if (props.assignToObject !== undefined) {
if (props.objects !== undefined) {
await assignTag.mutateAsync({
tag_id: tag.id,
object_ids: [props.assignToObject],
object_ids: props.objects.map((o) => o.id),
unassign: false
});
}

View file

@ -6,6 +6,7 @@ import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { EmptyNotice } from '../Explorer/View';
import { useExplorer } from '../Explorer/useExplorer';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
@ -22,20 +23,20 @@ export const Component = () => {
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
const explorer = useExplorer({
items: explorerData.data?.items || null,
parent: tag.data
? {
type: 'Tag',
tag: tag.data
}
: undefined
});
return (
<ExplorerContext.Provider
value={{
parent: tag.data
? {
type: 'Tag',
tag: tag.data
}
: undefined
}}
>
<ExplorerContext.Provider value={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
items={explorerData.data?.items || null}
emptyNotice={
<EmptyNotice
icon={<img className="h-32 w-32" src={getIcon(iconNames.Tags)} />}

View file

@ -12,6 +12,20 @@ body {
@apply bg-app;
}
.frame::before {
@apply bg-app-frame;
content: '';
pointer-events: none;
user-select: none;
position: absolute;
inset: 0px;
border-radius: inherit;
padding: 1px;
mask: linear-gradient(black, black) content-box content-box, linear-gradient(black, black);
mask-composite: xor;
z-index: 9999;
}
.has-blur-effects {
.app-background {
// adjust macOS blur intensity here

View file

@ -2,7 +2,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { Plus } from 'phosphor-react';
import { useRef } from 'react';
import { useLibraryMutation, useLibraryQuery, usePlausibleEvent } from '@sd/client';
import { Object, useLibraryMutation, useLibraryQuery, usePlausibleEvent } from '@sd/client';
import {
ContextMenu,
DropdownMenu,
@ -16,15 +16,17 @@ import { useOperatingSystem } from '~/hooks';
import { useScrolled } from '~/hooks/useScrolled';
import { keybindForOs } from '~/util/keybinds';
export default (props: { objectId: number }) => {
export default (props: { objects: Object[] }) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const submitPlausibleEvent = usePlausibleEvent();
const tags = useLibraryQuery(['tags.list'], { suspense: true });
const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], {
suspense: true
});
// Map<tag::id, Vec<object::id>>
const tagsWithObjects = useLibraryQuery([
'tags.getWithObjects',
props.objects.map(({ id }) => id)
]);
const assignTag = useLibraryMutation('tags.assign', {
onSuccess: () => {
@ -55,9 +57,7 @@ export default (props: { objectId: number }) => {
iconProps={{ size: 15 }}
keybind={keybind([ModifierKeys.Control], ['N'])}
onClick={() => {
dialogManager.create((dp) => (
<CreateDialog {...dp} assignToObject={props.objectId} />
));
dialogManager.create((dp) => <CreateDialog {...dp} objects={props.objects} />);
}}
/>
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
@ -80,9 +80,16 @@ export default (props: { objectId: number }) => {
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const tag = tags.data[virtualRow.index];
const active = !!tagsForObject.data?.find((t) => t.id === tag?.id);
if (!tag) return null;
const objectsWithTag = tagsWithObjects.data?.[tag?.id];
// only unassign if all objects have tag
// this is the same functionality as finder
const unassign = objectsWithTag?.length === props.objects.length;
// TODO: UI to differentiate tag assigning when some objects have tag when no objects have tag - ENG-965
return (
<Menu.Item
key={virtualRow.index}
@ -94,12 +101,24 @@ export default (props: { objectId: number }) => {
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
assignTag.mutate({
await assignTag.mutateAsync({
unassign,
tag_id: tag.id,
object_ids: [props.objectId],
unassign: active
object_ids: unassign
? // use objects that already have tag
objectsWithTag
: // use objects that don't have tag
props.objects
.filter(
(o) =>
!objectsWithTag?.some(
(ot) => ot === o.id
)
)
.map((o) => o.id)
});
}}
>
@ -107,7 +126,11 @@ export default (props: { objectId: number }) => {
className="mr-0.5 h-[15px] w-[15px] shrink-0 rounded-full border"
style={{
backgroundColor:
active && tag.color ? tag.color : 'transparent',
objectsWithTag &&
objectsWithTag.length > 0 &&
tag.color
? tag.color
: 'transparent',
borderColor: tag.color || '#efefef'
}}
/>

View file

@ -1,121 +1,175 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import React, {
HTMLAttributes,
PropsWithChildren,
ReactNode,
cloneElement,
createContext,
useContext,
useRef
} from 'react';
import React, { ReactNode, useCallback, useLayoutEffect, useRef } from 'react';
import { RefObject, useEffect, useMemo, useState } from 'react';
import Selecto, { SelectoProps } from 'react-selecto';
import { useBoundingclientrect, useIntersectionObserverRef, useKey, useKeys } from 'rooks';
import { useMutationObserver } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import { TOP_BAR_HEIGHT } from '~/app/$libraryId/TopBar';
type GridListSelection = number | number[];
interface GridListDefaults<T extends GridListSelection> {
type ItemData = any | undefined;
type ItemId = number | string;
export interface GridListItem<IdT extends ItemId = number, DataT extends ItemData = undefined> {
index: number;
id: IdT;
row: number;
column: number;
rect: Omit<DOMRect, 'toJSON'>;
data: DataT;
}
export interface UseGridListProps<IdT extends ItemId = number, DataT extends ItemData = undefined> {
count: number;
scrollRef: RefObject<HTMLElement>;
ref: RefObject<HTMLElement>;
padding?: number | { x?: number; y?: number };
gap?: number | { x?: number; y?: number };
children: (props: {
index: number;
item: (props: GridListItemProps) => JSX.Element;
}) => JSX.Element | null;
selected?: T;
onSelectedChange?: (change: T) => void;
selectable?: boolean;
onSelect?: (index: number) => void;
onDeselect?: (index: number) => void;
overscan?: number;
top?: number;
onLoadMore?: () => void;
rowsBeforeLoadMore?: number;
preventSelection?: boolean;
preventContextMenuSelection?: boolean;
}
interface WrapProps<T extends GridListSelection> extends GridListDefaults<T> {
size: number | { width: number; height: number };
onLoadMore?: () => void;
getItemId?: (index: number) => IdT | undefined;
getItemData?: (index: number) => DataT;
size?: number | { width: number; height: number };
columns?: number;
}
interface ResizeProps<T extends GridListSelection> extends GridListDefaults<T> {
columns: number;
}
type GridListProps<T extends GridListSelection> = WrapProps<T> | ResizeProps<T>;
export const GridList = <T extends GridListSelection>({
selectable = true,
export const useGridList = <IdT extends ItemId = number, DataT extends ItemData = undefined>({
padding,
gap,
size,
columns,
ref,
getItemId,
getItemData,
...props
}: GridListProps<T>) => {
const scrollBarWidth = 6;
}: UseGridListProps<IdT, DataT>) => {
const { width } = useResizeObserver({ ref });
const multiSelect = Array.isArray(props.selected);
const paddingX = (typeof padding === 'object' ? padding.x : padding) || 0;
const paddingY = (typeof padding === 'object' ? padding.y : padding) || 0;
const paddingX = (typeof props.padding === 'object' ? props.padding.x : props.padding) || 0;
const paddingY = (typeof props.padding === 'object' ? props.padding.y : props.padding) || 0;
const gapX = (typeof gap === 'object' ? gap.x : gap) || 0;
const gapY = (typeof gap === 'object' ? gap.y : gap) || 0;
const gapX = (typeof props.gap === 'object' ? props.gap.x : props.gap) || 0;
const gapY = (typeof props.gap === 'object' ? props.gap.y : props.gap) || 0;
const itemWidth = size ? (typeof size === 'object' ? size.width : size) : undefined;
const itemHeight = size ? (typeof size === 'object' ? size.height : size) : undefined;
const itemWidth =
'size' in props
? typeof props.size === 'object'
? props.size.width
: props.size
: undefined;
const gridWidth = width ? width - (paddingX || 0) * 2 : 0;
const itemHeight =
'size' in props
? typeof props.size === 'object'
? props.size.height
: props.size
: undefined;
let columnCount = columns || 0;
const ref = useRef<HTMLDivElement>(null);
if (!columns && itemWidth) {
let columns = Math.floor(gridWidth / itemWidth);
if (gapX) columns = Math.floor((gridWidth - (columns - 1) * gapX) / itemWidth);
columnCount = columns;
}
const { width = 0 } = useResizeObserver({ ref: ref });
const rowCount = columnCount > 0 ? Math.ceil(props.count / columnCount) : 0;
const rect = useBoundingclientrect(ref);
const virtualItemWidth =
columnCount > 0 ? (gridWidth - (columnCount - 1) * gapX) / columnCount : 0;
const selecto = useRef<Selecto>(null);
const [scrollOptions, setScrollOptions] = React.useState<SelectoProps['scrollOptions']>();
const [listOffset, setListOffset] = useState(0);
const gridWidth = width - (paddingX || 0) * 2;
// Virtualizer count calculation
const amountOfColumns =
'columns' in props ? props.columns : itemWidth ? Math.floor(gridWidth / itemWidth) : 0;
const amountOfRows = amountOfColumns > 0 ? Math.ceil(props.count / amountOfColumns) : 0;
// Virtualizer item size calculation
const virtualItemWidth = amountOfColumns > 0 ? gridWidth / amountOfColumns : 0;
const virtualItemHeight = itemHeight || virtualItemWidth;
const getItem = useCallback(
(index: number) => {
if (index < 0 || index >= props.count) return;
const id = getItemId?.(index) || index;
const data = getItemData?.(index) as DataT;
const column = index % columnCount;
const row = Math.floor(index / columnCount);
const x = paddingX + (column !== 0 ? gapX : 0) * column + virtualItemWidth * column;
const y = paddingY + (row !== 0 ? gapY : 0) * row + virtualItemHeight * row;
const item: GridListItem<typeof id, DataT> = {
index,
id,
data,
row,
column,
rect: {
height: virtualItemHeight,
width: virtualItemWidth,
x,
y,
top: y,
bottom: y + virtualItemHeight,
left: x,
right: x + virtualItemWidth
}
};
return item;
},
[
columnCount,
props.count,
gapX,
gapY,
getItemId,
getItemData,
paddingX,
paddingY,
virtualItemHeight,
virtualItemWidth
]
);
return {
columnCount,
rowCount,
width: gridWidth,
padding: { x: paddingX, y: paddingY },
gap: { x: gapX, y: gapY },
itemHeight,
itemWidth,
virtualItemHeight,
virtualItemWidth,
getItem,
...props
};
};
export interface GridListProps {
grid: ReturnType<typeof useGridList>;
scrollRef: RefObject<HTMLElement>;
children: (index: number) => ReactNode;
}
export const GridList = ({ grid, children, scrollRef }: GridListProps) => {
const ref = useRef<HTMLDivElement>(null);
const [listOffset, setListOffset] = useState(0);
const getHeight = useCallback(
(index: number) => grid.virtualItemHeight + (index !== 0 ? grid.gap.y : 0),
[grid.virtualItemHeight, grid.gap.y]
);
const getWidth = useCallback(
(index: number) => grid.virtualItemWidth + (index !== 0 ? grid.gap.x : 0),
[grid.virtualItemWidth, grid.gap.x]
);
const rowVirtualizer = useVirtualizer({
count: amountOfRows,
getScrollElement: () => props.scrollRef.current,
estimateSize: () => virtualItemHeight,
measureElement: () => virtualItemHeight,
paddingStart: paddingY,
paddingEnd: paddingY,
overscan: props.overscan,
count: grid.rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: getHeight,
paddingStart: grid.padding.y,
paddingEnd: grid.padding.y,
overscan: grid.overscan,
scrollMargin: listOffset
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: amountOfColumns,
getScrollElement: () => props.scrollRef.current,
estimateSize: () => virtualItemWidth,
measureElement: () => virtualItemWidth,
paddingStart: paddingX,
paddingEnd: paddingX
count: grid.columnCount,
getScrollElement: () => scrollRef.current,
estimateSize: getWidth,
paddingStart: grid.padding.x,
paddingEnd: grid.padding.x
});
const virtualRows = rowVirtualizer.getVirtualItems();
@ -125,7 +179,7 @@ export const GridList = <T extends GridListSelection>({
useEffect(() => {
rowVirtualizer.measure();
columnVirtualizer.measure();
}, [rowVirtualizer, columnVirtualizer, virtualItemWidth, virtualItemHeight]);
}, [rowVirtualizer, columnVirtualizer, grid.virtualItemWidth, grid.virtualItemHeight]);
// Force recalculate range
// https://github.com/TanStack/virtual/issues/485
@ -134,143 +188,30 @@ export const GridList = <T extends GridListSelection>({
rowVirtualizer.calculateRange();
// @ts-ignore
columnVirtualizer.calculateRange();
}, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]);
// Set Selecto scroll options
useEffect(() => {
setScrollOptions({
container: props.scrollRef.current!,
getScrollPosition: () => {
// FIXME: Eslint is right here.
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
return [props.scrollRef.current?.scrollLeft!, props.scrollRef.current?.scrollTop!];
},
throttleTime: 30,
threshold: 0
});
}, []);
// Check Selecto scroll
useEffect(() => {
const handleScroll = () => {
selecto.current?.checkScroll();
};
props.scrollRef.current?.addEventListener('scroll', handleScroll);
return () => props.scrollRef.current?.removeEventListener('scroll', handleScroll);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowVirtualizer, columnVirtualizer, grid.columnCount, grid.rowCount]);
useEffect(() => {
setListOffset(ref.current?.offsetTop || 0);
}, [rect]);
// Handle key Selection
useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => {
!props.preventSelection && e.preventDefault();
if (!selectable || !props.onSelectedChange || props.preventSelection) return;
const selectedItems = selecto.current?.getSelectedTargets() || [
...document.querySelectorAll<HTMLDivElement>(`[data-selected="true"]`)
];
const lastItem = selectedItems[selectedItems.length - 1];
if (lastItem) {
const currentIndex = Number(lastItem.getAttribute('data-selectable-index'));
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowUp':
newIndex += -amountOfColumns;
break;
case 'ArrowDown':
newIndex += amountOfColumns;
break;
case 'ArrowRight':
newIndex += 1;
break;
case 'ArrowLeft':
newIndex += -1;
break;
}
const newSelectedItem = document.querySelector<HTMLDivElement>(
`[data-selectable-index="${newIndex}"]`
);
if (newSelectedItem) {
if (!multiSelect) {
const id = Number(newSelectedItem.getAttribute('data-selectable-id'));
props.onSelectedChange(id as T);
} else {
const addToGridListSelection = e.shiftKey;
selecto.current?.setSelectedTargets([
...(addToGridListSelection ? selectedItems : []),
newSelectedItem
]);
props.onSelectedChange(
[...(addToGridListSelection ? selectedItems : []), newSelectedItem].map(
(el) => Number(el.getAttribute('data-selectable-id'))
) as T
);
}
if (props.scrollRef.current) {
const direction = newIndex > currentIndex ? 'down' : 'up';
const itemRect = newSelectedItem.getBoundingClientRect();
const scrollRect = props.scrollRef.current.getBoundingClientRect();
const paddingTop = parseInt(
getComputedStyle(props.scrollRef.current).paddingTop
);
const top = props.top ? paddingTop + props.top : paddingTop;
switch (direction) {
case 'up': {
if (itemRect.top < top) {
props.scrollRef.current.scrollBy({
top: itemRect.top - top - paddingY - 1,
behavior: 'smooth'
});
}
break;
}
case 'down': {
if (itemRect.bottom > scrollRect.height) {
props.scrollRef.current.scrollBy({
top: itemRect.bottom - scrollRect.height + paddingY + 1,
behavior: 'smooth'
});
}
break;
}
}
}
}
}
});
useEffect(() => {
if (props.onLoadMore) {
if (grid.onLoadMore) {
const lastRow = virtualRows[virtualRows.length - 1];
if (lastRow) {
const rowsBeforeLoadMore = props.rowsBeforeLoadMore || 1;
const rowsBeforeLoadMore = grid.rowsBeforeLoadMore || 1;
const loadMoreOnIndex =
rowsBeforeLoadMore > amountOfRows ||
lastRow.index > amountOfRows - rowsBeforeLoadMore
? amountOfRows - 1
: amountOfRows - rowsBeforeLoadMore;
rowsBeforeLoadMore > grid.rowCount ||
lastRow.index > grid.rowCount - rowsBeforeLoadMore
? grid.rowCount - 1
: grid.rowCount - rowsBeforeLoadMore;
if (lastRow.index === loadMoreOnIndex) props.onLoadMore();
if (lastRow.index === loadMoreOnIndex) grid.onLoadMore();
}
}
}, [virtualRows, amountOfRows, props.rowsBeforeLoadMore, props.onLoadMore]);
}, [virtualRows, grid.rowCount, grid.rowsBeforeLoadMore, grid.onLoadMore, grid]);
useMutationObserver(scrollRef, () => setListOffset(ref.current?.offsetTop ?? 0));
useLayoutEffect(() => setListOffset(ref.current?.offsetTop ?? 0), []);
return (
<div
@ -280,155 +221,46 @@ export const GridList = <T extends GridListSelection>({
height: `${rowVirtualizer.getTotalSize()}px`
}}
>
{multiSelect && (
<Selecto
ref={selecto}
dragContainer={ref.current}
boundContainer={ref.current}
selectableTargets={['[data-selectable]']}
toggleContinueSelect={'shift'}
hitRate={0}
scrollOptions={scrollOptions}
onDragStart={(e) => {
if (e.inputEvent.target.nodeName === 'BUTTON') {
return false;
}
return true;
}}
onScroll={(e) => {
selecto.current;
props.scrollRef.current?.scrollBy(
e.direction[0]! * 10,
e.direction[1]! * 10
);
}}
onSelect={(e) => {
const set = new Set(props.selected as number[]);
{grid.width > 0 &&
virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.index}>
{virtualColumns.map((virtualColumn) => {
const index = virtualRow.index * grid.columnCount + virtualColumn.index;
e.removed.forEach((el) => {
set.delete(Number(el.getAttribute('data-selectable-id')));
});
if (index >= grid.count) return null;
e.added.forEach((el) => {
set.add(Number(el.getAttribute('data-selectable-id')));
});
props.onSelectedChange?.([...set] as T);
}}
/>
)}
{width !== 0 && (
<SelectoContext.Provider value={selecto}>
{virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.index}>
{virtualColumns.map((virtualColumn) => {
const index =
virtualRow.index * amountOfColumns + virtualColumn.index;
const item = props.children({ index, item: GridListItem });
if (!item) return null;
return (
return (
<div
key={virtualColumn.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${
virtualColumn.start
}px) translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
paddingLeft: virtualColumn.index !== 0 ? grid.gap.x : 0,
paddingTop: virtualRow.index !== 0 ? grid.gap.y : 0
}}
>
<div
key={virtualColumn.index}
className="m-auto"
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${
virtualColumn.start
}px) translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`
width: grid.itemWidth || '100%',
height: grid.itemHeight || '100%'
}}
>
{cloneElement<GridListItemProps>(item, {
selectable: selectable && !!props.onSelectedChange,
index,
style: { width: itemWidth },
onMouseDown: (id) => {
!multiSelect && props.onSelectedChange?.(id as T);
},
onContextMenu: (id) => {
!props.preventContextMenuSelection &&
!multiSelect &&
props.onSelectedChange?.(id as T);
}
})}
{children(index)}
</div>
);
})}
</React.Fragment>
))}
</SelectoContext.Provider>
)}
</div>
);
};
const SelectoContext = createContext<React.RefObject<Selecto>>(undefined!);
const useSelecto = () => useContext(SelectoContext);
interface GridListItemProps
extends PropsWithChildren,
Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'onMouseDown' | 'onContextMenu'> {
selectable?: boolean;
index?: number;
selected?: boolean;
id?: number;
onMouseDown?: (id: number) => void;
onContextMenu?: (id: number) => void;
}
const GridListItem = ({ className, children, style, ...props }: GridListItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const selecto = useSelecto();
useEffect(() => {
if (props.selectable && props.selected && selecto.current) {
const current = selecto.current.getSelectedTargets();
selecto.current?.setSelectedTargets([
...current.filter(
(el) => el.getAttribute('data-selectable-id') !== String(props.id)
),
ref.current!
]);
}
}, []);
const selectableProps = props.selectable
? {
'data-selectable': '',
'data-selectable-id': props.id || props.index,
'data-selectable-index': props.index,
'data-selected': props.selected
}
: {};
return (
<div
ref={ref}
{...selectableProps}
style={style}
className={clsx('mx-auto h-full', className)}
onMouseDown={(e) => {
e.stopPropagation();
if (e.button === 0 && props.onMouseDown && props.selectable) {
const id = props.id || props.index;
if (id) props.onMouseDown(id);
}
}}
onContextMenu={() => {
if (props.onContextMenu && props.selectable) {
const id = props.id || props.index;
if (id) props.onContextMenu(id);
}
}}
>
{children}
</div>
);
})}
</React.Fragment>
))}
</div>
);
};

View file

@ -3,14 +3,20 @@ import { ExplorerItem } from '@sd/client';
import { dialogManager } from '@sd/ui';
import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog';
export const useKeyDeleteFile = (selectedItem: ExplorerItem | null, location_id: number | null) => {
export const useKeyDeleteFile = (selectedItems: Set<ExplorerItem>, locationId?: number | null) => {
return useKey('Delete', (e) => {
e.preventDefault();
if (!selectedItem || !location_id) return;
if (!locationId) return;
const pathIds: number[] = [];
for (const item of selectedItems) {
if (item.type === 'Path') pathIds.push(item.item.id);
}
dialogManager.create((dp) => (
<DeleteDialog {...dp} location_id={location_id} path_id={selectedItem.item.id} />
<DeleteDialog {...dp} locationId={locationId} pathIds={pathIds} />
));
});
};

View file

@ -60,7 +60,7 @@
"react-router": "6.9.0",
"react-router-dom": "6.9.0",
"react-scroll-sync": "^0.11.0",
"react-selecto": "^1.22.3",
"react-selecto": "^1.26.0",
"react-sticky-el": "^2.1.0",
"react-use-draggable-scroll": "^0.4.7",
"remix-params-helper": "^0.4.10",

View file

@ -9,3 +9,7 @@ export function useForceUpdate() {
const [, setTick] = useState(0);
return useCallback(() => setTick((tick) => tick + 1), []);
}
export type NonEmptyArray<T> = [T, ...T[]];
export const isNonEmpty = <T,>(input: T[]): input is NonEmptyArray<T> => input.length > 0;

View file

@ -29,6 +29,7 @@ export type Procedures = {
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key: number]: number[] } } |
{ key: "tags.list", input: LibraryArgs<null>, result: Tag[] } |
{ key: "volumes.list", input: never, result: Volume[] },
mutations:
@ -41,7 +42,7 @@ export type Procedures = {
{ key: "files.renameFile", input: LibraryArgs<RenameFileArgs>, result: null } |
{ key: "files.setFavorite", input: LibraryArgs<SetFavoriteArgs>, result: null } |
{ key: "files.setNote", input: LibraryArgs<SetNoteArgs>, result: null } |
{ key: "files.updateAccessTime", input: LibraryArgs<number>, result: null } |
{ key: "files.updateAccessTime", input: LibraryArgs<number[]>, result: null } |
{ key: "invalidation.test-invalidate-mutation", input: LibraryArgs<null>, result: null } |
{ key: "jobs.cancel", input: LibraryArgs<string>, result: null } |
{ key: "jobs.clear", input: LibraryArgs<string>, result: null } |

View file

@ -27,7 +27,7 @@ const getDecimalUnit = (n: bigint) => {
);
};
function bytesToNumber(bytes: string[] | number[] | bigint[]) {
export function bytesToNumber(bytes: string[] | number[] | bigint[]) {
return bytes
.map((b) => (typeof b === 'bigint' ? b : BigInt(b)))
.reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1));

View file

@ -1,4 +1,5 @@
import { ExplorerItem } from '../core';
import { useMemo } from 'react';
import { ExplorerItem, FilePath, Object } from '../core';
import { ObjectKind, ObjectKindKey } from './objectKind';
export function getItemObject(data: ExplorerItem) {
@ -31,3 +32,54 @@ export function getExplorerItemData(data: ExplorerItem) {
thumbnailKey: data.thumbnail_key
};
}
export const useItemsAsObjects = (items: ExplorerItem[]) => {
return useMemo(() => {
const array: Object[] = [];
for (const item of items) {
switch (item.type) {
case 'Path': {
if (!item.item.object) return [];
array.push(item.item.object);
break;
}
case 'Object': {
array.push(item.item);
break;
}
default:
return [];
}
}
return array;
}, [items]);
};
export const useItemsAsFilePaths = (items: ExplorerItem[]) => {
return useMemo(() => {
const array: FilePath[] = [];
for (const item of items) {
switch (item.type) {
case 'Path': {
array.push(item.item);
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);
else return [];
break;
}
default:
return [];
}
}
return array;
}, [items]);
};

View file

@ -740,8 +740,8 @@ importers:
specifier: ^0.11.0
version: 0.11.0(react-dom@18.2.0)(react@18.2.0)
react-selecto:
specifier: ^1.22.3
version: 1.22.3
specifier: ^1.26.0
version: 1.26.0
react-sticky-el:
specifier: ^2.1.0
version: 2.1.0(react-dom@18.2.0)(react@18.2.0)
@ -21001,10 +21001,10 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-selecto@1.22.3:
resolution: {integrity: sha512-xCBnXEfuoWx5mczCL1wqm8v8Crq3JhIh5bJO5dCrWJFQyvBv0UbN1AGYrt9ZGxsxyitRj5cPdFq/Kf3KkpsuSA==}
/react-selecto@1.26.0:
resolution: {integrity: sha512-aBTZEYA68uE+o8TytNjTb2GpIn4oKEv0U4LIow3cspJQlF/PdAnBwkq9UuiKVuFluu5kfLQ7Keu3S2Tihlmw0g==}
dependencies:
selecto: 1.22.6
selecto: 1.26.0
dev: false
/react-shallow-renderer@16.15.0(react@18.2.0):
@ -21863,8 +21863,8 @@ packages:
kind-of: 6.0.3
dev: false
/selecto@1.22.6:
resolution: {integrity: sha512-9LqyQWmT032JZe3Rtg/urvXbkZJV/EFLGo115KQCv7wL9nuqindmJuzFQBRTB1xUcglWavGgnFZRp7EknFZh9Q==}
/selecto@1.26.0:
resolution: {integrity: sha512-cEFKdv5rmkF6pf2OScQJllaNp4UJy/FvviB40ZaMSHrQCxC72X/Q6uhzW1tlb2RE+0danvUNJTs64cI9VXtUyg==}
dependencies:
'@daybrush/utils': 1.13.0
'@egjs/children-differ': 1.0.1