mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-06 22:33:27 +00:00
[ENG-816, ENG-821] Re-implement reveal in finder + ContextMenu overhaul (#1029)
* mostly there * native opening working * more * cleanup * reorganise * remove unnecessary import * uncomment some stuff * spacing * store quickview ref inside provider * fix linting * clippy --------- Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com> Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
This commit is contained in:
parent
f2629a9f9a
commit
06de379169
25
Cargo.lock
generated
25
Cargo.lock
generated
|
@ -1763,6 +1763,17 @@ dependencies = [
|
|||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deps-generator"
|
||||
version = "0.0.0"
|
||||
|
@ -3837,6 +3848,16 @@ version = "0.2.146"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libheif-rs"
|
||||
version = "0.19.2"
|
||||
|
@ -5192,7 +5213,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6c62dcb6174f9cb326eac248f07e955d5d559c272730b6c03e396b443b562788"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"dbus",
|
||||
"normpath",
|
||||
"url",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
|
@ -7794,12 +7817,14 @@ dependencies = [
|
|||
"httpz 0.0.3",
|
||||
"opener",
|
||||
"percent-encoding",
|
||||
"prisma-client-rust",
|
||||
"rand 0.8.5",
|
||||
"rspc",
|
||||
"sd-core",
|
||||
"sd-desktop-linux",
|
||||
"sd-desktop-macos",
|
||||
"sd-desktop-windows",
|
||||
"sd-prisma",
|
||||
"serde",
|
||||
"specta",
|
||||
"tauri",
|
||||
|
|
|
@ -26,11 +26,14 @@ tracing = "0.1.37"
|
|||
serde = "1.0.163"
|
||||
percent-encoding = "2.2.0"
|
||||
http = "0.2.9"
|
||||
opener = "0.6.1"
|
||||
opener = { version = "0.6.1", features = ["reveal"] }
|
||||
specta = { workspace = true }
|
||||
tauri-specta = { workspace = true, features = ["typescript"] }
|
||||
uuid = { version = "1.3.3", features = ["serde"] }
|
||||
|
||||
prisma-client-rust = { workspace = true }
|
||||
sd-prisma = { path = "../../../crates/prisma" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
axum = { version = "0.6.18", features = ["headers", "query"] }
|
||||
rand = "0.8.5"
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use sd_core::Node;
|
||||
use sd_core::{
|
||||
prisma::{file_path, location},
|
||||
Node,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use tracing::error;
|
||||
|
||||
type NodeState<'a> = tauri::State<'a, Arc<Node>>;
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum OpenFilePathResult {
|
||||
|
@ -17,7 +25,7 @@ pub enum OpenFilePathResult {
|
|||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn open_file_path(
|
||||
pub async fn open_file_paths(
|
||||
library: uuid::Uuid,
|
||||
ids: Vec<i32>,
|
||||
node: tauri::State<'_, Arc<Node>>,
|
||||
|
@ -64,7 +72,7 @@ pub struct OpenWithApplication {
|
|||
pub async fn get_file_path_open_with_apps(
|
||||
library: uuid::Uuid,
|
||||
ids: Vec<i32>,
|
||||
node: tauri::State<'_, Arc<Node>>,
|
||||
node: NodeState<'_>,
|
||||
) -> Result<Vec<OpenWithApplication>, ()> {
|
||||
let Some(library) = node.library_manager.get_library(library).await
|
||||
else {
|
||||
|
@ -210,7 +218,7 @@ type FileIdAndUrl = (i32, String);
|
|||
pub async fn open_file_path_with(
|
||||
library: uuid::Uuid,
|
||||
file_ids_and_urls: Vec<FileIdAndUrl>,
|
||||
node: tauri::State<'_, Arc<Node>>,
|
||||
node: NodeState<'_>,
|
||||
) -> Result<(), ()> {
|
||||
let Some(library) = node.library_manager.get_library(library).await
|
||||
else {
|
||||
|
@ -272,3 +280,71 @@ pub async fn open_file_path_with(
|
|||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(specta::Type, serde::Deserialize)]
|
||||
pub enum RevealItem {
|
||||
Location { id: location::id::Type },
|
||||
FilePath { id: file_path::id::Type },
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub async fn reveal_items(
|
||||
library: uuid::Uuid,
|
||||
items: Vec<RevealItem>,
|
||||
node: NodeState<'_>,
|
||||
) -> Result<(), ()> {
|
||||
let Some(library) = node.library_manager.get_library(library).await
|
||||
else {
|
||||
return Err(())
|
||||
};
|
||||
|
||||
let (paths, locations): (Vec<_>, Vec<_>) =
|
||||
items
|
||||
.into_iter()
|
||||
.fold((vec![], vec![]), |(mut paths, mut locations), item| {
|
||||
match item {
|
||||
RevealItem::FilePath { id } => paths.push(id),
|
||||
RevealItem::Location { id } => locations.push(id),
|
||||
}
|
||||
|
||||
(paths, locations)
|
||||
});
|
||||
|
||||
let mut paths_to_open = BTreeSet::new();
|
||||
|
||||
if !paths.is_empty() {
|
||||
paths_to_open.extend(
|
||||
library
|
||||
.get_file_paths(paths)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_values()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
if !locations.is_empty() {
|
||||
paths_to_open.extend(
|
||||
library
|
||||
.db
|
||||
.location()
|
||||
.find_many(vec![
|
||||
location::node_id::equals(Some(library.node_local_id)),
|
||||
location::id::in_vec(locations),
|
||||
])
|
||||
.select(location::select!({ path }))
|
||||
.exec()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.flat_map(|location| location.path.map(Into::into)),
|
||||
);
|
||||
}
|
||||
|
||||
for path in paths_to_open {
|
||||
opener::reveal(path).ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -167,9 +167,10 @@ async fn main() -> tauri::Result<()> {
|
|||
app_ready,
|
||||
reset_spacedrive,
|
||||
open_logs_dir,
|
||||
file::open_file_path,
|
||||
file::open_file_paths,
|
||||
file::get_file_path_open_with_apps,
|
||||
file::open_file_path_with,
|
||||
file::reveal_items,
|
||||
theme::lock_app_theme
|
||||
])
|
||||
.build(tauri::generate_context!())?;
|
||||
|
|
|
@ -17,14 +17,7 @@ import {
|
|||
} from '@sd/interface';
|
||||
import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
|
||||
import '@sd/ui/style';
|
||||
import {
|
||||
appReady,
|
||||
getFilePathOpenWithApps,
|
||||
lockAppTheme,
|
||||
openFilePath,
|
||||
openFilePathWith,
|
||||
openLogsDir
|
||||
} from './commands';
|
||||
import * as commands from './commands';
|
||||
|
||||
// TODO: Bring this back once upstream is fixed up.
|
||||
// const client = hooks.createClient({
|
||||
|
@ -81,12 +74,7 @@ const platform: Platform = {
|
|||
openFilePickerDialog: () => dialog.open(),
|
||||
saveFilePickerDialog: () => dialog.save(),
|
||||
showDevtools: () => invoke('show_devtools'),
|
||||
openPath: (path) => shell.open(path),
|
||||
openLogsDir,
|
||||
openFilePath,
|
||||
getFilePathOpenWithApps,
|
||||
openFilePathWith,
|
||||
lockAppTheme
|
||||
...commands
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
@ -96,7 +84,7 @@ const router = createBrowserRouter(routes);
|
|||
export default function App() {
|
||||
useEffect(() => {
|
||||
// This tells Tauri to show the current window because it's finished loading
|
||||
appReady();
|
||||
commands.appReady();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -22,8 +22,8 @@ export function openLogsDir() {
|
|||
return invoke()<null>("open_logs_dir")
|
||||
}
|
||||
|
||||
export function openFilePath(library: string, ids: number[]) {
|
||||
return invoke()<OpenFilePathResult[]>("open_file_path", { library,ids })
|
||||
export function openFilePaths(library: string, ids: number[]) {
|
||||
return invoke()<OpenFilePathResult[]>("open_file_paths", { library,ids })
|
||||
}
|
||||
|
||||
export function getFilePathOpenWithApps(library: string, ids: number[]) {
|
||||
|
@ -34,10 +34,15 @@ export function openFilePathWith(library: string, fileIdsAndUrls: ([number, stri
|
|||
return invoke()<null>("open_file_path_with", { library,fileIdsAndUrls })
|
||||
}
|
||||
|
||||
export function revealItems(library: string, items: RevealItem[]) {
|
||||
return invoke()<null>("reveal_items", { library,items })
|
||||
}
|
||||
|
||||
export function lockAppTheme(themeType: AppThemeType) {
|
||||
return invoke()<null>("lock_app_theme", { themeType })
|
||||
}
|
||||
|
||||
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }
|
||||
export type RevealItem = { Location: { id: number } } | { FilePath: { id: number } }
|
||||
export type OpenWithApplication = { id: number; name: string; url: string }
|
||||
export type AppThemeType = "Auto" | "Light" | "Dark"
|
||||
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }
|
||||
|
|
|
@ -6,7 +6,7 @@ datasource db {
|
|||
generator client {
|
||||
provider = "cargo prisma"
|
||||
output = "../../crates/prisma/src/prisma.rs"
|
||||
module_path = "crate::prisma"
|
||||
module_path = "sd_prisma::prisma"
|
||||
}
|
||||
|
||||
generator sync {
|
||||
|
|
35
interface/app/$libraryId/Explorer/Context.tsx
Normal file
35
interface/app/$libraryId/Explorer/Context.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 useExplorerContext = () => {
|
||||
const ctx = useContext(ExplorerContext);
|
||||
|
||||
if (ctx === null) throw new Error('ExplorerContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
};
|
|
@ -1,200 +0,0 @@
|
|||
import { Clipboard, FileX, Image, Plus, Repeat, Share, ShieldCheck } from 'phosphor-react';
|
||||
import { PropsWithChildren, useMemo } from 'react';
|
||||
import { useBridgeQuery, useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { ContextMenu as CM, ModifierKeys } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { getExplorerStore, useExplorerStore, useOperatingSystem } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
export const OpenInNativeExplorer = () => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
|
||||
const osFileBrowserName = useMemo(() => {
|
||||
if (os === 'macOS') {
|
||||
return 'Finder';
|
||||
} else if (os === 'windows') {
|
||||
return 'Explorer';
|
||||
} else {
|
||||
return 'file manager';
|
||||
}
|
||||
}, [os]);
|
||||
|
||||
const { openPath } = usePlatform();
|
||||
|
||||
let { locationId } = useExplorerStore();
|
||||
if (locationId == null) locationId = -1;
|
||||
|
||||
const { library } = useLibraryContext();
|
||||
const { data: node } = useBridgeQuery(['nodeState']);
|
||||
const { data: location } = useLibraryQuery(['locations.get', locationId]);
|
||||
const [{ path: subPath }] = useExplorerSearchParams();
|
||||
|
||||
// Disable for remote nodes, as opening directories in a remote node is a more complex task
|
||||
if (!(openPath && location?.path && node?.id && library.config.node_id === node.id))
|
||||
return null;
|
||||
const path = location.path + (subPath ? subPath : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CM.Item
|
||||
label={`Open in ${osFileBrowserName}`}
|
||||
keybind={keybind([ModifierKeys.Control], ['Y'])}
|
||||
onClick={() => openPath(path)}
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default (props: PropsWithChildren) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const [{ path: currentPath }] = useExplorerSearchParams();
|
||||
const { locationId, cutCopyState } = useExplorerStore();
|
||||
|
||||
const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
const objectValidator = useLibraryMutation('jobs.objectValidator');
|
||||
const rescanLocation = useLibraryMutation('locations.fullRescan');
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const cutFiles = useLibraryMutation('files.cutFiles');
|
||||
|
||||
return (
|
||||
<CM.Root trigger={props.children}>
|
||||
<OpenInNativeExplorer />
|
||||
|
||||
<CM.Item
|
||||
label="Share"
|
||||
icon={Share}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
{locationId && (
|
||||
<>
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rescanLocation.mutateAsync(locationId);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to re-index location, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Re-index"
|
||||
icon={Repeat}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind={keybind([ModifierKeys.Control], ['V'])}
|
||||
hidden={!cutCopyState.active}
|
||||
onClick={async () => {
|
||||
const path = currentPath ?? '/';
|
||||
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
|
||||
cutCopyState;
|
||||
const sameLocation =
|
||||
sourceLocationId === locationId && sourceParentPath === path;
|
||||
try {
|
||||
if (actionType == 'Copy') {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: path,
|
||||
target_file_name_suffix: sameLocation ? ' copy' : null
|
||||
});
|
||||
} else if (sameLocation) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `File already exists in this location`
|
||||
});
|
||||
} else {
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={Clipboard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
hidden={!cutCopyState.active}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
{locationId && (
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await generateThumbsForLocation.mutateAsync({
|
||||
id: locationId,
|
||||
path: currentPath ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to generate thumbanails, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Regen Thumbnails"
|
||||
icon={Image}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
objectValidator.mutateAsync({
|
||||
id: locationId,
|
||||
path: currentPath ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to generate checksum, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Generate Checksums"
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</CM.SubMenu>
|
||||
)}
|
||||
</CM.Root>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,74 @@
|
|||
import { Copy, Scissors } from 'phosphor-react';
|
||||
import { FilePath, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { getExplorerStore } from '../../store';
|
||||
import { useExplorerSearchParams } from '../../util';
|
||||
|
||||
interface Props {
|
||||
locationId: number;
|
||||
filePath: FilePath;
|
||||
}
|
||||
|
||||
export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
||||
const keybind = useKeybindFactory();
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
210
interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx
Normal file
210
interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
import { Image, Package, Trash, TrashSimple } from 'phosphor-react';
|
||||
import { FilePath, useLibraryContext, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import DeleteDialog from '../../FilePath/DeleteDialog';
|
||||
import EraseDialog from '../../FilePath/EraseDialog';
|
||||
import OpenWith from './OpenWith';
|
||||
|
||||
export * from './CutCopyItems';
|
||||
|
||||
interface FilePathProps {
|
||||
filePath: FilePath;
|
||||
}
|
||||
|
||||
export const Delete = ({ filePath }: FilePathProps) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
const locationId = filePath.location_id;
|
||||
|
||||
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 Compress = (_: FilePathProps) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Compress"
|
||||
icon={Package}
|
||||
keybind={keybind([ModifierKeys.Control], ['B'])}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Crypto = (_: FilePathProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* <ContextMenu.Item
|
||||
label="Encrypt"
|
||||
icon={LockSimple}
|
||||
keybind="⌘E"
|
||||
onClick={() => {
|
||||
if (keyManagerUnlocked && hasMountedKeys) {
|
||||
dialogManager.create((dp) => (
|
||||
<EncryptDialog
|
||||
{...dp}
|
||||
location_id={store.locationId!}
|
||||
path_id={data.item.id}
|
||||
/>
|
||||
));
|
||||
} else if (!keyManagerUnlocked) {
|
||||
showAlertDialog({
|
||||
title: 'Key manager locked',
|
||||
value: 'The key manager is currently locked. Please unlock it and try again.'
|
||||
});
|
||||
} else if (!hasMountedKeys) {
|
||||
showAlertDialog({
|
||||
title: 'No mounted keys',
|
||||
value: 'No mounted keys were found. Please mount a key and try again.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/> */}
|
||||
{/* 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"
|
||||
onClick={() => {
|
||||
if (keyManagerUnlocked) {
|
||||
dialogManager.create((dp) => (
|
||||
<DecryptDialog
|
||||
{...dp}
|
||||
location_id={store.locationId!}
|
||||
path_id={data.item.id}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
showAlertDialog({
|
||||
title: 'Key manager locked',
|
||||
value: 'The key manager is currently locked. Please unlock it and try again.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecureDelete = ({ filePath }: FilePathProps) => {
|
||||
const locationId = filePath.location_id;
|
||||
|
||||
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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ParentFolderActions = ({
|
||||
filePath,
|
||||
locationId
|
||||
}: FilePathProps & { locationId: number }) => {
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fullRescan.mutateAsync(locationId);
|
||||
} 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 OpenOrDownload = ({ filePath }: { filePath: FilePath }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
const { platform, openFilePaths: openFilePath } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
if (platform === 'web') return <ContextMenu.Item label="Download" />;
|
||||
else
|
||||
return (
|
||||
<>
|
||||
{openFilePath && (
|
||||
<ContextMenu.Item
|
||||
label="Open"
|
||||
keybind={keybind([ModifierKeys.Control], ['O'])}
|
||||
onClick={async () => {
|
||||
if (filePath.object_id)
|
||||
updateAccessTime
|
||||
.mutateAsync(filePath.object_id)
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePath(library.uuid, [filePath.id]);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to open file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<OpenWith filePath={filePath} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -10,6 +10,7 @@ export default (props: { filePath: FilePath }) => {
|
|||
|
||||
if (!getFilePathOpenWithApps || !openFilePathWith) return null;
|
||||
if (props.filePath.is_dir) return null;
|
||||
|
||||
return (
|
||||
<ContextMenu.SubMenu label="Open with">
|
||||
<Suspense>
|
|
@ -0,0 +1,77 @@
|
|||
import { Plus } from 'phosphor-react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { FilePathItems, ObjectItems, SharedItems } from '../../ContextMenu';
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Path' }>;
|
||||
}
|
||||
|
||||
export default ({ data }: 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 />
|
||||
|
||||
{object && <ObjectItems.RemoveFromRecents object={object} />}
|
||||
|
||||
{parent?.type === 'Location' && (
|
||||
<FilePathItems.CutCopyItems locationId={parent.location.id} filePath={filePath} />
|
||||
)}
|
||||
|
||||
<SharedItems.Deselect />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{object && <ObjectItems.AssignTag object={object} />}
|
||||
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import { ExplorerItem } from "@sd/client";
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { SharedItems } from "..";
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Location' }>;
|
||||
}
|
||||
|
||||
export default ({ data }: Props) => {
|
||||
const location = data.item;
|
||||
|
||||
return <>
|
||||
<SharedItems.OpenQuickView item={data} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Details />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.RevealInNativeExplorer
|
||||
locationId={location.id}
|
||||
/>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
</>
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { ArrowBendUpRight, TagSimple } from 'phosphor-react';
|
||||
import { FilePath, ObjectKind, Object as ObjectType, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import AssignTagMenuItems from '../../AssignTagMenuItems';
|
||||
|
||||
export const RemoveFromRecents = ({ object }: { object: ObjectType }) => {
|
||||
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
|
||||
|
||||
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}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssignTag = ({ object }: { object: ObjectType }) => (
|
||||
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objectId={object.id} />
|
||||
</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;
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
import { Plus } from 'phosphor-react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { FilePathItems, ObjectItems, SharedItems } from '..';
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Object' }>;
|
||||
}
|
||||
|
||||
export default ({ data }: 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 />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
|
||||
{(object || filePath) && <ContextMenu.Separator />}
|
||||
|
||||
{object && <ObjectItems.AssignTag object={object} />}
|
||||
|
||||
{filePath && (
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
<FilePathItems.Crypto filePath={filePath} />
|
||||
<FilePathItems.Compress filePath={filePath} />
|
||||
<ObjectItems.ConvertObject filePath={filePath} object={object} />
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
|
||||
{filePath && (
|
||||
<>
|
||||
<ContextMenu.Separator />
|
||||
<FilePathItems.Delete filePath={filePath} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
130
interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx
Normal file
130
interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
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 { useExplorerViewContext } from '../ViewContext';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
|
||||
export const OpenQuickView = ({ item }: { item: ExplorerItem }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Quick view"
|
||||
keybind={keybind([], [' '])}
|
||||
onClick={() => (getExplorerStore().quickViewObject = item)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Details = () => {
|
||||
const { showInspector } = useExplorerStore();
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!showInspector && (
|
||||
<ContextMenu.Item
|
||||
label="Details"
|
||||
keybind={keybind([ModifierKeys.Control], ['I'])}
|
||||
// icon={Sidebar}
|
||||
onClick={() => (getExplorerStore().showInspector = true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Rename = () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
const keybind = useKeybindFactory();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{explorerStore.layoutMode !== 'media' && (
|
||||
<ContextMenu.Item
|
||||
label="Rename"
|
||||
keybind={keybind([], ['Enter'])}
|
||||
onClick={() => explorerView.setIsRenaming(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RevealInNativeExplorer = (props: { locationId: number } | { filePath: FilePath }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = useKeybindFactory();
|
||||
const { revealItems } = usePlatform();
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const osFileBrowserName = useMemo(() => {
|
||||
const lookup: Record<string, string> = {
|
||||
macOS: 'Finder',
|
||||
windows: 'Explorer'
|
||||
};
|
||||
|
||||
return lookup[os] ?? 'file manager';
|
||||
}, [os]);
|
||||
|
||||
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 Deselect = () => {
|
||||
const { cutCopyState } = useExplorerStore();
|
||||
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Deselect"
|
||||
hidden={!cutCopyState.active}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Share = () => {
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
label="Share"
|
||||
icon={ShareIcon}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
19
interface/app/$libraryId/Explorer/ContextMenu/index.tsx
Normal file
19
interface/app/$libraryId/Explorer/ContextMenu/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
export * as SharedItems from './SharedItems';
|
||||
export * as FilePathItems from './FilePath/Items';
|
||||
export * as ObjectItems from './Object/Items';
|
||||
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import FilePathCM from "./FilePath"
|
||||
import ObjectCM from "./Object"
|
||||
import LocationCM from "./Location"
|
||||
|
||||
export default ({ item }: { item: ExplorerItem }) => {
|
||||
switch (item.type) {
|
||||
case "Path":
|
||||
return <FilePathCM data={item} />
|
||||
case "Object":
|
||||
return <ObjectCM data={item} />
|
||||
case "Location":
|
||||
return <LocationCM data={item} />
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import { ReactNode } from 'react';
|
|||
import DismissibleNotice from '~/components/DismissibleNotice';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import { dismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
|
||||
import { ExplorerLayoutMode, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { ExplorerLayoutMode, useExplorerStore } from './store';
|
||||
|
||||
const MediaViewIcon = () => {
|
||||
const isDark = useIsDark();
|
||||
|
|
|
@ -1,407 +0,0 @@
|
|||
import {
|
||||
ArrowBendUpRight,
|
||||
Copy,
|
||||
FileX,
|
||||
Image,
|
||||
Package,
|
||||
Plus,
|
||||
Scissors,
|
||||
Share,
|
||||
TagSimple,
|
||||
Trash,
|
||||
TrashSimple
|
||||
} from 'phosphor-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ExplorerItem,
|
||||
ObjectKind,
|
||||
getItemFilePath,
|
||||
getItemObject,
|
||||
useLibraryContext,
|
||||
useLibraryMutation
|
||||
} from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { getExplorerStore, useExplorerStore, useOperatingSystem } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
import AssignTagMenuItems from '../AssignTagMenuItems';
|
||||
import { OpenInNativeExplorer } from '../ContextMenu';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { useExplorerSearchParams } from '../util';
|
||||
import OpenWith from './ContextMenu/OpenWith';
|
||||
// import DecryptDialog from './DecryptDialog';
|
||||
import DeleteDialog from './DeleteDialog';
|
||||
// import EncryptDialog from './EncryptDialog';
|
||||
import EraseDialog from './EraseDialog';
|
||||
|
||||
interface Props {
|
||||
data?: ExplorerItem;
|
||||
}
|
||||
|
||||
export default ({ data }: Props) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const location = useLocation();
|
||||
const objectData = data ? getItemObject(data) : null;
|
||||
const explorerView = useExplorerViewContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const [{ path: currentPath }] = useExplorerSearchParams();
|
||||
const { cutCopyState, showInspector, ...store } = useExplorerStore();
|
||||
|
||||
const isLocation =
|
||||
location.pathname.includes('/location/') && explorerStore.layoutMode !== 'media';
|
||||
|
||||
// const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
|
||||
// const mountedKeys = useLibraryQuery(['keys.listMounted']);
|
||||
// const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0;
|
||||
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
|
||||
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const objectId = data.type == 'Path' ? data.item.object_id : null;
|
||||
const locationId = store.locationId ?? getItemFilePath(data)?.location_id;
|
||||
const objectDateAccessed =
|
||||
data.type == 'Path' ? data.item.object && data.item.object.date_accessed : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenOrDownloadOptions data={data} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{!showInspector && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
label="Details"
|
||||
keybind={keybind([ModifierKeys.Control], ['I'])}
|
||||
// icon={Sidebar}
|
||||
onClick={() => (getExplorerStore().showInspector = true)}
|
||||
/>
|
||||
<ContextMenu.Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<OpenInNativeExplorer />
|
||||
|
||||
<ContextMenu.Item
|
||||
hidden={explorerStore.layoutMode === 'media'}
|
||||
label="Rename"
|
||||
keybind={keybind([], ['Enter'])}
|
||||
onClick={() => explorerView.setIsRenaming(true)}
|
||||
/>
|
||||
|
||||
{objectId && objectDateAccessed && (
|
||||
<ContextMenu.Item
|
||||
label="Remove from recents"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeFromRecents.mutateAsync([objectId]);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to remove file from recents, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{locationId && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
hidden={!isLocation}
|
||||
label="Cut"
|
||||
keybind={keybind([ModifierKeys.Control], ['X'])}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: currentPath ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathId: data.item.id,
|
||||
actionType: 'Cut',
|
||||
active: true
|
||||
};
|
||||
}}
|
||||
icon={Scissors}
|
||||
/>
|
||||
|
||||
<ContextMenu.Item
|
||||
hidden={!isLocation}
|
||||
label="Copy"
|
||||
keybind={keybind([ModifierKeys.Control], ['C'])}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: currentPath ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathId: data.item.id,
|
||||
actionType: 'Copy',
|
||||
active: true
|
||||
};
|
||||
}}
|
||||
icon={Copy}
|
||||
/>
|
||||
|
||||
<ContextMenu.Item
|
||||
hidden={!isLocation}
|
||||
label="Duplicate"
|
||||
keybind={keybind([ModifierKeys.Control], ['D'])}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: locationId,
|
||||
sources_file_path_ids: [data.item.id],
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: currentPath ?? '/',
|
||||
target_file_name_suffix: ' copy'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to duplcate file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenu.Item
|
||||
label="Deselect"
|
||||
hidden={!(cutCopyState.active && isLocation)}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<ContextMenu.Item
|
||||
label="Share"
|
||||
icon={Share}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{objectData && (
|
||||
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objectId={objectData.id} />
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
{/* <ContextMenu.Item
|
||||
label="Encrypt"
|
||||
icon={LockSimple}
|
||||
keybind="⌘E"
|
||||
onClick={() => {
|
||||
if (keyManagerUnlocked && hasMountedKeys) {
|
||||
dialogManager.create((dp) => (
|
||||
<EncryptDialog
|
||||
{...dp}
|
||||
location_id={store.locationId!}
|
||||
path_id={data.item.id}
|
||||
/>
|
||||
));
|
||||
} else if (!keyManagerUnlocked) {
|
||||
showAlertDialog({
|
||||
title: 'Key manager locked',
|
||||
value: 'The key manager is currently locked. Please unlock it and try again.'
|
||||
});
|
||||
} else if (!hasMountedKeys) {
|
||||
showAlertDialog({
|
||||
title: 'No mounted keys',
|
||||
value: 'No mounted keys were found. Please mount a key and try again.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/> */}
|
||||
{/* 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"
|
||||
onClick={() => {
|
||||
if (keyManagerUnlocked) {
|
||||
dialogManager.create((dp) => (
|
||||
<DecryptDialog
|
||||
{...dp}
|
||||
location_id={store.locationId!}
|
||||
path_id={data.item.id}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
showAlertDialog({
|
||||
title: 'Key manager locked',
|
||||
value: 'The key manager is currently locked. Please unlock it and try again.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/> */}
|
||||
<ContextMenu.Item
|
||||
label="Compress"
|
||||
icon={Package}
|
||||
keybind={keybind([ModifierKeys.Control], ['B'])}
|
||||
disabled
|
||||
/>
|
||||
{[ObjectKind.Image, ObjectKind.Video].includes(objectData?.kind as ObjectKind) && (
|
||||
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
|
||||
{(() => {
|
||||
switch (objectData?.kind) {
|
||||
case ObjectKind.Image:
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Item label="PNG" disabled />
|
||||
<ContextMenu.Item label="WebP" disabled />
|
||||
<ContextMenu.Item label="Gif" disabled />
|
||||
</>
|
||||
);
|
||||
case ObjectKind.Video:
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Item label="MP4" disabled />
|
||||
<ContextMenu.Item label="MOV" disabled />
|
||||
<ContextMenu.Item label="AVI" disabled />
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
|
||||
{locationId != null && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
hidden={!isLocation}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fullRescan.mutateAsync(locationId);
|
||||
} 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: currentPath ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to generate thumbnails, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Regen Thumbnails"
|
||||
icon={Image}
|
||||
/>
|
||||
<ContextMenu.Item
|
||||
variant="danger"
|
||||
label="Secure delete"
|
||||
icon={TrashSimple}
|
||||
onClick={() =>
|
||||
dialogManager.create((dp) => (
|
||||
<EraseDialog
|
||||
{...dp}
|
||||
location_id={locationId}
|
||||
path_id={data.item.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.SubMenu>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{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={data.item.id} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenOrDownloadOptions = (props: { data: ExplorerItem }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const { openFilePath } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
const filePath = getItemFilePath(props.data);
|
||||
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
if (os === 'browser') return <ContextMenu.Item label="Download" />;
|
||||
else
|
||||
return (
|
||||
<>
|
||||
{filePath && (
|
||||
<>
|
||||
{openFilePath && (
|
||||
<ContextMenu.Item
|
||||
label="Open"
|
||||
keybind={keybind([ModifierKeys.Control], ['O'])}
|
||||
onClick={async () => {
|
||||
if (props.data.type === 'Path' && props.data.item.object_id)
|
||||
updateAccessTime
|
||||
.mutateAsync(props.data.item.object_id)
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePath(library.uuid, [filePath.id]);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to open file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<OpenWith filePath={filePath} />
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
label="Quick view"
|
||||
keybind={keybind([], [' '])}
|
||||
onClick={() => (getExplorerStore().quickViewObject = props.data)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -3,15 +3,12 @@ import clsx from 'clsx';
|
|||
import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ExplorerItem, getItemLocation, useLibraryContext } from '@sd/client';
|
||||
import { PDFViewer } from '~/components';
|
||||
import {
|
||||
getExplorerStore,
|
||||
useCallbackToWatchResize,
|
||||
useExplorerItemData,
|
||||
useExplorerStore,
|
||||
useIsDark
|
||||
} from '~/hooks';
|
||||
import { useCallbackToWatchResize, useIsDark } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { pdfViewerEnabled } from '~/util/pdfViewer';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { getExplorerStore } from '../store';
|
||||
import { useExplorerItemData } from '../util';
|
||||
import classes from './Thumb.module.scss';
|
||||
|
||||
interface ThumbnailProps {
|
||||
|
@ -123,7 +120,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
const [src, setSrc] = useState<null | string>(null);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [thumbType, setThumbType] = useState(ThumbType.Icon);
|
||||
const { locationId: explorerLocationId } = useExplorerStore();
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
|
||||
// thus avoiding improper thumb types changes
|
||||
|
@ -152,7 +149,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
locationId: itemLocationId,
|
||||
thumbnailKey
|
||||
} = itemData;
|
||||
const locationId = itemLocationId ?? explorerLocationId;
|
||||
const locationId =
|
||||
itemLocationId ?? (parent?.type === 'Location' ? parent.location.id : null);
|
||||
|
||||
switch (thumbType) {
|
||||
case ThumbType.Original:
|
||||
if (locationId) {
|
||||
|
@ -183,15 +182,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
if (isDir !== null) setSrc(getIcon(kind, isDark, extension, isDir));
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
props.data.item.id,
|
||||
isDark,
|
||||
library.uuid,
|
||||
itemData,
|
||||
platform,
|
||||
thumbType,
|
||||
explorerLocationId
|
||||
]);
|
||||
}, [props.data.item.id, isDark, library.uuid, itemData, platform, thumbType, parent]);
|
||||
|
||||
const onLoad = () => setLoaded(true);
|
||||
|
|
@ -17,9 +17,9 @@ import {
|
|||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui';
|
||||
import { useExplorerStore, useIsDark } from '~/hooks';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import AssignTagMenuItems from '../AssignTagMenuItems';
|
||||
import FileThumb from '../File/Thumb';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
import Note from './Note';
|
||||
|
||||
|
@ -47,7 +47,6 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro
|
|||
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;
|
||||
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import { RadixCheckbox, Select, SelectOption, Slider, tw } from '@sd/ui';
|
||||
import { type SortOrder, SortOrderSchema } from '~/app/route-schemas';
|
||||
import {
|
||||
FilePathSearchOrderingKeys,
|
||||
getExplorerConfigStore,
|
||||
getExplorerStore,
|
||||
useExplorerConfigStore,
|
||||
useExplorerStore
|
||||
} from '~/hooks';
|
||||
import { getExplorerConfigStore, useExplorerConfigStore } from './config';
|
||||
import { FilePathSearchOrderingKeys, getExplorerStore, useExplorerStore } from './store';
|
||||
|
||||
const Heading = tw.div`text-ink-dull text-xs font-semibold`;
|
||||
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;
|
||||
|
|
165
interface/app/$libraryId/Explorer/ParentContextMenu.tsx
Normal file
165
interface/app/$libraryId/Explorer/ParentContextMenu.tsx
Normal file
|
@ -0,0 +1,165 @@
|
|||
import { Clipboard, FileX, Image, Plus, Repeat, Share, ShieldCheck } from 'phosphor-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu as CM, ModifierKeys } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
import { useExplorerContext } from './Context';
|
||||
import { SharedItems } from './ContextMenu';
|
||||
import { getExplorerStore, useExplorerStore } from './store';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
export default (props: PropsWithChildren) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const [{ path: currentPath }] = useExplorerSearchParams();
|
||||
const { cutCopyState } = useExplorerStore();
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
const objectValidator = useLibraryMutation('jobs.objectValidator');
|
||||
const rescanLocation = useLibraryMutation('locations.fullRescan');
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const cutFiles = useLibraryMutation('files.cutFiles');
|
||||
|
||||
return (
|
||||
<CM.Root trigger={props.children}>
|
||||
{parent?.type === 'Location' && (
|
||||
<SharedItems.RevealInNativeExplorer locationId={parent.location.id} />
|
||||
)}
|
||||
|
||||
<CM.Item
|
||||
label="Share"
|
||||
icon={Share}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
{parent?.type === 'Location' && (
|
||||
<>
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rescanLocation.mutateAsync(parent.location.id);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to re-index location, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Re-index"
|
||||
icon={Repeat}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind={keybind([ModifierKeys.Control], ['V'])}
|
||||
hidden={!cutCopyState.active}
|
||||
onClick={async () => {
|
||||
const path = currentPath ?? '/';
|
||||
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
|
||||
cutCopyState;
|
||||
const sameLocation =
|
||||
sourceLocationId === parent.location.id &&
|
||||
sourceParentPath === path;
|
||||
try {
|
||||
if (actionType == 'Copy') {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path,
|
||||
target_file_name_suffix: sameLocation ? ' copy' : null
|
||||
});
|
||||
} else if (sameLocation) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `File already exists in this location`
|
||||
});
|
||||
} else {
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={Clipboard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
hidden={!cutCopyState.active}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
{parent?.type === 'Location' && (
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await generateThumbsForLocation.mutateAsync({
|
||||
id: parent.location.id,
|
||||
path: currentPath ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to generate thumbanails, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Regen Thumbnails"
|
||||
icon={Image}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
objectValidator.mutateAsync({
|
||||
id: parent.location.id,
|
||||
path: currentPath ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to generate checksum, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
label="Generate Checksums"
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</CM.SubMenu>
|
||||
)}
|
||||
</CM.Root>
|
||||
);
|
||||
};
|
26
interface/app/$libraryId/Explorer/QuickPreview/Context.tsx
Normal file
26
interface/app/$libraryId/Explorer/QuickPreview/Context.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { PropsWithChildren, RefObject, createContext, useContext, useRef } from 'react';
|
||||
|
||||
interface QuickPreviewContext {
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const QuickPreviewContext = createContext<QuickPreviewContext | null>(null);
|
||||
|
||||
export const QuickPreviewContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<QuickPreviewContext.Provider value={{ ref }}>
|
||||
{children}
|
||||
<div ref={ref} />
|
||||
</QuickPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useQuickPreviewContext = () => {
|
||||
const context = useContext(QuickPreviewContext);
|
||||
|
||||
if (!context) throw new Error('QuickPreviewContext.Provider not found!');
|
||||
|
||||
return context;
|
||||
};
|
|
@ -5,8 +5,8 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { subscribeKey } from 'valtio/utils';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import { getExplorerStore } from '~/hooks';
|
||||
import FileThumb from './File/Thumb';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { getExplorerStore } from '../store';
|
||||
|
||||
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
||||
const AnimatedDialogContent = animated(Dialog.Content);
|
131
interface/app/$libraryId/Explorer/QuickPreview/index.tsx
Normal file
131
interface/app/$libraryId/Explorer/QuickPreview/index.tsx
Normal file
|
@ -0,0 +1,131 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -11,15 +11,16 @@ import {
|
|||
} from 'phosphor-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRspcLibraryContext } from '@sd/client';
|
||||
import OptionsPanel from '~/app/$libraryId/Explorer/OptionsPanel';
|
||||
import { KeyManager } from '~/app/$libraryId/KeyManager';
|
||||
import { TOP_BAR_ICON_STYLE, ToolOption } from '~/app/$libraryId/TopBar/TopBarOptions';
|
||||
import { getExplorerStore, useExplorerStore } from './useExplorerStore';
|
||||
import { KeyManager } from '../KeyManager';
|
||||
import TopBarOptions, { TOP_BAR_ICON_STYLE, ToolOption } from '../TopBar/TopBarOptions';
|
||||
import { useExplorerContext } from './Context';
|
||||
import OptionsPanel from './OptionsPanel';
|
||||
import { getExplorerStore, useExplorerStore } from './store';
|
||||
|
||||
export const useExplorerTopBarOptions = () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const explorerViewOptions: ToolOption[] = [
|
||||
const viewOptions: ToolOption[] = [
|
||||
{
|
||||
toolTipLabel: 'Grid view',
|
||||
icon: <SquaresFour className={TOP_BAR_ICON_STYLE} />,
|
||||
|
@ -50,7 +51,7 @@ export const useExplorerTopBarOptions = () => {
|
|||
}
|
||||
];
|
||||
|
||||
const explorerControlOptions: ToolOption[] = [
|
||||
const controlOptions: ToolOption[] = [
|
||||
{
|
||||
toolTipLabel: 'Explorer display',
|
||||
icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
|
||||
|
@ -81,7 +82,9 @@ export const useExplorerTopBarOptions = () => {
|
|||
|
||||
const { client } = useRspcLibraryContext();
|
||||
|
||||
const explorerToolOptions: ToolOption[] = [
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
const toolOptions = [
|
||||
{
|
||||
toolTipLabel: 'Key Manager',
|
||||
icon: <Key className={TOP_BAR_ICON_STYLE} />,
|
||||
|
@ -102,28 +105,40 @@ export const useExplorerTopBarOptions = () => {
|
|||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
},
|
||||
{
|
||||
parent?.type === 'Location' && {
|
||||
toolTipLabel: 'Reload',
|
||||
onClick: () => {
|
||||
if (explorerStore.locationId) {
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: explorerStore.locationId,
|
||||
sub_path: ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
}
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: parent.location.id,
|
||||
sub_path: ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
},
|
||||
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
}
|
||||
];
|
||||
].filter(Boolean) as ToolOption[];
|
||||
|
||||
return { explorerViewOptions, explorerControlOptions, explorerToolOptions };
|
||||
return {
|
||||
viewOptions,
|
||||
controlOptions,
|
||||
toolOptions
|
||||
};
|
||||
};
|
||||
|
||||
export const DefaultTopBarOptions = () => {
|
||||
const options = useExplorerTopBarOptions();
|
||||
|
||||
return (
|
||||
<TopBarOptions
|
||||
options={[options.viewOptions, options.toolOptions, options.controlOptions]}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -3,10 +3,10 @@ import clsx from 'clsx';
|
|||
import { memo } from 'react';
|
||||
import { ExplorerItem, bytesToNumber, getItemFilePath, getItemLocation } from '@sd/client';
|
||||
import GridList from '~/components/GridList';
|
||||
import { isCut, useExplorerStore } from '~/hooks';
|
||||
import { ViewItem } from '.';
|
||||
import FileThumb from '../File/Thumb';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { isCut, useExplorerStore } from '../store';
|
||||
import RenamableItemText from './RenamableItemText';
|
||||
|
||||
interface GridViewItemProps {
|
||||
|
|
|
@ -26,17 +26,12 @@ import {
|
|||
getItemObject,
|
||||
isPath
|
||||
} from '@sd/client';
|
||||
import {
|
||||
FilePathSearchOrderingKeys,
|
||||
getExplorerStore,
|
||||
isCut,
|
||||
useExplorerStore,
|
||||
useScrolled
|
||||
} from '~/hooks';
|
||||
import { useScrolled } from '~/hooks';
|
||||
import { ViewItem } from '.';
|
||||
import FileThumb from '../File/Thumb';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { InfoPill } from '../Inspector';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { FilePathSearchOrderingKeys, getExplorerStore, isCut, useExplorerStore } from '../store';
|
||||
import RenamableItemText from './RenamableItemText';
|
||||
|
||||
interface ListViewItemProps {
|
||||
|
|
|
@ -4,10 +4,10 @@ import { memo } from 'react';
|
|||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import GridList from '~/components/GridList';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { ViewItem } from '.';
|
||||
import FileThumb from '../File/Thumb';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
|
||||
interface MediaViewItemProps {
|
||||
data: ExplorerItem;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
/* eslint-disable no-case-declarations */
|
||||
import clsx from 'clsx';
|
||||
import { ExplorerItem, getItemFilePath, getItemLocation } from '@sd/client';
|
||||
import { useExplorerStore } from '~/hooks';
|
||||
import { RenameLocationTextBox, RenamePathTextBox } from '../File/RenameTextBox';
|
||||
import { RenameLocationTextBox, RenamePathTextBox } from '../FilePath/RenameTextBox';
|
||||
import { useExplorerStore } from '../store';
|
||||
|
||||
export default function RenamableItemText(props: {
|
||||
item: ExplorerItem;
|
||||
|
|
|
@ -1,309 +1,301 @@
|
|||
import clsx from 'clsx';
|
||||
import { Columns, GridFour, Icon, MonitorPlay, Rows } from 'phosphor-react';
|
||||
import {
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
isValidElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
isValidElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ExplorerItem,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getItemLocation,
|
||||
isPath,
|
||||
useLibraryContext,
|
||||
useLibraryMutation
|
||||
ExplorerItem,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getItemLocation,
|
||||
isPath,
|
||||
useLibraryContext,
|
||||
useLibraryMutation
|
||||
} from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import {
|
||||
ExplorerLayoutMode,
|
||||
getExplorerStore,
|
||||
useExplorerConfigStore,
|
||||
useOperatingSystem
|
||||
} from '~/hooks';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import CreateDialog from '../../settings/library/tags/CreateDialog';
|
||||
import {
|
||||
ExplorerViewContext,
|
||||
ExplorerViewSelection,
|
||||
ExplorerViewSelectionChange,
|
||||
ViewContext,
|
||||
useExplorerViewContext
|
||||
ExplorerViewContext,
|
||||
ExplorerViewSelection,
|
||||
ExplorerViewSelectionChange,
|
||||
ViewContext,
|
||||
useExplorerViewContext
|
||||
} from '../ViewContext';
|
||||
import { useExplorerConfigStore } from '../config';
|
||||
import { ExplorerLayoutMode, getExplorerStore } from '../store';
|
||||
import GridView from './GridView';
|
||||
import ListView from './ListView';
|
||||
import MediaView from './MediaView';
|
||||
|
||||
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
const { library } = useLibraryContext();
|
||||
const navigate = useNavigate();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const { library } = useLibraryContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { openFilePath } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
const filePath = getItemFilePath(data);
|
||||
const location = getItemLocation(data);
|
||||
const { openFilePaths } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
const filePath = getItemFilePath(data);
|
||||
const location = getItemLocation(data);
|
||||
|
||||
const explorerConfig = useExplorerConfigStore();
|
||||
const explorerConfig = useExplorerConfigStore();
|
||||
|
||||
const onDoubleClick = () => {
|
||||
if (location) {
|
||||
navigate({
|
||||
pathname: `/${library.uuid}/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 (
|
||||
openFilePath &&
|
||||
filePath &&
|
||||
explorerConfig.openOnDoubleClick &&
|
||||
!explorerView.isRenaming
|
||||
) {
|
||||
if (data.type === 'Path' && data.item.object_id) {
|
||||
updateAccessTime.mutate(data.item.object_id);
|
||||
}
|
||||
const onDoubleClick = () => {
|
||||
if (location) {
|
||||
navigate({
|
||||
pathname: `/${library.uuid}/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);
|
||||
}
|
||||
|
||||
openFilePath(library.uuid, [filePath.id]);
|
||||
} else {
|
||||
const { kind } = getExplorerItemData(data);
|
||||
openFilePaths(library.uuid, [filePath.id]);
|
||||
} else {
|
||||
const { kind } = getExplorerItemData(data);
|
||||
|
||||
if (['Video', 'Image', 'Audio'].includes(kind)) {
|
||||
getExplorerStore().quickViewObject = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
if (['Video', 'Image', 'Audio'].includes(kind)) {
|
||||
getExplorerStore().quickViewObject = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div onDoubleClick={onDoubleClick} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
disabled={!explorerView.contextMenu}
|
||||
asChild={false}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div onDoubleClick={onDoubleClick} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
disabled={!explorerView.contextMenu}
|
||||
asChild={false}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ExplorerViewProps<T extends ExplorerViewSelection = ExplorerViewSelection>
|
||||
extends Omit<
|
||||
ExplorerViewContext<T>,
|
||||
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
|
||||
> {
|
||||
layout: ExplorerLayoutMode;
|
||||
className?: string;
|
||||
emptyNotice?: JSX.Element | { icon?: Icon | ReactNode; message?: ReactNode } | null;
|
||||
extends Omit<
|
||||
ExplorerViewContext<T>,
|
||||
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
|
||||
> {
|
||||
layout: ExplorerLayoutMode;
|
||||
className?: string;
|
||||
emptyNotice?: JSX.Element | { icon?: Icon | ReactNode; message?: ReactNode } | null;
|
||||
}
|
||||
|
||||
export default memo(
|
||||
<T extends ExplorerViewSelection>({
|
||||
layout,
|
||||
className,
|
||||
emptyNotice,
|
||||
...contextProps
|
||||
}: ExplorerViewProps<T>) => {
|
||||
const os = useOperatingSystem();
|
||||
const { library } = useLibraryContext();
|
||||
const { openFilePath } = usePlatform();
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const selectedItem = useMemo(
|
||||
() =>
|
||||
contextProps.items?.find(
|
||||
(item) =>
|
||||
item.item.id ===
|
||||
(Array.isArray(contextProps.selected)
|
||||
? contextProps.selected[0]
|
||||
: contextProps.selected)
|
||||
),
|
||||
[contextProps.items, contextProps.selected]
|
||||
);
|
||||
const itemPath = selectedItem ? getItemFilePath(selectedItem) : null;
|
||||
<T extends ExplorerViewSelection>({
|
||||
layout,
|
||||
className,
|
||||
emptyNotice,
|
||||
...contextProps
|
||||
}: ExplorerViewProps<T>) => {
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const handleNewTag = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (
|
||||
itemPath == null ||
|
||||
event.key.toUpperCase() !== 'N' ||
|
||||
!event.getModifierState(
|
||||
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
|
||||
)
|
||||
)
|
||||
return;
|
||||
const emptyNoticeIcon = (icon?: Icon) => {
|
||||
const Icon =
|
||||
icon ??
|
||||
{
|
||||
grid: GridFour,
|
||||
media: MonitorPlay,
|
||||
columns: Columns,
|
||||
rows: Rows
|
||||
}[layout];
|
||||
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
|
||||
},
|
||||
[os, itemPath]
|
||||
);
|
||||
return <Icon size={100} opacity={0.3} />;
|
||||
};
|
||||
|
||||
const handleOpenShortcut = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (
|
||||
itemPath == null ||
|
||||
openFilePath == null ||
|
||||
event.key.toUpperCase() !== 'O' ||
|
||||
!event.getModifierState(
|
||||
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
|
||||
)
|
||||
)
|
||||
return;
|
||||
useKeyDownHandlers({
|
||||
items: contextProps.items,
|
||||
selected: contextProps.selected,
|
||||
isRenaming
|
||||
});
|
||||
|
||||
try {
|
||||
await openFilePath(library.uuid, [itemPath.id]);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Couldn't open file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
},
|
||||
[os, itemPath, library.uuid, openFilePath]
|
||||
);
|
||||
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
|
||||
}
|
||||
>
|
||||
{layout === 'grid' && <GridView />}
|
||||
{layout === 'rows' && <ListView />}
|
||||
{layout === 'media' && <MediaView />}
|
||||
</ViewContext.Provider>
|
||||
) : emptyNotice === null ? null : isValidElement(emptyNotice) ? (
|
||||
emptyNotice
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
|
||||
{emptyNotice && 'icon' in emptyNotice
|
||||
? isValidElement(emptyNotice.icon)
|
||||
? emptyNotice.icon
|
||||
: emptyNoticeIcon(emptyNotice.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
const handleOpenQuickPreview = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key !== ' ') return;
|
||||
if (!getExplorerStore().quickViewObject) {
|
||||
if (selectedItem) {
|
||||
getExplorerStore().quickViewObject = selectedItem;
|
||||
}
|
||||
} else {
|
||||
getExplorerStore().quickViewObject = null;
|
||||
}
|
||||
},
|
||||
[selectedItem]
|
||||
);
|
||||
|
||||
const handleExplorerShortcut = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key.toUpperCase() !== 'I' ||
|
||||
!event.getModifierState(
|
||||
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
getExplorerStore().showInspector = !getExplorerStore().showInspector;
|
||||
},
|
||||
[os]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlers = [
|
||||
handleNewTag,
|
||||
handleOpenShortcut,
|
||||
handleOpenQuickPreview,
|
||||
handleExplorerShortcut
|
||||
];
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isRenaming) return;
|
||||
for (const handler of handlers) handler(event);
|
||||
};
|
||||
document.body.addEventListener('keydown', handler);
|
||||
return () => document.body.removeEventListener('keydown', handler);
|
||||
}, [
|
||||
isRenaming,
|
||||
handleNewTag,
|
||||
handleOpenShortcut,
|
||||
handleOpenQuickPreview,
|
||||
handleExplorerShortcut
|
||||
]);
|
||||
|
||||
const emptyNoticeIcon = (icon?: Icon) => {
|
||||
let Icon = icon;
|
||||
|
||||
if (!Icon) {
|
||||
switch (layout) {
|
||||
case 'grid':
|
||||
Icon = GridFour;
|
||||
break;
|
||||
case 'media':
|
||||
Icon = MonitorPlay;
|
||||
break;
|
||||
case 'columns':
|
||||
Icon = Columns;
|
||||
break;
|
||||
case 'rows':
|
||||
Icon = Rows;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return <Icon size={100} opacity={0.3} />;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
>
|
||||
{layout === 'grid' && <GridView />}
|
||||
{layout === 'rows' && <ListView />}
|
||||
{layout === 'media' && <MediaView />}
|
||||
</ViewContext.Provider>
|
||||
) : emptyNotice === null ? null : isValidElement(emptyNotice) ? (
|
||||
emptyNotice
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
|
||||
{emptyNotice && 'icon' in emptyNotice
|
||||
? isValidElement(emptyNotice.icon)
|
||||
? emptyNotice.icon
|
||||
: emptyNoticeIcon(emptyNotice.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
<p className="mt-5 text-xs">
|
||||
{emptyNotice && 'message' in emptyNotice
|
||||
? emptyNotice.message
|
||||
: 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<p className="mt-5 text-xs">
|
||||
{emptyNotice && 'message' in emptyNotice
|
||||
? emptyNotice.message
|
||||
: 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
) as <T extends ExplorerViewSelection>(props: ExplorerViewProps<T>) => JSX.Element;
|
||||
|
||||
const useKeyDownHandlers = ({
|
||||
items,
|
||||
selected,
|
||||
isRenaming
|
||||
}: Pick<ExplorerViewProps, 'items' | 'selected'> & { isRenaming: boolean }) => {
|
||||
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) => {
|
||||
if (
|
||||
itemPath == null ||
|
||||
event.key.toUpperCase() !== 'N' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
)
|
||||
return;
|
||||
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
|
||||
},
|
||||
[os, itemPath]
|
||||
);
|
||||
|
||||
const handleOpenShortcut = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (
|
||||
itemPath == null ||
|
||||
openFilePaths == null ||
|
||||
event.key.toUpperCase() !== 'O' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await openFilePaths(library.uuid, [itemPath.id]);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Couldn't open file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
},
|
||||
[os, itemPath, library.uuid, openFilePaths]
|
||||
);
|
||||
|
||||
const handleOpenQuickPreview = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key !== ' ') return;
|
||||
if (!getExplorerStore().quickViewObject) {
|
||||
if (selectedItem) {
|
||||
getExplorerStore().quickViewObject = selectedItem;
|
||||
}
|
||||
} else {
|
||||
getExplorerStore().quickViewObject = null;
|
||||
}
|
||||
},
|
||||
[selectedItem]
|
||||
);
|
||||
|
||||
const handleExplorerShortcut = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key.toUpperCase() !== 'I' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
)
|
||||
return;
|
||||
|
||||
getExplorerStore().showInspector = !getExplorerStore().showInspector;
|
||||
},
|
||||
[os]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlers = [
|
||||
handleNewTag,
|
||||
handleOpenShortcut,
|
||||
handleOpenQuickPreview,
|
||||
handleExplorerShortcut
|
||||
];
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isRenaming) return;
|
||||
for (const handler of handlers) handler(event);
|
||||
};
|
||||
document.body.addEventListener('keydown', handler);
|
||||
return () => document.body.removeEventListener('keydown', handler);
|
||||
}, [
|
||||
isRenaming,
|
||||
handleNewTag,
|
||||
handleOpenShortcut,
|
||||
handleOpenQuickPreview,
|
||||
handleExplorerShortcut
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import { FolderNotchOpen } from 'phosphor-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ExplorerItem, useLibrarySubscription } from '@sd/client';
|
||||
import { useExplorerStore, useKeyDeleteFile } from '~/hooks';
|
||||
import { useKeyDeleteFile } from '~/hooks';
|
||||
import { TOP_BAR_HEIGHT } from '../TopBar';
|
||||
import ExplorerContextMenu from './ContextMenu';
|
||||
import { useExplorerContext } from './Context';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import DismissibleNotice from './DismissibleNotice';
|
||||
import ContextMenu from './File/ContextMenu';
|
||||
import { Inspector } from './Inspector';
|
||||
import ExplorerContextMenu from './ParentContextMenu';
|
||||
import { QuickPreview } from './QuickPreview';
|
||||
import { useQuickPreviewContext } from './QuickPreview/Context';
|
||||
import View, { ExplorerViewProps } from './View';
|
||||
import { useExplorerStore } from './store';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
interface Props {
|
||||
|
@ -47,10 +52,17 @@ export default function Explorer(props: Props) {
|
|||
}
|
||||
});
|
||||
|
||||
useKeyDeleteFile(selectedItem || null, explorerStore.locationId);
|
||||
const ctx = useExplorerContext();
|
||||
|
||||
useKeyDeleteFile(
|
||||
selectedItem || null,
|
||||
ctx.parent?.type === 'Location' ? ctx.parent.location.id : null
|
||||
);
|
||||
|
||||
useEffect(() => setSelectedItemId(undefined), [path]);
|
||||
|
||||
const quickPreviewCtx = useQuickPreviewContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExplorerContextMenu>
|
||||
|
@ -72,7 +84,7 @@ export default function Explorer(props: Props) {
|
|||
rowsBeforeLoadMore={5}
|
||||
selected={selectedItemId}
|
||||
onSelectedChange={setSelectedItemId}
|
||||
contextMenu={<ContextMenu data={selectedItem} />}
|
||||
contextMenu={selectedItem && <ContextMenu item={selectedItem} />}
|
||||
emptyNotice={
|
||||
props.emptyNotice || {
|
||||
icon: FolderNotchOpen,
|
||||
|
@ -84,6 +96,9 @@ export default function Explorer(props: Props) {
|
|||
</div>
|
||||
</ExplorerContextMenu>
|
||||
|
||||
{quickPreviewCtx.ref.current &&
|
||||
createPortal(<QuickPreview />, quickPreviewCtx.ref.current)}
|
||||
|
||||
{explorerStore.showInspector && (
|
||||
<Inspector
|
||||
data={selectedItem}
|
||||
|
|
86
interface/app/$libraryId/Explorer/store.ts
Normal file
86
interface/app/$libraryId/Explorer/store.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { proxy, useSnapshot } from 'valtio';
|
||||
import { proxySet } from 'valtio/utils';
|
||||
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering, resetStore } from '@sd/client';
|
||||
import { SortOrder } from '~/app/route-schemas';
|
||||
|
||||
type Join<K, P> = K extends string | number
|
||||
? P extends string | number
|
||||
? `${K}${'' extends P ? '' : '.'}${P}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type Leaves<T> = T extends object ? { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : '';
|
||||
|
||||
type UnionKeys<T> = T extends any ? Leaves<T> : never;
|
||||
|
||||
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
|
||||
|
||||
export enum ExplorerKind {
|
||||
Location,
|
||||
Tag,
|
||||
Space
|
||||
}
|
||||
|
||||
export type CutCopyType = 'Cut' | 'Copy';
|
||||
|
||||
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
|
||||
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
|
||||
|
||||
const state = {
|
||||
layoutMode: 'grid' as ExplorerLayoutMode,
|
||||
gridItemSize: 110,
|
||||
listItemSize: 40,
|
||||
showBytesInGridView: true,
|
||||
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
|
||||
},
|
||||
quickViewObject: null as ExplorerItem | null,
|
||||
mediaColumns: 8,
|
||||
mediaAspectSquare: false,
|
||||
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
|
||||
orderByDirection: 'Desc' as SortOrder,
|
||||
groupBy: 'none'
|
||||
};
|
||||
|
||||
export function flattenThumbnailKey(thumbKey: string[]) {
|
||||
return thumbKey.join('/');
|
||||
}
|
||||
|
||||
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
|
||||
const explorerStore = proxy({
|
||||
...state,
|
||||
reset: () => resetStore(explorerStore, state),
|
||||
addNewThumbnail: (thumbKey: string[]) => {
|
||||
explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey));
|
||||
},
|
||||
// this should be done when the explorer query is refreshed
|
||||
// prevents memory leak
|
||||
resetNewThumbnails: () => {
|
||||
explorerStore.newThumbnails.clear();
|
||||
}
|
||||
});
|
||||
|
||||
export function useExplorerStore() {
|
||||
return useSnapshot(explorerStore);
|
||||
}
|
||||
|
||||
export function getExplorerStore() {
|
||||
return explorerStore;
|
||||
}
|
||||
|
||||
export function isCut(id: number) {
|
||||
return (
|
||||
explorerStore.cutCopyState.active &&
|
||||
explorerStore.cutCopyState.actionType === 'Cut' &&
|
||||
explorerStore.cutCopyState.sourcePathId === id
|
||||
);
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import { useMemo } from 'react';
|
||||
import { FilePathSearchOrdering } from '@sd/client';
|
||||
import { ExplorerItem, FilePathSearchOrdering, getExplorerItemData } from '@sd/client';
|
||||
import { ExplorerParamsSchema } from '~/app/route-schemas';
|
||||
import { useExplorerStore, useZodSearchParams } from '~/hooks';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
import { flattenThumbnailKey, useExplorerStore } from './store';
|
||||
|
||||
export function useExplorerOrder(): FilePathSearchOrdering | undefined {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
@ -27,3 +28,22 @@ export function useExplorerOrder(): FilePathSearchOrdering | undefined {
|
|||
export function useExplorerSearchParams() {
|
||||
return useZodSearchParams(ExplorerParamsSchema);
|
||||
}
|
||||
|
||||
export function useExplorerItemData(explorerItem: ExplorerItem) {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const newThumbnail = !!(
|
||||
explorerItem.thumbnail_key &&
|
||||
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const itemData = getExplorerItemData(explorerItem);
|
||||
|
||||
if (!itemData.hasLocalThumbnail) {
|
||||
itemData.hasLocalThumbnail = newThumbnail;
|
||||
}
|
||||
|
||||
return itemData;
|
||||
}, [explorerItem, newThumbnail]);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { LibraryIdParamsSchema } from '~/app/route-schemas';
|
||||
import { useOperatingSystem, useZodRouteParams } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { QuickPreview } from '../Explorer/QuickPreview';
|
||||
import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context';
|
||||
import Sidebar from './Sidebar';
|
||||
import Toasts from './Toasts';
|
||||
|
||||
|
@ -50,12 +50,13 @@ const Layout = () => {
|
|||
<Sidebar />
|
||||
<div className="relative flex w-full overflow-hidden bg-app">
|
||||
{library ? (
|
||||
<LibraryContextProvider library={library}>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<QuickPreview />
|
||||
</LibraryContextProvider>
|
||||
<QuickPreviewContextProvider>
|
||||
<LibraryContextProvider library={library}>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</LibraryContextProvider>
|
||||
</QuickPreviewContextProvider>
|
||||
) : (
|
||||
<h1 className="p-4 text-white">
|
||||
Please select or create a library in the sidebar.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useLibraryContext,
|
||||
useLibraryQuery,
|
||||
|
@ -8,48 +8,45 @@ import {
|
|||
} from '@sd/client';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { Folder } from '~/components';
|
||||
import {
|
||||
getExplorerStore,
|
||||
useExplorerStore,
|
||||
useExplorerTopBarOptions,
|
||||
useZodRouteParams,
|
||||
useZodSearchParams
|
||||
} from '~/hooks';
|
||||
import { useZodRouteParams } from '~/hooks';
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
|
||||
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import TopBarOptions from '../TopBar/TopBarOptions';
|
||||
import LocationOptions from './LocationOptions';
|
||||
|
||||
export const Component = () => {
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
|
||||
useExplorerTopBarOptions();
|
||||
|
||||
const { data: location } = useLibraryQuery(['locations.get', locationId]);
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
|
||||
useLibrarySubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
sub_path: location?.path ?? '',
|
||||
sub_path: location.data?.path ?? '',
|
||||
location_id: locationId
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
|
||||
const explorerStore = getExplorerStore();
|
||||
|
||||
useEffect(() => {
|
||||
explorerStore.locationId = locationId;
|
||||
}, [explorerStore, locationId]);
|
||||
|
||||
const { items, loadMore } = useItems({ locationId });
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
parent: location.data
|
||||
? {
|
||||
type: 'Location',
|
||||
location: location.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="group flex flex-row items-center space-x-2">
|
||||
|
@ -58,21 +55,19 @@ export const Component = () => {
|
|||
<span className="max-w-[100px] truncate text-sm font-medium">
|
||||
{path && path?.length > 1
|
||||
? getLastSectionOfPath(path)
|
||||
: location?.name}
|
||||
: location.data?.name}
|
||||
</span>
|
||||
</span>
|
||||
{location && <LocationOptions location={location} path={path || ''} />}
|
||||
{location.data && (
|
||||
<LocationOptions location={location.data} path={path || ''} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<TopBarOptions
|
||||
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
|
||||
/>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
|
||||
<Explorer items={items} onLoadMore={loadMore} />
|
||||
</>
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -19,11 +19,8 @@ const OptionButton = tw(TopBarButton)`w-full gap-1 !px-1.5 !py-1`;
|
|||
export default function LocationOptions({ location, path }: { location: Location; path: string }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const _scanLocation = useLibraryMutation('locations.fullRescan');
|
||||
const scanLocation = () => _scanLocation.mutate(location.id);
|
||||
|
||||
const _regenThumbs = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
const regenThumbs = () => _regenThumbs.mutate({ id: location.id, path });
|
||||
const scanLocation = useLibraryMutation('locations.fullRescan');
|
||||
const regenThumbs = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
|
||||
const archiveLocation = () => alert('Not implemented');
|
||||
|
||||
|
@ -76,11 +73,13 @@ export default function LocationOptions({ location, path }: { location: Location
|
|||
</PopoverSection>
|
||||
<PopoverDivider />
|
||||
<PopoverSection>
|
||||
<OptionButton onClick={scanLocation}>
|
||||
<OptionButton onClick={() => scanLocation.mutate(location.id)}>
|
||||
<FolderDotted />
|
||||
Re-index
|
||||
</OptionButton>
|
||||
<OptionButton onClick={regenThumbs}>
|
||||
<OptionButton
|
||||
onClick={() => regenThumbs.mutate({ id: location.id, path })}
|
||||
>
|
||||
<Image />
|
||||
Regenerate Thumbs
|
||||
</OptionButton>
|
||||
|
|
|
@ -1,23 +1,30 @@
|
|||
import { Laptop, Node } from '@sd/assets/icons';
|
||||
import { Laptop } from '@sd/assets/icons';
|
||||
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
|
||||
import Explorer from '~/app/$libraryId/Explorer';
|
||||
import { TopBarPortal } from '~/app/$libraryId/TopBar/Portal';
|
||||
import TopBarOptions from '~/app/$libraryId/TopBar/TopBarOptions';
|
||||
import { NodeIdParamsSchema } from '~/app/route-schemas';
|
||||
import { useExplorerTopBarOptions, useZodRouteParams } from '~/hooks';
|
||||
import { useZodRouteParams } from '~/hooks';
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
const { id: nodeId } = useZodRouteParams(NodeIdParamsSchema);
|
||||
|
||||
const locations = useLibraryQuery(['nodes.listLocations', nodeId]);
|
||||
const query = useLibraryQuery(['nodes.listLocations', nodeId]);
|
||||
|
||||
const nodeState = useBridgeQuery(['nodeState']);
|
||||
|
||||
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
|
||||
useExplorerTopBarOptions();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
parent: nodeState.data
|
||||
? {
|
||||
type: 'Node',
|
||||
node: nodeState.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="group flex flex-row items-center space-x-2">
|
||||
|
@ -32,14 +39,10 @@ export const Component = () => {
|
|||
</span>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<TopBarOptions
|
||||
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
|
||||
/>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
|
||||
{locations.data && <Explorer items={locations.data} />}
|
||||
</>
|
||||
<Explorer items={query.data || []} />
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import { iconNames } from '@sd/assets/util';
|
|||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Category, useLibraryContext, useRspcLibraryContext } from '@sd/client';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks';
|
||||
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
|
||||
|
||||
export const IconForCategory: Partial<Record<Category, string>> = {
|
||||
Recents: iconNames.Collection,
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
import { Category } from '@sd/client';
|
||||
import { useExplorerStore, useExplorerTopBarOptions } from '~/hooks';
|
||||
import ContextMenu from '../Explorer/File/ContextMenu';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
// import ContextMenu from '../Explorer/FilePath/ContextMenu';
|
||||
import { Inspector } from '../Explorer/Inspector';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import View from '../Explorer/View';
|
||||
import { useExplorerStore } from '../Explorer/store';
|
||||
import { usePageLayout } from '../PageLayout';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import TopBarOptions from '../TopBar/TopBarOptions';
|
||||
import Statistics from '../overview/Statistics';
|
||||
import { Categories } from './Categories';
|
||||
import { useItems } from './data';
|
||||
|
@ -17,9 +18,6 @@ export const Component = () => {
|
|||
|
||||
const page = usePageLayout();
|
||||
|
||||
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
|
||||
useExplorerTopBarOptions();
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
|
||||
|
||||
const { items, query, loadMore } = useItems(selectedCategory);
|
||||
|
@ -32,14 +30,8 @@ export const Component = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBarPortal
|
||||
right={
|
||||
<TopBarOptions
|
||||
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ExplorerContext.Provider value={{}}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
|
||||
<div>
|
||||
<Statistics />
|
||||
|
@ -58,7 +50,7 @@ export const Component = () => {
|
|||
onSelectedChange={setSelectedItemId}
|
||||
top={68}
|
||||
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
|
||||
contextMenu={<ContextMenu data={selectedItem} />}
|
||||
// contextMenu={<ContextMenu data={selectedItem as any} />}
|
||||
emptyNotice={null}
|
||||
/>
|
||||
|
||||
|
@ -71,6 +63,6 @@ export const Component = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,21 +1,16 @@
|
|||
import { MagnifyingGlass } from 'phosphor-react';
|
||||
import { Suspense, memo, useDeferredValue, useEffect, useMemo } from 'react';
|
||||
import { Suspense, memo, useDeferredValue, useMemo } from 'react';
|
||||
import { getExplorerItemData, useLibraryQuery } from '@sd/client';
|
||||
import { SearchParams, SearchParamsSchema } from '~/app/route-schemas';
|
||||
import {
|
||||
getExplorerStore,
|
||||
useExplorerStore,
|
||||
useExplorerTopBarOptions,
|
||||
useZodSearchParams
|
||||
} from '~/hooks';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
import Explorer from './Explorer';
|
||||
import { ExplorerContext } from './Explorer/Context';
|
||||
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
||||
import { getExplorerStore, useExplorerStore } from './Explorer/store';
|
||||
import { TopBarPortal } from './TopBar/Portal';
|
||||
import TopBarOptions from './TopBar/TopBarOptions';
|
||||
|
||||
const SearchExplorer = memo((props: { args: SearchParams }) => {
|
||||
const explorerStore = useExplorerStore();
|
||||
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
|
||||
useExplorerTopBarOptions();
|
||||
|
||||
const { search, ...args } = props.args;
|
||||
|
||||
|
@ -36,27 +31,13 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
|
|||
});
|
||||
}, [query.data, explorerStore.layoutMode]);
|
||||
|
||||
useEffect(() => {
|
||||
getExplorerStore().selectedRowIndex = null;
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items && items.length > 0 ? (
|
||||
<>
|
||||
<TopBarPortal
|
||||
right={
|
||||
<TopBarOptions
|
||||
options={[
|
||||
explorerViewOptions,
|
||||
explorerToolOptions,
|
||||
explorerControlOptions
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ExplorerContext.Provider value={{}}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<Explorer items={items} />
|
||||
</>
|
||||
</ExplorerContext.Provider>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{!search && (
|
||||
|
|
|
@ -1,39 +1,38 @@
|
|||
import { Tag } from 'phosphor-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import Explorer from '~/app/$libraryId/Explorer';
|
||||
import { TopBarPortal } from '~/app/$libraryId/TopBar/Portal';
|
||||
import TopBarOptions from '~/app/$libraryId/TopBar/TopBarOptions';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { useExplorerTopBarOptions, useZodRouteParams } from '~/hooks';
|
||||
import { useZodRouteParams } from '~/hooks';
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
|
||||
const topBarOptions = useExplorerTopBarOptions();
|
||||
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
|
||||
const explorerData = useLibraryQuery([
|
||||
'search.objects',
|
||||
{
|
||||
filter: {
|
||||
tags: [locationId]
|
||||
tags: [tagId]
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBarPortal
|
||||
right={
|
||||
<TopBarOptions
|
||||
options={[
|
||||
topBarOptions.explorerViewOptions,
|
||||
topBarOptions.explorerToolOptions,
|
||||
topBarOptions.explorerControlOptions
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
parent: tag.data
|
||||
? {
|
||||
type: 'Tag',
|
||||
tag: tag.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<Explorer
|
||||
items={explorerData.data?.items || null}
|
||||
emptyNotice={{
|
||||
|
@ -41,6 +40,6 @@ export const Component = () => {
|
|||
message: 'No items assigned to this tag'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,10 +5,6 @@ export * from './useCounter';
|
|||
export * from './useDebouncedForm';
|
||||
export * from './useDismissibleNoticeStore';
|
||||
export * from './useDragSelect';
|
||||
export * from './useExplorerConfigStore';
|
||||
export * from './useExplorerItemData';
|
||||
export * from './useExplorerStore';
|
||||
export * from './useExplorerTopBarOptions';
|
||||
export * from './useFocusState';
|
||||
export * from './useInputState';
|
||||
export * from './useIsDark';
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { useMemo } from 'react';
|
||||
import { ExplorerItem, getExplorerItemData } from '@sd/client';
|
||||
import { flattenThumbnailKey, useExplorerStore } from './useExplorerStore';
|
||||
|
||||
export function useExplorerItemData(explorerItem: ExplorerItem) {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const newThumbnail = !!(
|
||||
explorerItem.thumbnail_key &&
|
||||
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const itemData = getExplorerItemData(explorerItem);
|
||||
|
||||
if (!itemData.hasLocalThumbnail) {
|
||||
itemData.hasLocalThumbnail = newThumbnail;
|
||||
}
|
||||
|
||||
return itemData;
|
||||
}, [explorerItem, newThumbnail]);
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
import { proxy, useSnapshot } from 'valtio';
|
||||
import { proxySet } from 'valtio/utils';
|
||||
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering, resetStore } from '@sd/client';
|
||||
import { SortOrder } from '~/app/route-schemas';
|
||||
|
||||
type Join<K, P> = K extends string | number
|
||||
? P extends string | number
|
||||
? `${K}${'' extends P ? '' : '.'}${P}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type Leaves<T> = T extends object ? { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : '';
|
||||
|
||||
type UnionKeys<T> = T extends any ? Leaves<T> : never;
|
||||
|
||||
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
|
||||
|
||||
export enum ExplorerKind {
|
||||
Location,
|
||||
Tag,
|
||||
Space
|
||||
}
|
||||
|
||||
export type CutCopyType = 'Cut' | 'Copy';
|
||||
|
||||
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
|
||||
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
|
||||
|
||||
const state = {
|
||||
locationId: null as number | null,
|
||||
layoutMode: 'grid' as ExplorerLayoutMode,
|
||||
gridItemSize: 110,
|
||||
listItemSize: 40,
|
||||
selectedRowIndex: 1 as number | null,
|
||||
showBytesInGridView: true,
|
||||
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
|
||||
},
|
||||
quickViewObject: null as ExplorerItem | null,
|
||||
mediaColumns: 8,
|
||||
mediaAspectSquare: false,
|
||||
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
|
||||
orderByDirection: 'Desc' as SortOrder,
|
||||
groupBy: 'none'
|
||||
};
|
||||
|
||||
export function flattenThumbnailKey(thumbKey: string[]) {
|
||||
return thumbKey.join('/');
|
||||
}
|
||||
|
||||
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
|
||||
const explorerStore = proxy({
|
||||
...state,
|
||||
reset: () => resetStore(explorerStore, state),
|
||||
addNewThumbnail: (thumbKey: string[]) => {
|
||||
explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey));
|
||||
},
|
||||
// this should be done when the explorer query is refreshed
|
||||
// prevents memory leak
|
||||
resetNewThumbnails: () => {
|
||||
explorerStore.newThumbnails.clear();
|
||||
}
|
||||
});
|
||||
|
||||
export function useExplorerStore() {
|
||||
return useSnapshot(explorerStore);
|
||||
}
|
||||
|
||||
export function getExplorerStore() {
|
||||
return explorerStore;
|
||||
}
|
||||
|
||||
export function isCut(id: number) {
|
||||
return (
|
||||
explorerStore.cutCopyState.active &&
|
||||
explorerStore.cutCopyState.actionType === 'Cut' &&
|
||||
explorerStore.cutCopyState.sourcePathId === id
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { useKey } from 'rooks';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { dialogManager } from '@sd/ui';
|
||||
import DeleteDialog from '~/app/$libraryId/Explorer/File/DeleteDialog';
|
||||
import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog';
|
||||
|
||||
export const useKeyDeleteFile = (selectedItem: ExplorerItem | null, location_id: number | null) => {
|
||||
return useKey('Delete', (e) => {
|
||||
|
|
4
interface/hooks/useKeybindFactory.ts
Normal file
4
interface/hooks/useKeybindFactory.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { keybindForOs } from '~/util/keybinds';
|
||||
import { useOperatingSystem } from './useOperatingSystem';
|
||||
|
||||
export const useKeybindFactory = () => keybindForOs(useOperatingSystem());
|
|
@ -1,5 +1,4 @@
|
|||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
import { useTheme } from '../hooks';
|
||||
|
||||
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
|
||||
|
||||
|
@ -24,7 +23,11 @@ export type Platform = {
|
|||
openPath?(path: string): void;
|
||||
openLogsDir?(): void;
|
||||
// Opens a file path with a given ID
|
||||
openFilePath?(library: string, ids: number[]): any;
|
||||
openFilePaths?(library: string, ids: number[]): any;
|
||||
revealItems?(
|
||||
library: string,
|
||||
items: ({ Location: { id: number } } | { FilePath: { id: number } })[]
|
||||
): Promise<unknown>;
|
||||
getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
|
||||
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise<unknown>;
|
||||
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;
|
||||
|
|
Loading…
Reference in a new issue