mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
Merge pull request #2925 from spacedriveapp/space-item-cleanup
Space item cleanup
This commit is contained in:
@@ -119,6 +119,7 @@ impl From<RevokeArgs> for DeviceRevokeInput {
|
||||
fn from(args: RevokeArgs) -> Self {
|
||||
Self {
|
||||
device_id: args.device_id,
|
||||
remove_from_library: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
97715081206FCB268DE6EF1C /* libPods-Spacedrive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */; };
|
||||
3F6D28D8CA4B3C1FDF9E78A6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */; };
|
||||
B7DAB1C86A3850F7C3233BE9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
D7EEB2E25018FB2A40EE29D2 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */; };
|
||||
E48312EB51FF1B0800CD3FA4 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */; };
|
||||
C046F0D9873F8B0D24767F2C /* libPods-Spacedrive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -20,16 +20,16 @@
|
||||
13B07F961A680F5B00A75B9A /* Spacedrive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spacedrive.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Spacedrive/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Spacedrive/Info.plist; sourceTree = "<group>"; };
|
||||
1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.debug.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.release.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Spacedrive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Spacedrive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.debug.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.release.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Spacedrive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Spacedrive/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* Spacedrive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Spacedrive-Bridging-Header.h"; path = "Spacedrive/Spacedrive-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -37,13 +37,21 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97715081206FCB268DE6EF1C /* libPods-Spacedrive.a in Frameworks */,
|
||||
C046F0D9873F8B0D24767F2C /* libPods-Spacedrive.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0406F24C3ABEBFD6CF35395F /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6A0A3E08EC06A5293CBB19F /* Spacedrive */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13B07FAE1A68108700A75B9A /* Spacedrive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -53,7 +61,7 @@
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */,
|
||||
AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = Spacedrive;
|
||||
sourceTree = "<group>";
|
||||
@@ -62,29 +70,11 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */,
|
||||
48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
42798537B08B5D76DFB457E7 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */,
|
||||
6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FB1CBF77585F74645A1185E /* Spacedrive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = Spacedrive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -99,8 +89,8 @@
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
B72BBC0A0CC1879D53F63DAB /* ExpoModulesProviders */,
|
||||
42798537B08B5D76DFB457E7 /* Pods */,
|
||||
E0DC8B892BBF51D498C04E89 /* Pods */,
|
||||
0406F24C3ABEBFD6CF35395F /* ExpoModulesProviders */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@@ -115,12 +105,12 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B72BBC0A0CC1879D53F63DAB /* ExpoModulesProviders */ = {
|
||||
A6A0A3E08EC06A5293CBB19F /* Spacedrive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FB1CBF77585F74645A1185E /* Spacedrive */,
|
||||
F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
name = Spacedrive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
@@ -132,6 +122,16 @@
|
||||
path = Spacedrive/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E0DC8B892BBF51D498C04E89 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */,
|
||||
74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -139,14 +139,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Spacedrive" */;
|
||||
buildPhases = (
|
||||
52BA9D16F880670D9D7E318C /* [CP] Check Pods Manifest.lock */,
|
||||
AFC9D743E8C8086DE4D89015 /* [Expo] Configure project */,
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
0B49C67E201184D33FDE32F4 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
4F55279F996324975C6F08DE /* [CP] Embed Pods Frameworks */,
|
||||
31F4BA6009A034F8388CF793 /* [CP] Copy Pods Resources */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
7B5809D980A4A36345CE0978 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -196,7 +196,7 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
E48312EB51FF1B0800CD3FA4 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
B7DAB1C86A3850F7C3233BE9 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -220,7 +220,75 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
31F4BA6009A034F8388CF793 /* [CP] Copy Pods Resources */ = {
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Spacedrive-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
0B49C67E201184D33FDE32F4 /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/Spacedrive/Spacedrive.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Spacedrive/expo-configure-project.sh\"\n";
|
||||
};
|
||||
7B5809D980A4A36345CE0978 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -256,74 +324,6 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
4F55279F996324975C6F08DE /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
52BA9D16F880670D9D7E318C /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Spacedrive-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
AFC9D743E8C8086DE4D89015 /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/Spacedrive/Spacedrive.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Spacedrive/expo-configure-project.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -332,7 +332,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
D7EEB2E25018FB2A40EE29D2 /* ExpoModulesProvider.swift in Sources */,
|
||||
3F6D28D8CA4B3C1FDF9E78A6 /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -341,7 +341,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */;
|
||||
baseConfigurationReference = 4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
@@ -378,7 +378,7 @@
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */;
|
||||
baseConfigurationReference = 74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LocationCacheDemo,
|
||||
PopoutInspector,
|
||||
QuickPreview,
|
||||
JobsScreen,
|
||||
Settings,
|
||||
PlatformProvider,
|
||||
SpacedriveProvider,
|
||||
@@ -81,6 +82,8 @@ function App() {
|
||||
setRoute("/quick-preview");
|
||||
} else if (label.startsWith("cache-demo")) {
|
||||
setRoute("/cache-demo");
|
||||
} else if (label.startsWith("job-manager")) {
|
||||
setRoute("/job-manager");
|
||||
}
|
||||
|
||||
// Tell Tauri window is ready to be shown
|
||||
@@ -264,6 +267,20 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (route === "/job-manager") {
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<ServerProvider>
|
||||
<div className="h-screen bg-app overflow-hidden rounded-[10px] border border-transparent frame">
|
||||
<JobsScreen />
|
||||
</div>
|
||||
</ServerProvider>
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<Explorer client={client} />
|
||||
|
||||
@@ -295,6 +295,21 @@ impl DeviceManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a specific paired device from the cache by device ID
|
||||
/// Used when a device is unpaired/revoked
|
||||
pub fn remove_paired_device_from_cache(&self, device_id: Uuid) -> Result<(), DeviceError> {
|
||||
let mut cache = self
|
||||
.paired_device_cache
|
||||
.write()
|
||||
.map_err(|_| DeviceError::LockPoisoned)?;
|
||||
|
||||
// Find and remove the device by its ID (search by value)
|
||||
cache.retain(|_slug, &mut cached_id| cached_id != device_id);
|
||||
|
||||
tracing::debug!("Removed device {} from DeviceManager cache", device_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current device as a domain Device object
|
||||
pub async fn current_device(&self) -> Device {
|
||||
let config = self.config.read().unwrap();
|
||||
|
||||
@@ -905,7 +905,8 @@ impl JobManager {
|
||||
device_id,
|
||||
status,
|
||||
progress: progress_percentage,
|
||||
started_at: chrono::Utc::now(), // TODO: Get actual start time
|
||||
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
|
||||
started_at: Some(chrono::Utc::now()), // Running jobs have started
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
parent_job_id: None,
|
||||
@@ -993,7 +994,8 @@ impl JobManager {
|
||||
device_id,
|
||||
status: current_status,
|
||||
progress: progress_percentage,
|
||||
started_at: chrono::Utc::now(), // TODO: Get from DB
|
||||
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
|
||||
started_at: Some(chrono::Utc::now()), // Running jobs have started
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
parent_job_id: None,
|
||||
@@ -1069,7 +1071,8 @@ impl JobManager {
|
||||
device_id,
|
||||
status,
|
||||
progress,
|
||||
started_at: j.started_at.unwrap_or(j.created_at),
|
||||
created_at: j.created_at,
|
||||
started_at: j.started_at,
|
||||
completed_at: j.completed_at,
|
||||
error_message: j.error_message,
|
||||
parent_job_id: j.parent_job_id.and_then(|s| s.parse::<Uuid>().ok()),
|
||||
@@ -1117,7 +1120,8 @@ impl JobManager {
|
||||
device_id,
|
||||
status,
|
||||
progress,
|
||||
started_at: chrono::Utc::now(), // TODO: Get actual start time from DB
|
||||
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
|
||||
started_at: Some(chrono::Utc::now()), // Running jobs have started
|
||||
completed_at: None, // Running jobs aren't completed yet
|
||||
error_message: None, // TODO: Get from handle if failed
|
||||
parent_job_id: None, // TODO: Get from DB if needed
|
||||
@@ -1157,7 +1161,8 @@ impl JobManager {
|
||||
device_id,
|
||||
status,
|
||||
progress,
|
||||
started_at: j.started_at.unwrap_or(j.created_at),
|
||||
created_at: j.created_at,
|
||||
started_at: j.started_at,
|
||||
completed_at: j.completed_at,
|
||||
error_message: j.error_message,
|
||||
parent_job_id: j.parent_job_id.and_then(|s| s.parse::<Uuid>().ok()),
|
||||
|
||||
@@ -165,7 +165,8 @@ pub struct JobInfo {
|
||||
pub device_id: Uuid, // Device running this job
|
||||
pub status: JobStatus,
|
||||
pub progress: f32,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub error_message: Option<String>,
|
||||
pub parent_job_id: Option<Uuid>,
|
||||
|
||||
@@ -1447,8 +1447,8 @@ impl Library {
|
||||
UPDATE content_kinds
|
||||
SET file_count = (
|
||||
SELECT COUNT(*)
|
||||
FROM content_identity
|
||||
WHERE content_identity.kind_id = content_kinds.id
|
||||
FROM content_identities
|
||||
WHERE content_identities.kind_id = content_kinds.id
|
||||
)
|
||||
"#
|
||||
.to_owned(),
|
||||
|
||||
@@ -359,8 +359,21 @@ impl IndexPersistence for MemoryAdapter {
|
||||
};
|
||||
|
||||
if let Some(content_kind) = content_kind {
|
||||
self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind)
|
||||
.await;
|
||||
// Skip event emission for hidden files (dotfiles) to match query filtering behavior.
|
||||
// Hidden files are still indexed but won't trigger UI updates since they're
|
||||
// filtered out by default in directory_listing queries.
|
||||
// TODO: make this configurable
|
||||
let is_hidden = entry
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_hidden {
|
||||
self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entry_id)
|
||||
|
||||
@@ -136,10 +136,6 @@ impl IndexerJobConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates config for ephemeral browsing (external drives, network shares).
|
||||
///
|
||||
/// Hidden files (dotfiles) are filtered by default to match typical file browser
|
||||
/// behavior. Use `rule_toggles.no_hidden = false` if hidden files are needed.
|
||||
pub fn ephemeral_browse(path: SdPath, scope: IndexScope) -> Self {
|
||||
Self {
|
||||
location_id: None,
|
||||
@@ -152,13 +148,7 @@ impl IndexerJobConfig {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// Filter hidden files for ephemeral browsing to match file browser expectations
|
||||
// and prevent event/query mismatch where hidden files emit events but are
|
||||
// filtered from query results.
|
||||
rule_toggles: super::rules::RuleToggles {
|
||||
no_hidden: true,
|
||||
..Default::default()
|
||||
},
|
||||
rule_toggles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
@@ -65,8 +66,7 @@ impl LibraryAction for JobCancelAction {
|
||||
success: true,
|
||||
}),
|
||||
Err(e) => {
|
||||
// Return success=false instead of error for better UX
|
||||
eprintln!("Failed to cancel job: {}", e);
|
||||
warn!("Failed to cancel job {}: {}", self.input.job_id, e);
|
||||
Ok(JobCancelOutput {
|
||||
job_id: self.input.job_id,
|
||||
success: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
@@ -65,8 +66,7 @@ impl LibraryAction for JobPauseAction {
|
||||
success: true,
|
||||
}),
|
||||
Err(e) => {
|
||||
// Return success=false instead of error for better UX
|
||||
eprintln!("Failed to pause job: {}", e);
|
||||
warn!("Failed to pause job {}: {}", self.input.job_id, e);
|
||||
Ok(JobPauseOutput {
|
||||
job_id: self.input.job_id,
|
||||
success: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
@@ -65,8 +66,7 @@ impl LibraryAction for JobResumeAction {
|
||||
success: true,
|
||||
}),
|
||||
Err(e) => {
|
||||
// Return success=false instead of error for better UX
|
||||
eprintln!("Failed to resume job: {}", e);
|
||||
warn!("Failed to resume job {}: {}", self.input.job_id, e);
|
||||
Ok(JobResumeOutput {
|
||||
job_id: self.input.job_id,
|
||||
success: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use uuid::Uuid;
|
||||
@@ -8,7 +9,8 @@ pub struct JobInfoOutput {
|
||||
pub name: String,
|
||||
pub status: crate::infra::job::types::JobStatus,
|
||||
pub progress: f32,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ impl LibraryQuery for JobInfoQuery {
|
||||
name: j.name,
|
||||
status: j.status,
|
||||
progress: j.progress,
|
||||
created_at: j.created_at,
|
||||
started_at: j.started_at,
|
||||
completed_at: j.completed_at,
|
||||
error_message: j.error_message,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::infra::job::types::ActionContextInfo;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use uuid::Uuid;
|
||||
@@ -12,6 +13,9 @@ pub struct JobListItem {
|
||||
pub progress: f32,
|
||||
pub action_type: Option<String>,
|
||||
pub action_context: Option<ActionContextInfo>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -56,6 +56,9 @@ impl LibraryQuery for JobListQuery {
|
||||
progress: j.progress,
|
||||
action_type: j.action_type,
|
||||
action_context: j.action_context,
|
||||
created_at: j.created_at,
|
||||
started_at: j.started_at,
|
||||
completed_at: j.completed_at,
|
||||
})
|
||||
.collect();
|
||||
Ok(JobListOutput { jobs: items })
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
pub struct DeviceRevokeAction {
|
||||
pub device_id: uuid::Uuid,
|
||||
pub remove_from_library: bool,
|
||||
}
|
||||
|
||||
impl CoreAction for DeviceRevokeAction {
|
||||
@@ -13,6 +14,7 @@ impl CoreAction for DeviceRevokeAction {
|
||||
fn from_input(input: Self::Input) -> std::result::Result<Self, String> {
|
||||
Ok(Self {
|
||||
device_id: input.device_id,
|
||||
remove_from_library: input.remove_from_library,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,22 +22,135 @@ impl CoreAction for DeviceRevokeAction {
|
||||
self,
|
||||
context: Arc<crate::context::CoreContext>,
|
||||
) -> std::result::Result<Self::Output, ActionError> {
|
||||
tracing::info!("Revoking device: {}", self.device_id);
|
||||
|
||||
let net = context
|
||||
.get_networking()
|
||||
.await
|
||||
.ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?;
|
||||
// Remove from registry state and persistence
|
||||
|
||||
// Remove from network registry state and persistence
|
||||
{
|
||||
let reg = net.device_registry();
|
||||
let mut guard = reg.write().await;
|
||||
let _ = guard.remove_device(self.device_id);
|
||||
let _ = guard.remove_paired_device(self.device_id).await;
|
||||
|
||||
tracing::info!(
|
||||
"Removing device {} from network registry in-memory state",
|
||||
self.device_id
|
||||
);
|
||||
if let Err(e) = guard.remove_device(self.device_id) {
|
||||
tracing::warn!("Failed to remove device from network registry: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Removing device {} from network encrypted persistence",
|
||||
self.device_id
|
||||
);
|
||||
match guard.remove_paired_device(self.device_id).await {
|
||||
Ok(removed) => {
|
||||
if removed {
|
||||
tracing::info!(
|
||||
"Device {} removed from network persistent storage",
|
||||
self.device_id
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Device {} not found in network persistent storage (already removed?)",
|
||||
self.device_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove device from network persistence: {}", e);
|
||||
return Err(ActionError::Internal(format!(
|
||||
"Failed to remove device from network persistence: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from all library databases (if requested)
|
||||
if self.remove_from_library {
|
||||
tracing::info!(
|
||||
"Removing device {} from library databases (remove_from_library=true)",
|
||||
self.device_id
|
||||
);
|
||||
let libraries = context.libraries().await;
|
||||
let mut removed_from_libraries = 0;
|
||||
|
||||
for library in libraries.get_open_libraries().await {
|
||||
let db = library.db().conn();
|
||||
|
||||
// Delete device from library database
|
||||
use crate::infra::db::entities::device;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
match device::Entity::delete_many()
|
||||
.filter(device::Column::Uuid.eq(self.device_id))
|
||||
.exec(db)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected > 0 {
|
||||
tracing::info!(
|
||||
"Device {} removed from library {} database",
|
||||
self.device_id,
|
||||
library.id()
|
||||
);
|
||||
removed_from_libraries += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to remove device from library {} database: {}",
|
||||
library.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removed_from_libraries > 0 {
|
||||
tracing::info!(
|
||||
"Device {} removed from {} library database(s)",
|
||||
self.device_id,
|
||||
removed_from_libraries
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Device {} not found in any library databases (may have been removed already)",
|
||||
self.device_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Skipping library database removal for device {} (remove_from_library=false)",
|
||||
self.device_id
|
||||
);
|
||||
}
|
||||
|
||||
// Remove from DeviceManager cache
|
||||
tracing::info!(
|
||||
"Removing device {} from DeviceManager cache",
|
||||
self.device_id
|
||||
);
|
||||
if let Err(e) = context
|
||||
.device_manager
|
||||
.remove_paired_device_from_cache(self.device_id)
|
||||
{
|
||||
tracing::warn!("Failed to remove device from cache: {}", e);
|
||||
}
|
||||
|
||||
// Emit ResourceDeleted event
|
||||
tracing::info!(
|
||||
"Emitting ResourceDeleted event for device {}",
|
||||
self.device_id
|
||||
);
|
||||
use crate::domain::resource::EventEmitter;
|
||||
crate::domain::device::Device::emit_deleted(self.device_id, &context.events);
|
||||
|
||||
tracing::info!("Device {} successfully revoked", self.device_id);
|
||||
Ok(DeviceRevokeOutput { revoked: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,11 @@ use uuid::Uuid;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct DeviceRevokeInput {
|
||||
pub device_id: Uuid,
|
||||
|
||||
/// Whether to also remove the device from all library databases
|
||||
///
|
||||
/// If false (default), only unpairs from network but keeps device history in libraries.
|
||||
/// If true, completely removes device from libraries (deletes all records).
|
||||
#[serde(default)]
|
||||
pub remove_from_library: bool,
|
||||
}
|
||||
|
||||
@@ -192,11 +192,41 @@ impl DevicePersistence {
|
||||
|
||||
/// Remove a paired device
|
||||
pub async fn remove_paired_device(&self, device_id: Uuid) -> Result<bool> {
|
||||
tracing::debug!(
|
||||
"Attempting to remove paired device {} from persistence",
|
||||
device_id
|
||||
);
|
||||
|
||||
let mut devices = self.load_paired_devices().await?;
|
||||
let removed = devices.remove(&device_id).is_some();
|
||||
|
||||
if removed {
|
||||
tracing::info!("Device {} found in paired devices, removing...", device_id);
|
||||
|
||||
// Delete the individual device key from KeyManager
|
||||
let key = Self::device_key(device_id);
|
||||
tracing::debug!("Deleting device key '{}' from KeyManager", key);
|
||||
|
||||
if let Err(e) = self.key_manager.delete_secret(&key).await {
|
||||
tracing::warn!("Failed to delete device key {}: {}", key, e);
|
||||
} else {
|
||||
tracing::info!("Device key '{}' deleted from KeyManager", key);
|
||||
}
|
||||
|
||||
// Update the device list (removes from paired_devices_list)
|
||||
tracing::debug!(
|
||||
"Updating paired devices list (now {} devices)",
|
||||
devices.len()
|
||||
);
|
||||
self.save_paired_devices(&devices).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device {} successfully removed from persistence ({} devices remaining)",
|
||||
device_id,
|
||||
devices.len()
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("Device {} not found in paired devices list", device_id);
|
||||
}
|
||||
|
||||
Ok(removed)
|
||||
|
||||
@@ -439,15 +439,24 @@ impl DeviceRegistry {
|
||||
/// Remove a device from the registry
|
||||
pub fn remove_device(&mut self, device_id: Uuid) -> Result<()> {
|
||||
if let Some(state) = self.devices.remove(&device_id) {
|
||||
// Clean up mappings
|
||||
// Clean up node-to-device mappings for all states
|
||||
match &state {
|
||||
DeviceState::Discovered { node_id, .. } | DeviceState::Pairing { node_id, .. } => {
|
||||
self.node_to_device.remove(node_id);
|
||||
}
|
||||
DeviceState::Pairing { session_id, .. } => {
|
||||
self.session_to_device.remove(session_id);
|
||||
DeviceState::Paired { info, .. }
|
||||
| DeviceState::Connected { info, .. }
|
||||
| DeviceState::Disconnected { info, .. } => {
|
||||
// Extract node ID from network fingerprint and clean up mapping
|
||||
if let Ok(node_id) = info.network_fingerprint.node_id.parse::<iroh::NodeId>() {
|
||||
self.node_to_device.remove(&node_id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Clean up session-to-device mapping for pairing state
|
||||
if let DeviceState::Pairing { session_id, .. } = &state {
|
||||
self.session_to_device.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
packages/assets/sounds/index.ts
generated
3
packages/assets/sounds/index.ts
generated
@@ -8,6 +8,8 @@ import splatOgg from "./splat.ogg";
|
||||
import splatMp3 from "./splat.mp3";
|
||||
import splatTriggerOgg from "./splat-trigger.ogg";
|
||||
import splatTriggerMp3 from "./splat-trigger.mp3";
|
||||
import jobDoneOgg from "./job-done.ogg";
|
||||
import jobDoneMp3 from "./job-done.mp3";
|
||||
|
||||
/**
|
||||
* Play a sound effect
|
||||
@@ -35,4 +37,5 @@ export const sounds = {
|
||||
pairing: () => playSound(pairingOgg, pairingMp3, 0.5),
|
||||
splat: () => playSound(splatOgg, splatMp3, 0.05),
|
||||
splatTrigger: () => playSound(splatTriggerOgg, splatTriggerMp3, 0.3),
|
||||
jobDone: () => playSound(jobDoneOgg, jobDoneMp3, 0.4),
|
||||
};
|
||||
|
||||
BIN
packages/assets/sounds/job-done.mp3
Normal file
BIN
packages/assets/sounds/job-done.mp3
Normal file
Binary file not shown.
BIN
packages/assets/sounds/job-done.ogg
Normal file
BIN
packages/assets/sounds/job-done.ogg
Normal file
Binary file not shown.
@@ -12,12 +12,7 @@ import { Dialogs } from "@sd/ui";
|
||||
import { Inspector, type InspectorVariant } from "./Inspector";
|
||||
import { TopBarProvider, TopBar } from "./TopBar";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ExplorerProvider,
|
||||
useExplorer,
|
||||
Sidebar,
|
||||
getSpaceItemKeyFromRoute,
|
||||
} from "./components/Explorer";
|
||||
import { ExplorerProvider, useExplorer, Sidebar } from "./components/Explorer";
|
||||
import {
|
||||
SelectionProvider,
|
||||
useSelection,
|
||||
@@ -65,7 +60,7 @@ import { House, Clock, Heart, Folders } from "@phosphor-icons/react";
|
||||
* we update the preview to show the newly selected file.
|
||||
*/
|
||||
function QuickPreviewSyncer() {
|
||||
const { quickPreviewFileId, setQuickPreviewFileId } = useExplorer();
|
||||
const { quickPreviewFileId, openQuickPreview } = useExplorer();
|
||||
const { selectedFiles } = useSelection();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,9 +71,9 @@ function QuickPreviewSyncer() {
|
||||
selectedFiles.length === 1 &&
|
||||
selectedFiles[0].id !== quickPreviewFileId
|
||||
) {
|
||||
setQuickPreviewFileId(selectedFiles[0].id);
|
||||
openQuickPreview(selectedFiles[0].id);
|
||||
}
|
||||
}, [selectedFiles, quickPreviewFileId, setQuickPreviewFileId]);
|
||||
}, [selectedFiles, quickPreviewFileId, openQuickPreview]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -163,19 +158,9 @@ function ExplorerLayoutContent() {
|
||||
tagModeActive,
|
||||
setTagModeActive,
|
||||
viewMode,
|
||||
setSpaceItemId,
|
||||
currentPath,
|
||||
} = useExplorer();
|
||||
|
||||
// Sync route with explorer context for view preferences
|
||||
useEffect(() => {
|
||||
const spaceItemKey = getSpaceItemKeyFromRoute(
|
||||
location.pathname,
|
||||
location.search,
|
||||
);
|
||||
setSpaceItemId(spaceItemKey);
|
||||
}, [location.pathname, location.search, setSpaceItemId]);
|
||||
|
||||
// Check if we're on Overview (hide inspector) or in Knowledge view (has its own inspector)
|
||||
const isOverview = location.pathname === "/";
|
||||
const isKnowledgeView = viewMode === "knowledge";
|
||||
@@ -769,7 +754,8 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
{activeItem.itemType === "Overview" && "Overview"}
|
||||
{activeItem.itemType === "Recents" && "Recents"}
|
||||
{activeItem.itemType === "Favorites" && "Favorites"}
|
||||
{activeItem.itemType === "FileKinds" && "File Kinds"}
|
||||
{activeItem.itemType === "FileKinds" &&
|
||||
"File Kinds"}
|
||||
</span>
|
||||
</div>
|
||||
) : activeItem?.label ? (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useExplorer } from "./context";
|
||||
import { GridView } from "./views/GridView";
|
||||
import { ListView } from "./views/ListView";
|
||||
@@ -25,7 +23,6 @@ import { SortMenu } from "./SortMenu";
|
||||
import { ViewModeMenu } from "./ViewModeMenu";
|
||||
|
||||
export function ExplorerView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const {
|
||||
sidebarVisible,
|
||||
setSidebarVisible,
|
||||
@@ -43,9 +40,7 @@ export function ExplorerView() {
|
||||
canGoForward,
|
||||
currentPath,
|
||||
currentView,
|
||||
setCurrentPath,
|
||||
syncPathFromUrl,
|
||||
syncViewFromUrl,
|
||||
navigateToPath,
|
||||
devices,
|
||||
quickPreviewFileId,
|
||||
} = useExplorer();
|
||||
@@ -53,52 +48,6 @@ export function ExplorerView() {
|
||||
const { isVirtualView } = useVirtualListing();
|
||||
const isPreviewActive = !!quickPreviewFileId;
|
||||
|
||||
// Sync currentPath or currentView from URL query parameters
|
||||
useEffect(() => {
|
||||
const pathParam = searchParams.get("path");
|
||||
const viewParam = searchParams.get("view");
|
||||
|
||||
if (pathParam) {
|
||||
try {
|
||||
const sdPath = JSON.parse(decodeURIComponent(pathParam));
|
||||
const currentPathStr = JSON.stringify(currentPath);
|
||||
const newPathStr = JSON.stringify(sdPath);
|
||||
|
||||
if (currentPathStr !== newPathStr) {
|
||||
syncPathFromUrl(sdPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse path query parameter:", e);
|
||||
}
|
||||
} else if (viewParam) {
|
||||
const id = searchParams.get("id");
|
||||
const params: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== "view" && key !== "id") {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const newView = {
|
||||
view: viewParam,
|
||||
id: id || undefined,
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
};
|
||||
const currentViewStr = JSON.stringify(currentView);
|
||||
const newViewStr = JSON.stringify(newView);
|
||||
|
||||
if (currentViewStr !== newViewStr) {
|
||||
syncViewFromUrl(newView);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
searchParams,
|
||||
currentPath,
|
||||
currentView,
|
||||
syncPathFromUrl,
|
||||
syncViewFromUrl,
|
||||
]);
|
||||
|
||||
// Allow rendering if either we have a currentPath or we're in a virtual view
|
||||
if (!currentPath && !isVirtualView) {
|
||||
return <EmptyView />;
|
||||
@@ -133,7 +82,7 @@ export function ExplorerView() {
|
||||
<PathBar
|
||||
path={currentPath}
|
||||
devices={devices}
|
||||
onNavigate={setCurrentPath}
|
||||
onNavigate={navigateToPath}
|
||||
/>
|
||||
)}
|
||||
{currentView && (
|
||||
|
||||
@@ -5,59 +5,62 @@ import { Title } from "./Title";
|
||||
import { Metadata } from "./Metadata";
|
||||
|
||||
interface FileProps {
|
||||
file: FileType;
|
||||
selected?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
onMouseMove?: (e: React.MouseEvent) => void;
|
||||
onMouseUp?: (e: React.MouseEvent) => void;
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
layout?: "column" | "row";
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
"data-file-id"?: string;
|
||||
file: FileType;
|
||||
selected?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
onMouseMove?: (e: React.MouseEvent) => void;
|
||||
onMouseUp?: (e: React.MouseEvent) => void;
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
layout?: "column" | "row";
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
"data-file-id"?: string;
|
||||
}
|
||||
|
||||
function FileComponent({
|
||||
file,
|
||||
selected,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
layout = "column",
|
||||
children,
|
||||
className,
|
||||
"data-file-id": dataFileId,
|
||||
file,
|
||||
selected,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
layout = "column",
|
||||
children,
|
||||
className,
|
||||
"data-file-id": dataFileId,
|
||||
}: FileProps) {
|
||||
return (
|
||||
<div
|
||||
data-file-id={dataFileId}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={clsx(
|
||||
"cursor-default transition-colors",
|
||||
layout === "column" ? "flex flex-col" : "flex flex-row items-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-file-id={dataFileId}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
"cursor-default transition-colors outline-none focus:outline-none",
|
||||
layout === "column"
|
||||
? "flex flex-col"
|
||||
: "flex flex-row items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const File = Object.assign(FileComponent, {
|
||||
Thumb,
|
||||
Title,
|
||||
Metadata,
|
||||
Thumb,
|
||||
Title,
|
||||
Metadata,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RadioButtonIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { SdPath, LibraryDeviceInfo } from "@sd/ts-client";
|
||||
import { getDeviceIconBySlug, useLibraryMutation } from "@sd/ts-client";
|
||||
import { getDeviceIcon, useLibraryMutation } from "@sd/ts-client";
|
||||
import { sdPathToUri } from "../utils";
|
||||
import LaptopIcon from "@sd/assets/icons/Laptop.png";
|
||||
import { useNormalizedQuery } from "@sd/ts-client";
|
||||
@@ -250,6 +250,9 @@ function IndexIndicator({ path }: { path: SdPath }) {
|
||||
export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isShiftHeld, setIsShiftHeld] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editingAsUri, setEditingAsUri] = useState(false);
|
||||
const { navigateToView } = useExplorer();
|
||||
const uri = sdPathToUri(path);
|
||||
const currentDir = getCurrentDirectoryName(path);
|
||||
@@ -264,7 +267,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
(d) => d.slug === deviceSlug,
|
||||
);
|
||||
return {
|
||||
icon: getDeviceIconBySlug(deviceSlug, devices),
|
||||
icon: device ? getDeviceIcon(device) : LaptopIcon,
|
||||
device,
|
||||
};
|
||||
}
|
||||
@@ -278,6 +281,68 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const enterEditMode = (initialValue: string, asUri: boolean) => {
|
||||
setIsEditing(true);
|
||||
setEditValue(initialValue);
|
||||
setEditingAsUri(asUri);
|
||||
};
|
||||
|
||||
const exitEditMode = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue("");
|
||||
setEditingAsUri(false);
|
||||
};
|
||||
|
||||
const handleContainerClick = (e: React.MouseEvent) => {
|
||||
// Only enter edit mode if clicking the container itself, not buttons/segments
|
||||
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === "INPUT") {
|
||||
const isUriMode = showUri;
|
||||
const valueToEdit = isUriMode ? uri : ("Physical" in path ? path.Physical.path : uri);
|
||||
enterEditMode(valueToEdit, isUriMode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (!trimmed) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingAsUri) {
|
||||
// Try to parse as SdPath JSON
|
||||
const parsed = JSON.parse(trimmed) as SdPath;
|
||||
onNavigate(parsed);
|
||||
} else {
|
||||
// Parse as file path string
|
||||
if ("Physical" in path) {
|
||||
const newPath: SdPath = {
|
||||
Physical: {
|
||||
device_slug: path.Physical.device_slug,
|
||||
path: trimmed.startsWith("/") ? trimmed : `/${trimmed}`,
|
||||
},
|
||||
};
|
||||
onNavigate(newPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse path:", error);
|
||||
}
|
||||
|
||||
exitEditMode();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") setIsShiftHeld(true);
|
||||
@@ -297,7 +362,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
|
||||
const showUri = isExpanded && isShiftHeld;
|
||||
|
||||
// Calculate widths for three states
|
||||
// Calculate widths for different states
|
||||
const collapsedWidth = currentDir.length * 8.5 + 70;
|
||||
const breadcrumbsWidth = Math.min(
|
||||
segments.reduce((sum, seg) => sum + seg.name.length * 6.5, 0) +
|
||||
@@ -306,29 +371,37 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
600,
|
||||
);
|
||||
const uriWidth = Math.min(uri.length * 7 + 70, 600);
|
||||
const editWidth = Math.max(200, Math.min(editValue.length * 7 + 70, 600));
|
||||
|
||||
const currentWidth = !isExpanded
|
||||
? collapsedWidth
|
||||
: showUri
|
||||
? uriWidth
|
||||
: breadcrumbsWidth;
|
||||
const currentWidth = isEditing
|
||||
? editWidth
|
||||
: !isExpanded
|
||||
? collapsedWidth
|
||||
: showUri
|
||||
? uriWidth
|
||||
: breadcrumbsWidth;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ width: currentWidth }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
|
||||
onMouseEnter={() => setIsExpanded(true)}
|
||||
onMouseLeave={() => setIsExpanded(false)}
|
||||
onMouseEnter={() => !isEditing && setIsExpanded(true)}
|
||||
onMouseLeave={() => !isEditing && setIsExpanded(false)}
|
||||
onClick={handleContainerClick}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 h-8 px-3 rounded-full",
|
||||
"backdrop-blur-xl border border-sidebar-line/30",
|
||||
"bg-sidebar-box/20 transition-colors",
|
||||
"focus-within:bg-sidebar-box/30 focus-within:border-sidebar-line/40",
|
||||
!isEditing && "cursor-text",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleDeviceClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeviceClick();
|
||||
}}
|
||||
disabled={!deviceInfo.device}
|
||||
title={
|
||||
deviceInfo.device
|
||||
@@ -349,7 +422,24 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showUri ? (
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={exitEditMode}
|
||||
autoFocus
|
||||
className={clsx(
|
||||
"bg-transparent border-0 outline-none ring-0 flex-1 min-w-0",
|
||||
"text-xs font-medium text-sidebar-ink",
|
||||
"placeholder:text-sidebar-inkFaint",
|
||||
"focus:ring-0 focus:outline-none",
|
||||
editingAsUri && "font-mono",
|
||||
)}
|
||||
placeholder={editingAsUri ? "Enter SdPath JSON..." : "Enter path..."}
|
||||
/>
|
||||
) : showUri ? (
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
@@ -373,9 +463,10 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
className="flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
!isLast && onNavigate(segment.path)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
!isLast && onNavigate(segment.path);
|
||||
}}
|
||||
disabled={isLast}
|
||||
className={clsx(
|
||||
"text-xs font-medium transition-colors whitespace-nowrap",
|
||||
@@ -386,7 +477,18 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
|
||||
>
|
||||
{segment.name}
|
||||
</button>
|
||||
{!isLast && <CaretRight size={12} />}
|
||||
{!isLast && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const valueToEdit = "Physical" in path ? path.Physical.path : uri;
|
||||
enterEditMode(valueToEdit, false);
|
||||
}}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity cursor-text"
|
||||
>
|
||||
<CaretRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useReducer,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useNormalizedQuery } from "../../context";
|
||||
import { usePlatform } from "../../platform";
|
||||
|
||||
import type {
|
||||
SdPath,
|
||||
File,
|
||||
Device,
|
||||
ListLibraryDevicesInput,
|
||||
DirectorySortBy,
|
||||
MediaSortBy,
|
||||
@@ -23,15 +23,24 @@ import {
|
||||
useSortPreferencesStore,
|
||||
} from "@sd/ts-client";
|
||||
|
||||
interface ViewSettings {
|
||||
gridSize: number; // 80-400px
|
||||
gapSize: number; // 1-32px
|
||||
export type SortBy = DirectorySortBy | MediaSortBy;
|
||||
export type ViewMode =
|
||||
| "grid"
|
||||
| "list"
|
||||
| "media"
|
||||
| "column"
|
||||
| "size"
|
||||
| "knowledge";
|
||||
|
||||
export interface ViewSettings {
|
||||
gridSize: number;
|
||||
gapSize: number;
|
||||
showFileSize: boolean;
|
||||
columnWidth: number; // 200-400px for column view
|
||||
columnWidth: number;
|
||||
foldersFirst: boolean;
|
||||
}
|
||||
|
||||
export type NavigationEntry =
|
||||
export type NavigationTarget =
|
||||
| { type: "path"; path: SdPath }
|
||||
| {
|
||||
type: "view";
|
||||
@@ -40,59 +49,270 @@ export type NavigationEntry =
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface VirtualView {
|
||||
view: string;
|
||||
id?: string;
|
||||
params?: Record<string, string>;
|
||||
function targetToKey(target: NavigationTarget): string {
|
||||
if (target.type === "path") {
|
||||
const p = target.path;
|
||||
if ("Physical" in p && p.Physical) {
|
||||
return `path:${p.Physical.device_slug}:${p.Physical.path}`;
|
||||
}
|
||||
if ("Virtual" in p && p.Virtual) {
|
||||
return `path:virtual:${p.Virtual}`;
|
||||
}
|
||||
return `path:${JSON.stringify(p)}`;
|
||||
}
|
||||
return `view:${target.view}:${target.id || ""}`;
|
||||
}
|
||||
|
||||
function getSpaceItemKeyFromRoute(pathname: string, search: string): string {
|
||||
function targetsEqual(
|
||||
a: NavigationTarget | null,
|
||||
b: NavigationTarget | null,
|
||||
): boolean {
|
||||
if (a === null || b === null) return a === b;
|
||||
return targetToKey(a) === targetToKey(b);
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 100;
|
||||
|
||||
interface NavigationState {
|
||||
history: NavigationTarget[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
type NavigationAction =
|
||||
| { type: "NAVIGATE"; target: NavigationTarget }
|
||||
| { type: "GO_BACK" }
|
||||
| { type: "GO_FORWARD" }
|
||||
| { type: "SYNC"; target: NavigationTarget };
|
||||
|
||||
function navigationReducer(
|
||||
state: NavigationState,
|
||||
action: NavigationAction,
|
||||
): NavigationState {
|
||||
switch (action.type) {
|
||||
case "NAVIGATE": {
|
||||
const current = state.history[state.index];
|
||||
if (current && targetsEqual(current, action.target)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newHistory = state.history.slice(0, state.index + 1);
|
||||
newHistory.push(action.target);
|
||||
|
||||
const trimmedHistory = newHistory.slice(-MAX_HISTORY_SIZE);
|
||||
const indexAdjustment = newHistory.length - trimmedHistory.length;
|
||||
|
||||
return {
|
||||
history: trimmedHistory,
|
||||
index: state.index + 1 - indexAdjustment,
|
||||
};
|
||||
}
|
||||
|
||||
case "GO_BACK": {
|
||||
if (state.index <= 0) return state;
|
||||
return { ...state, index: state.index - 1 };
|
||||
}
|
||||
|
||||
case "GO_FORWARD": {
|
||||
if (state.index >= state.history.length - 1) return state;
|
||||
return { ...state, index: state.index + 1 };
|
||||
}
|
||||
|
||||
case "SYNC": {
|
||||
const current = state.history[state.index];
|
||||
if (current && targetsEqual(current, action.target)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newHistory = [
|
||||
...state.history.slice(0, state.index + 1),
|
||||
action.target,
|
||||
];
|
||||
const trimmedHistory = newHistory.slice(-MAX_HISTORY_SIZE);
|
||||
const indexAdjustment = newHistory.length - trimmedHistory.length;
|
||||
|
||||
return {
|
||||
history: trimmedHistory,
|
||||
index: state.index + 1 - indexAdjustment,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialNavigationState: NavigationState = {
|
||||
history: [],
|
||||
index: -1,
|
||||
};
|
||||
|
||||
interface UIState {
|
||||
viewMode: ViewMode;
|
||||
sortBy: SortBy;
|
||||
viewSettings: ViewSettings;
|
||||
sidebarVisible: boolean;
|
||||
inspectorVisible: boolean;
|
||||
quickPreviewFileId: string | null;
|
||||
tagModeActive: boolean;
|
||||
}
|
||||
|
||||
type UIAction =
|
||||
| { type: "SET_VIEW_MODE"; mode: ViewMode }
|
||||
| { type: "SET_SORT_BY"; sort: SortBy }
|
||||
| { type: "SET_VIEW_SETTINGS"; settings: Partial<ViewSettings> }
|
||||
| { type: "SET_SIDEBAR_VISIBLE"; visible: boolean }
|
||||
| { type: "SET_INSPECTOR_VISIBLE"; visible: boolean }
|
||||
| { type: "SET_QUICK_PREVIEW"; fileId: string | null }
|
||||
| { type: "SET_TAG_MODE"; active: boolean }
|
||||
| {
|
||||
type: "LOAD_PREFERENCES";
|
||||
viewMode: ViewMode;
|
||||
viewSettings?: Partial<ViewSettings>;
|
||||
};
|
||||
|
||||
const defaultViewSettings: ViewSettings = {
|
||||
gridSize: 120,
|
||||
gapSize: 16,
|
||||
showFileSize: true,
|
||||
columnWidth: 256,
|
||||
foldersFirst: false,
|
||||
};
|
||||
|
||||
function uiReducer(state: UIState, action: UIAction): UIState {
|
||||
switch (action.type) {
|
||||
case "SET_VIEW_MODE":
|
||||
return { ...state, viewMode: action.mode };
|
||||
|
||||
case "SET_SORT_BY":
|
||||
return { ...state, sortBy: action.sort };
|
||||
|
||||
case "SET_VIEW_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
viewSettings: { ...state.viewSettings, ...action.settings },
|
||||
};
|
||||
|
||||
case "SET_SIDEBAR_VISIBLE":
|
||||
return { ...state, sidebarVisible: action.visible };
|
||||
|
||||
case "SET_INSPECTOR_VISIBLE":
|
||||
return { ...state, inspectorVisible: action.visible };
|
||||
|
||||
case "SET_QUICK_PREVIEW":
|
||||
return { ...state, quickPreviewFileId: action.fileId };
|
||||
|
||||
case "SET_TAG_MODE":
|
||||
return { ...state, tagModeActive: action.active };
|
||||
|
||||
case "LOAD_PREFERENCES":
|
||||
return {
|
||||
...state,
|
||||
viewMode: action.viewMode,
|
||||
viewSettings: action.viewSettings
|
||||
? { ...state.viewSettings, ...action.viewSettings }
|
||||
: state.viewSettings,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialUIState: UIState = {
|
||||
viewMode: "grid",
|
||||
sortBy: "name",
|
||||
viewSettings: defaultViewSettings,
|
||||
sidebarVisible: true,
|
||||
inspectorVisible: true,
|
||||
quickPreviewFileId: null,
|
||||
tagModeActive: false,
|
||||
};
|
||||
|
||||
function targetToUrl(target: NavigationTarget): string {
|
||||
if (target.type === "path") {
|
||||
const encoded = encodeURIComponent(JSON.stringify(target.path));
|
||||
return `/explorer?path=${encoded}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ view: target.view });
|
||||
if (target.id) params.set("id", target.id);
|
||||
if (target.params) {
|
||||
Object.entries(target.params).forEach(([k, v]) => params.set(k, v));
|
||||
}
|
||||
return `/explorer?${params.toString()}`;
|
||||
}
|
||||
|
||||
function urlToTarget(search: string): NavigationTarget | null {
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
const pathParam = params.get("path");
|
||||
if (pathParam) {
|
||||
try {
|
||||
const path = JSON.parse(decodeURIComponent(pathParam)) as SdPath;
|
||||
return { type: "path", path };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const view = params.get("view");
|
||||
if (view) {
|
||||
const id = params.get("id") || undefined;
|
||||
const extraParams: Record<string, string> = {};
|
||||
params.forEach((v, k) => {
|
||||
if (k !== "view" && k !== "id") extraParams[k] = v;
|
||||
});
|
||||
return {
|
||||
type: "view",
|
||||
view,
|
||||
id,
|
||||
params:
|
||||
Object.keys(extraParams).length > 0 ? extraParams : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSpaceItemKey(pathname: string, search: string): string {
|
||||
if (pathname === "/") return "overview";
|
||||
if (pathname === "/recents") return "recents";
|
||||
if (pathname === "/favorites") return "favorites";
|
||||
if (pathname === "/file-kinds") return "file-kinds";
|
||||
if (pathname.startsWith("/tag/")) {
|
||||
const tagId = pathname.replace("/tag/", "");
|
||||
return `tag:${tagId}`;
|
||||
}
|
||||
if (pathname === "/explorer" && search) {
|
||||
return `explorer:${search}`;
|
||||
}
|
||||
if (pathname.startsWith("/tag/")) return `tag:${pathname.slice(5)}`;
|
||||
if (pathname === "/explorer" && search) return `explorer:${search}`;
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function getPathKey(sdPath: SdPath | null): string {
|
||||
if (!sdPath) return "null";
|
||||
return JSON.stringify(sdPath);
|
||||
function getPathKey(target: NavigationTarget | null): string {
|
||||
if (!target) return "null";
|
||||
return targetToKey(target);
|
||||
}
|
||||
|
||||
interface ExplorerState {
|
||||
interface ExplorerContextValue {
|
||||
currentTarget: NavigationTarget | null;
|
||||
currentPath: SdPath | null;
|
||||
currentView: VirtualView | null;
|
||||
setCurrentPath: (path: SdPath | null) => void;
|
||||
currentView: {
|
||||
view: string;
|
||||
id?: string;
|
||||
params?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
navigateToPath: (path: SdPath) => void;
|
||||
navigateToView: (
|
||||
view: string,
|
||||
id?: string,
|
||||
params?: Record<string, string>,
|
||||
) => void;
|
||||
syncPathFromUrl: (path: SdPath | null) => void;
|
||||
syncViewFromUrl: (view: VirtualView | null) => void;
|
||||
|
||||
history: NavigationEntry[];
|
||||
historyIndex: number;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
|
||||
viewMode: "grid" | "list" | "media" | "column" | "size" | "knowledge";
|
||||
setViewMode: (
|
||||
mode: "grid" | "list" | "media" | "column" | "size" | "knowledge",
|
||||
) => void;
|
||||
|
||||
sortBy: DirectorySortBy | MediaSortBy;
|
||||
setSortBy: (sort: DirectorySortBy | MediaSortBy) => void;
|
||||
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
sortBy: SortBy;
|
||||
setSortBy: (sort: SortBy) => void;
|
||||
viewSettings: ViewSettings;
|
||||
setViewSettings: (settings: Partial<ViewSettings>) => void;
|
||||
|
||||
@@ -102,7 +322,6 @@ interface ExplorerState {
|
||||
setInspectorVisible: (visible: boolean) => void;
|
||||
|
||||
quickPreviewFileId: string | null;
|
||||
setQuickPreviewFileId: (fileId: string | null) => void;
|
||||
openQuickPreview: (fileId: string) => void;
|
||||
closeQuickPreview: () => void;
|
||||
|
||||
@@ -112,338 +331,253 @@ interface ExplorerState {
|
||||
tagModeActive: boolean;
|
||||
setTagModeActive: (active: boolean) => void;
|
||||
|
||||
devices: Map<string, any>;
|
||||
devices: Map<string, Device>;
|
||||
|
||||
setSpaceItemId: (id: string) => void;
|
||||
loadPreferencesForSpaceItem: (id: string) => void;
|
||||
}
|
||||
|
||||
const ExplorerContext = createContext<ExplorerState | null>(null);
|
||||
const ExplorerContext = createContext<ExplorerContextValue | null>(null);
|
||||
|
||||
interface ExplorerProviderProps {
|
||||
children: ReactNode;
|
||||
spaceItemId?: string;
|
||||
}
|
||||
|
||||
export function ExplorerProvider({
|
||||
children,
|
||||
spaceItemId: initialSpaceItemId,
|
||||
}: ExplorerProviderProps) {
|
||||
const navigate = useNavigate();
|
||||
const platform = usePlatform();
|
||||
export function ExplorerProvider({ children }: ExplorerProviderProps) {
|
||||
const routerNavigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const viewPrefs = useViewPreferencesStore();
|
||||
const sortPrefs = useSortPreferencesStore();
|
||||
|
||||
const [spaceItemIdInternal, setSpaceItemIdInternal] = useState(
|
||||
initialSpaceItemId || "default",
|
||||
const [navState, navDispatch] = useReducer(
|
||||
navigationReducer,
|
||||
initialNavigationState,
|
||||
);
|
||||
const [currentPath, setCurrentPathInternal] = useState<SdPath | null>(null);
|
||||
const [currentView, setCurrentView] = useState<VirtualView | null>(null);
|
||||
const [history, setHistory] = useState<NavigationEntry[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [viewMode, setViewModeInternal] = useState<
|
||||
"grid" | "list" | "media" | "column" | "size" | "knowledge"
|
||||
>("grid");
|
||||
const [sortByInternal, setSortByInternal] = useState<
|
||||
DirectorySortBy | MediaSortBy
|
||||
>("name");
|
||||
const [viewSettings, setViewSettingsInternal] = useState<ViewSettings>({
|
||||
gridSize: 120,
|
||||
gapSize: 16,
|
||||
showFileSize: true,
|
||||
columnWidth: 256,
|
||||
foldersFirst: false,
|
||||
});
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [inspectorVisible, setInspectorVisible] = useState(true);
|
||||
const [quickPreviewFileId, setQuickPreviewFileId] = useState<string | null>(
|
||||
null,
|
||||
const [uiState, uiDispatch] = useReducer(uiReducer, initialUIState);
|
||||
const [currentFiles, setCurrentFiles] = useReducer(
|
||||
(_: File[], files: File[]) => files,
|
||||
[] as File[],
|
||||
);
|
||||
const [currentFiles, setCurrentFiles] = useState<File[]>([]);
|
||||
const [tagModeActive, setTagModeActive] = useState(false);
|
||||
|
||||
const spaceItemKey = spaceItemIdInternal;
|
||||
const pathKey = getPathKey(currentPath);
|
||||
const currentTarget = navState.history[navState.index] ?? null;
|
||||
const canGoBack = navState.index > 0;
|
||||
const canGoForward = navState.index < navState.history.length - 1;
|
||||
|
||||
// Load view preferences when space item changes
|
||||
useEffect(() => {
|
||||
const prefs = viewPrefs.getPreferences(spaceItemKey);
|
||||
if (prefs) {
|
||||
setViewModeInternal(prefs.viewMode);
|
||||
if (prefs.viewSettings) {
|
||||
setViewSettingsInternal((prev) => ({
|
||||
...prev,
|
||||
...prefs.viewSettings,
|
||||
}));
|
||||
}
|
||||
const currentPath = useMemo(() => {
|
||||
if (currentTarget?.type === "path") return currentTarget.path;
|
||||
return null;
|
||||
}, [currentTarget]);
|
||||
|
||||
const currentView = useMemo(() => {
|
||||
if (currentTarget?.type === "view") {
|
||||
return {
|
||||
view: currentTarget.view,
|
||||
id: currentTarget.id,
|
||||
params: currentTarget.params,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spaceItemKey]);
|
||||
return null;
|
||||
}, [currentTarget]);
|
||||
|
||||
// Load sort preferences when path changes
|
||||
useEffect(() => {
|
||||
const sortPref = sortPrefs.getPreferences(pathKey);
|
||||
if (sortPref) {
|
||||
setSortByInternal(sortPref as DirectorySortBy | MediaSortBy);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathKey]);
|
||||
|
||||
// Wrapper for setViewMode that persists to store
|
||||
const setViewMode = useCallback(
|
||||
(mode: "grid" | "list" | "media" | "column" | "size" | "knowledge") => {
|
||||
setViewModeInternal(mode);
|
||||
viewPrefs.setPreferences(spaceItemKey, { viewMode: mode });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[spaceItemKey],
|
||||
);
|
||||
|
||||
// Wrapper for setSortBy that persists to store
|
||||
const setSortBy = useCallback(
|
||||
(sort: DirectorySortBy | MediaSortBy) => {
|
||||
setSortByInternal(sort);
|
||||
sortPrefs.setPreferences(pathKey, sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[pathKey],
|
||||
);
|
||||
|
||||
// Update sort when switching to media view
|
||||
useEffect(() => {
|
||||
if (viewMode === "media" && sortByInternal === "type") {
|
||||
setSortByInternal("datetaken");
|
||||
sortPrefs.setPreferences(pathKey, "datetaken");
|
||||
} else if (viewMode !== "media" && sortByInternal === "datetaken") {
|
||||
setSortByInternal("modified");
|
||||
sortPrefs.setPreferences(pathKey, "modified");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewMode, sortByInternal, pathKey]);
|
||||
|
||||
const setViewSettings = useCallback(
|
||||
(settings: Partial<ViewSettings>) => {
|
||||
setViewSettingsInternal((prev) => {
|
||||
const updated = { ...prev, ...settings };
|
||||
viewPrefs.setPreferences(spaceItemKey, {
|
||||
viewSettings: updated,
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[spaceItemKey],
|
||||
);
|
||||
|
||||
// Use normalized query for automatic updates when device events are emitted
|
||||
const devicesQuery = useNormalizedQuery<ListLibraryDevicesInput, any[]>({
|
||||
const devicesQuery = useNormalizedQuery<ListLibraryDevicesInput, Device[]>({
|
||||
wireMethod: "query:devices.list",
|
||||
input: { include_offline: true, include_details: false },
|
||||
resourceType: "device",
|
||||
});
|
||||
|
||||
const devices = useMemo(() => {
|
||||
const deviceList = devicesQuery.data || [];
|
||||
return new Map(deviceList.map((d) => [d.id, d]));
|
||||
const list = devicesQuery.data ?? [];
|
||||
return new Map(list.map((d) => [d.id, d]));
|
||||
}, [devicesQuery.data]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const entry = history[newIndex];
|
||||
setHistoryIndex(newIndex);
|
||||
|
||||
if (entry.type === "path") {
|
||||
setCurrentPathInternal(entry.path);
|
||||
setCurrentView(null);
|
||||
const encodedPath = encodeURIComponent(
|
||||
JSON.stringify(entry.path),
|
||||
);
|
||||
navigate(`/explorer?path=${encodedPath}`, { replace: true });
|
||||
} else {
|
||||
setCurrentPathInternal(null);
|
||||
setCurrentView({
|
||||
view: entry.view,
|
||||
id: entry.id,
|
||||
params: entry.params,
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
view: entry.view,
|
||||
...(entry.id && { id: entry.id }),
|
||||
...(entry.params || {}),
|
||||
});
|
||||
navigate(`/explorer?${params.toString()}`, { replace: true });
|
||||
}
|
||||
// Exclude currentTarget from deps to prevent infinite sync loops.
|
||||
useEffect(() => {
|
||||
const target = urlToTarget(location.search);
|
||||
if (target && !targetsEqual(target, currentTarget)) {
|
||||
navDispatch({ type: "SYNC", target });
|
||||
}
|
||||
}, [historyIndex, history, navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const entry = history[newIndex];
|
||||
setHistoryIndex(newIndex);
|
||||
const pathKey = getPathKey(currentTarget);
|
||||
|
||||
if (entry.type === "path") {
|
||||
setCurrentPathInternal(entry.path);
|
||||
setCurrentView(null);
|
||||
const encodedPath = encodeURIComponent(
|
||||
JSON.stringify(entry.path),
|
||||
);
|
||||
navigate(`/explorer?path=${encodedPath}`, { replace: true });
|
||||
} else {
|
||||
setCurrentPathInternal(null);
|
||||
setCurrentView({
|
||||
view: entry.view,
|
||||
id: entry.id,
|
||||
params: entry.params,
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
view: entry.view,
|
||||
...(entry.id && { id: entry.id }),
|
||||
...(entry.params || {}),
|
||||
});
|
||||
navigate(`/explorer?${params.toString()}`, { replace: true });
|
||||
}
|
||||
useEffect(() => {
|
||||
const savedSort = sortPrefs.getPreferences(pathKey);
|
||||
if (savedSort) {
|
||||
uiDispatch({ type: "SET_SORT_BY", sort: savedSort as SortBy });
|
||||
}
|
||||
}, [historyIndex, history, navigate]);
|
||||
}, [pathKey, sortPrefs]);
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < history.length - 1;
|
||||
// "datetaken" only applies to media view; fall back to "modified" elsewhere.
|
||||
useEffect(() => {
|
||||
if (uiState.viewMode === "media" && uiState.sortBy === "type") {
|
||||
uiDispatch({ type: "SET_SORT_BY", sort: "datetaken" });
|
||||
sortPrefs.setPreferences(pathKey, "datetaken");
|
||||
} else if (
|
||||
uiState.viewMode !== "media" &&
|
||||
uiState.sortBy === "datetaken"
|
||||
) {
|
||||
uiDispatch({ type: "SET_SORT_BY", sort: "modified" });
|
||||
sortPrefs.setPreferences(pathKey, "modified");
|
||||
}
|
||||
}, [uiState.viewMode, uiState.sortBy, pathKey, sortPrefs]);
|
||||
|
||||
const navigateToPath = useCallback(
|
||||
(path: SdPath | null) => {
|
||||
if (!path) {
|
||||
setCurrentPathInternal(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear view state
|
||||
setCurrentView(null);
|
||||
|
||||
// Update history
|
||||
setHistory((prev) => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
newHistory.push({ type: "path", path });
|
||||
return newHistory;
|
||||
});
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
setCurrentPathInternal(path);
|
||||
|
||||
// Update URL to match
|
||||
const encodedPath = encodeURIComponent(JSON.stringify(path));
|
||||
navigate(`/explorer?path=${encodedPath}`, { replace: false });
|
||||
(path: SdPath) => {
|
||||
const target: NavigationTarget = { type: "path", path };
|
||||
navDispatch({ type: "NAVIGATE", target });
|
||||
routerNavigate(targetToUrl(target));
|
||||
},
|
||||
[historyIndex, navigate],
|
||||
[routerNavigate],
|
||||
);
|
||||
|
||||
const navigateToView = useCallback(
|
||||
(view: string, id?: string, params?: Record<string, string>) => {
|
||||
// Clear path state
|
||||
setCurrentPathInternal(null);
|
||||
|
||||
// Set view state
|
||||
setCurrentView({ view, id, params });
|
||||
|
||||
// Update history
|
||||
setHistory((prev) => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
newHistory.push({ type: "view", view, id, params });
|
||||
return newHistory;
|
||||
});
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
|
||||
// Update URL
|
||||
const queryParams = new URLSearchParams({
|
||||
view,
|
||||
...(id && { id }),
|
||||
...(params || {}),
|
||||
});
|
||||
navigate(`/explorer?${queryParams.toString()}`, { replace: false });
|
||||
const target: NavigationTarget = { type: "view", view, id, params };
|
||||
navDispatch({ type: "NAVIGATE", target });
|
||||
routerNavigate(targetToUrl(target));
|
||||
},
|
||||
[historyIndex, navigate],
|
||||
[routerNavigate],
|
||||
);
|
||||
|
||||
const syncPathFromUrl = useCallback((path: SdPath | null) => {
|
||||
// Update internal state without navigating - used when URL changes externally
|
||||
setCurrentPathInternal(path);
|
||||
setCurrentView(null); // Clear view when syncing path
|
||||
const goBack = useCallback(() => {
|
||||
navDispatch({ type: "GO_BACK" });
|
||||
const targetIndex = navState.index - 1;
|
||||
if (targetIndex >= 0) {
|
||||
const target = navState.history[targetIndex];
|
||||
routerNavigate(targetToUrl(target), { replace: true });
|
||||
}
|
||||
}, [navState.index, navState.history, routerNavigate]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
navDispatch({ type: "GO_FORWARD" });
|
||||
const targetIndex = navState.index + 1;
|
||||
if (targetIndex < navState.history.length) {
|
||||
const target = navState.history[targetIndex];
|
||||
routerNavigate(targetToUrl(target), { replace: true });
|
||||
}
|
||||
}, [navState.index, navState.history, routerNavigate]);
|
||||
|
||||
const spaceKey = getSpaceItemKey(location.pathname, location.search);
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: ViewMode) => {
|
||||
uiDispatch({ type: "SET_VIEW_MODE", mode });
|
||||
viewPrefs.setPreferences(spaceKey, { viewMode: mode });
|
||||
},
|
||||
[spaceKey, viewPrefs],
|
||||
);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(sort: SortBy) => {
|
||||
uiDispatch({ type: "SET_SORT_BY", sort });
|
||||
sortPrefs.setPreferences(pathKey, sort);
|
||||
},
|
||||
[pathKey, sortPrefs],
|
||||
);
|
||||
|
||||
const setViewSettings = useCallback(
|
||||
(settings: Partial<ViewSettings>) => {
|
||||
uiDispatch({ type: "SET_VIEW_SETTINGS", settings });
|
||||
viewPrefs.setPreferences(spaceKey, {
|
||||
viewSettings: { ...uiState.viewSettings, ...settings },
|
||||
});
|
||||
},
|
||||
[spaceKey, uiState.viewSettings, viewPrefs],
|
||||
);
|
||||
|
||||
const setSidebarVisible = useCallback((visible: boolean) => {
|
||||
uiDispatch({ type: "SET_SIDEBAR_VISIBLE", visible });
|
||||
}, []);
|
||||
|
||||
const syncViewFromUrl = useCallback((view: VirtualView | null) => {
|
||||
// Update internal state without navigating - used when URL changes externally
|
||||
setCurrentView(view);
|
||||
setCurrentPathInternal(null); // Clear path when syncing view
|
||||
const setInspectorVisible = useCallback((visible: boolean) => {
|
||||
uiDispatch({ type: "SET_INSPECTOR_VISIBLE", visible });
|
||||
}, []);
|
||||
|
||||
const openQuickPreview = useCallback((fileId: string) => {
|
||||
setQuickPreviewFileId(fileId);
|
||||
uiDispatch({ type: "SET_QUICK_PREVIEW", fileId });
|
||||
}, []);
|
||||
|
||||
const closeQuickPreview = useCallback(() => {
|
||||
setQuickPreviewFileId(null);
|
||||
uiDispatch({ type: "SET_QUICK_PREVIEW", fileId: null });
|
||||
}, []);
|
||||
|
||||
const value: ExplorerState = useMemo(
|
||||
const setTagModeActive = useCallback((active: boolean) => {
|
||||
uiDispatch({ type: "SET_TAG_MODE", active });
|
||||
}, []);
|
||||
|
||||
const loadPreferencesForSpaceItem = useCallback(
|
||||
(id: string) => {
|
||||
const prefs = viewPrefs.getPreferences(id);
|
||||
if (prefs) {
|
||||
uiDispatch({
|
||||
type: "LOAD_PREFERENCES",
|
||||
viewMode: prefs.viewMode,
|
||||
viewSettings: prefs.viewSettings,
|
||||
});
|
||||
}
|
||||
},
|
||||
[viewPrefs],
|
||||
);
|
||||
|
||||
const value = useMemo<ExplorerContextValue>(
|
||||
() => ({
|
||||
currentPath,
|
||||
currentView,
|
||||
setCurrentPath: navigateToPath,
|
||||
navigateToView,
|
||||
syncPathFromUrl,
|
||||
syncViewFromUrl,
|
||||
history,
|
||||
historyIndex,
|
||||
goBack,
|
||||
goForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
sortBy: sortByInternal,
|
||||
setSortBy,
|
||||
viewSettings,
|
||||
setViewSettings,
|
||||
sidebarVisible,
|
||||
setSidebarVisible,
|
||||
inspectorVisible,
|
||||
setInspectorVisible,
|
||||
quickPreviewFileId,
|
||||
setQuickPreviewFileId,
|
||||
openQuickPreview,
|
||||
closeQuickPreview,
|
||||
currentFiles,
|
||||
setCurrentFiles,
|
||||
tagModeActive,
|
||||
setTagModeActive,
|
||||
devices,
|
||||
setSpaceItemId: setSpaceItemIdInternal,
|
||||
}),
|
||||
[
|
||||
currentTarget,
|
||||
currentPath,
|
||||
currentView,
|
||||
navigateToPath,
|
||||
navigateToView,
|
||||
syncPathFromUrl,
|
||||
syncViewFromUrl,
|
||||
history,
|
||||
historyIndex,
|
||||
goBack,
|
||||
goForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
viewMode,
|
||||
viewMode: uiState.viewMode,
|
||||
setViewMode,
|
||||
sortByInternal,
|
||||
sortBy: uiState.sortBy,
|
||||
setSortBy,
|
||||
viewSettings,
|
||||
viewSettings: uiState.viewSettings,
|
||||
setViewSettings,
|
||||
sidebarVisible,
|
||||
inspectorVisible,
|
||||
quickPreviewFileId,
|
||||
sidebarVisible: uiState.sidebarVisible,
|
||||
setSidebarVisible,
|
||||
inspectorVisible: uiState.inspectorVisible,
|
||||
setInspectorVisible,
|
||||
quickPreviewFileId: uiState.quickPreviewFileId,
|
||||
openQuickPreview,
|
||||
closeQuickPreview,
|
||||
currentFiles,
|
||||
tagModeActive,
|
||||
setCurrentFiles,
|
||||
tagModeActive: uiState.tagModeActive,
|
||||
setTagModeActive,
|
||||
devices,
|
||||
loadPreferencesForSpaceItem,
|
||||
}),
|
||||
[
|
||||
currentTarget,
|
||||
currentPath,
|
||||
currentView,
|
||||
navigateToPath,
|
||||
navigateToView,
|
||||
goBack,
|
||||
goForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
uiState.viewMode,
|
||||
setViewMode,
|
||||
uiState.sortBy,
|
||||
setSortBy,
|
||||
uiState.viewSettings,
|
||||
setViewSettings,
|
||||
uiState.sidebarVisible,
|
||||
setSidebarVisible,
|
||||
uiState.inspectorVisible,
|
||||
setInspectorVisible,
|
||||
uiState.quickPreviewFileId,
|
||||
openQuickPreview,
|
||||
closeQuickPreview,
|
||||
currentFiles,
|
||||
uiState.tagModeActive,
|
||||
setTagModeActive,
|
||||
devices,
|
||||
loadPreferencesForSpaceItem,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -454,11 +588,17 @@ export function ExplorerProvider({
|
||||
);
|
||||
}
|
||||
|
||||
export function useExplorer() {
|
||||
export function useExplorer(): ExplorerContextValue {
|
||||
const context = useContext(ExplorerContext);
|
||||
if (!context)
|
||||
throw new Error("useExplorer must be used within ExplorerProvider");
|
||||
if (!context) {
|
||||
throw new Error("useExplorer must be used within an ExplorerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { getSpaceItemKeyFromRoute };
|
||||
export {
|
||||
getSpaceItemKey,
|
||||
getSpaceItemKey as getSpaceItemKeyFromRoute,
|
||||
targetToKey,
|
||||
targetsEqual,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DirectorySortBy } from "@sd/ts-client";
|
||||
import { useTypeaheadSearch } from "./useTypeaheadSearch";
|
||||
|
||||
export function useExplorerKeyboard() {
|
||||
const { currentPath, sortBy, setCurrentPath, viewMode, viewSettings, sidebarVisible, inspectorVisible, openQuickPreview, tagModeActive, setTagModeActive } = useExplorer();
|
||||
const { currentPath, sortBy, navigateToPath, viewMode, viewSettings, sidebarVisible, inspectorVisible, openQuickPreview, tagModeActive, setTagModeActive } = useExplorer();
|
||||
const { selectedFiles, selectFile, selectAll, clearSelection, focusedIndex, setFocusedIndex, setSelectedFiles } = useSelection();
|
||||
|
||||
// Query files for keyboard operations
|
||||
@@ -41,8 +41,8 @@ export function useExplorerKeyboard() {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
// Arrow keys: Navigation
|
||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
// Skip column view - each column handles its own keyboard navigation
|
||||
if (viewMode === "column") {
|
||||
// Skip views that handle their own keyboard navigation
|
||||
if (viewMode === "column" || viewMode === "media" || viewMode === "grid") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function useExplorerKeyboard() {
|
||||
const selected = selectedFiles[0];
|
||||
if (selected.kind === "Directory") {
|
||||
e.preventDefault();
|
||||
setCurrentPath(selected.sd_path);
|
||||
navigateToPath(selected.sd_path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function useExplorerKeyboard() {
|
||||
inspectorVisible,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
setCurrentPath,
|
||||
navigateToPath,
|
||||
setFocusedIndex,
|
||||
setSelectedFiles,
|
||||
openQuickPreview,
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useFileContextMenu({
|
||||
selectedFiles,
|
||||
selected,
|
||||
}: UseFileContextMenuProps) {
|
||||
const { setCurrentPath, currentPath } = useExplorer();
|
||||
const { navigateToPath, currentPath } = useExplorer();
|
||||
const platform = usePlatform();
|
||||
const copyFiles = useLibraryMutation("files.copy");
|
||||
const deleteFiles = useLibraryMutation("files.delete");
|
||||
@@ -71,7 +71,7 @@ export function useFileContextMenu({
|
||||
label: "Open",
|
||||
onClick: () => {
|
||||
if (file.kind === "Directory") {
|
||||
setCurrentPath(file.sd_path);
|
||||
navigateToPath(file.sd_path);
|
||||
} else {
|
||||
console.log("Open file:", file.name);
|
||||
// TODO: Implement file opening
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ExplorerProvider, useExplorer, getSpaceItemKeyFromRoute } from "./context";
|
||||
export { ExplorerProvider, useExplorer, getSpaceItemKey, getSpaceItemKeyFromRoute, targetToKey, targetsEqual } from "./context";
|
||||
export type { NavigationTarget, ViewMode, ViewSettings, SortBy } from "./context";
|
||||
export { SelectionProvider, useSelection } from "./SelectionContext";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { ExplorerView } from "./ExplorerView";
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ColumnItem = memo(
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<div ref={setNodeRef} {...listeners} {...attributes} tabIndex={-1} className="outline-none focus:outline-none">
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected && !isDragging}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTypeaheadSearch } from "../../hooks/useTypeaheadSearch";
|
||||
import { useVirtualListing } from "../../hooks/useVirtualListing";
|
||||
|
||||
export function ColumnView() {
|
||||
const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer();
|
||||
const { currentPath, navigateToPath, sortBy, viewSettings } = useExplorer();
|
||||
const { files: virtualFiles, isVirtualView } = useVirtualListing();
|
||||
const {
|
||||
selectedFiles,
|
||||
@@ -53,7 +53,7 @@ export function ColumnView() {
|
||||
if (!multi && !range) {
|
||||
if (file.kind === "Directory") {
|
||||
// Truncate columns after current and add new one
|
||||
// DON'T call setCurrentPath - columnStack manages internal navigation
|
||||
// DON'T call navigateToPath - columnStack manages internal navigation
|
||||
// This prevents ExplorerLayout from re-rendering on every column change
|
||||
setColumnStack((prev) => [
|
||||
...prev.slice(0, columnIndex + 1),
|
||||
@@ -70,9 +70,9 @@ export function ColumnView() {
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: SdPath) => {
|
||||
setCurrentPath(path);
|
||||
navigateToPath(path);
|
||||
},
|
||||
[setCurrentPath],
|
||||
[navigateToPath],
|
||||
);
|
||||
|
||||
// Find the active column (the one containing the first selected file)
|
||||
@@ -144,96 +144,105 @@ export function ColumnView() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Handle arrow keys
|
||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Navigate within current column
|
||||
if (activeColumnFiles.length === 0) return;
|
||||
// Navigate within current column
|
||||
if (activeColumnFiles.length === 0) return;
|
||||
|
||||
const currentIndex =
|
||||
selectedFiles.length > 0
|
||||
? activeColumnFiles.findIndex(
|
||||
(f) => f.id === selectedFiles[0].id,
|
||||
)
|
||||
: -1;
|
||||
|
||||
const newIndex =
|
||||
e.key === "ArrowDown"
|
||||
? currentIndex < 0
|
||||
? 0
|
||||
: Math.min(
|
||||
currentIndex + 1,
|
||||
activeColumnFiles.length - 1,
|
||||
const currentIndex =
|
||||
selectedFiles.length > 0
|
||||
? activeColumnFiles.findIndex(
|
||||
(f) => f.id === selectedFiles[0].id,
|
||||
)
|
||||
: currentIndex < 0
|
||||
? 0
|
||||
: Math.max(currentIndex - 1, 0);
|
||||
: -1;
|
||||
|
||||
if (newIndex !== currentIndex && activeColumnFiles[newIndex]) {
|
||||
const newFile = activeColumnFiles[newIndex];
|
||||
handleSelectFile(
|
||||
newFile,
|
||||
activeColumnIndex,
|
||||
activeColumnFiles,
|
||||
);
|
||||
const newIndex =
|
||||
e.key === "ArrowDown"
|
||||
? currentIndex < 0
|
||||
? 0
|
||||
: Math.min(
|
||||
currentIndex + 1,
|
||||
activeColumnFiles.length - 1,
|
||||
)
|
||||
: currentIndex < 0
|
||||
? 0
|
||||
: Math.max(currentIndex - 1, 0);
|
||||
|
||||
// Scroll to keep selection visible
|
||||
const element = document.querySelector(
|
||||
`[data-file-id="${newFile.id}"]`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
// Move to previous column
|
||||
if (activeColumnIndex > 0) {
|
||||
// Truncate columns and stay at previous column
|
||||
// DON'T call setCurrentPath - columnStack manages internal navigation
|
||||
setColumnStack((prev) => prev.slice(0, activeColumnIndex));
|
||||
clearSelectionRef.current();
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
// If selected file is a directory and there's a next column, move focus there
|
||||
const firstSelected = selectedFiles[0];
|
||||
if (
|
||||
firstSelected?.kind === "Directory" &&
|
||||
activeColumnIndex < columnStack.length - 1
|
||||
) {
|
||||
// Select first item in next column
|
||||
if (nextColumnFiles.length > 0) {
|
||||
const firstFile = nextColumnFiles[0];
|
||||
if (
|
||||
newIndex !== currentIndex &&
|
||||
activeColumnFiles[newIndex]
|
||||
) {
|
||||
const newFile = activeColumnFiles[newIndex];
|
||||
handleSelectFile(
|
||||
firstFile,
|
||||
activeColumnIndex + 1,
|
||||
nextColumnFiles,
|
||||
newFile,
|
||||
activeColumnIndex,
|
||||
activeColumnFiles,
|
||||
);
|
||||
|
||||
// Scroll to keep selection visible
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(
|
||||
`[data-file-id="${firstFile.id}"]`,
|
||||
const element = document.querySelector(
|
||||
`[data-file-id="${newFile.id}"]`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
// Move to previous column
|
||||
if (activeColumnIndex > 0) {
|
||||
// Truncate columns and stay at previous column
|
||||
// DON'T call navigateToPath - columnStack manages internal navigation
|
||||
setColumnStack((prev) =>
|
||||
prev.slice(0, activeColumnIndex),
|
||||
);
|
||||
clearSelectionRef.current();
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
// If selected file is a directory and there's a next column, move focus there
|
||||
const firstSelected = selectedFiles[0];
|
||||
if (
|
||||
firstSelected?.kind === "Directory" &&
|
||||
activeColumnIndex < columnStack.length - 1
|
||||
) {
|
||||
// Select first item in next column
|
||||
if (nextColumnFiles.length > 0) {
|
||||
const firstFile = nextColumnFiles[0];
|
||||
handleSelectFile(
|
||||
firstFile,
|
||||
activeColumnIndex + 1,
|
||||
nextColumnFiles,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Scroll to keep selection visible
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(
|
||||
`[data-file-id="${firstFile.id}"]`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Typeahead search for active column
|
||||
typeahead.handleKey(e);
|
||||
};
|
||||
// Typeahead search for active column
|
||||
typeahead.handleKey(e);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const FileCard = memo(
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
}: FileCardProps) {
|
||||
const { viewSettings, setCurrentPath } = useExplorer();
|
||||
const { viewSettings, navigateToPath } = useExplorer();
|
||||
const { gridSize, showFileSize } = viewSettings;
|
||||
|
||||
const contextMenu = useFileContextMenu({
|
||||
@@ -55,13 +55,13 @@ export const FileCard = memo(
|
||||
const handleDoubleClick = () => {
|
||||
// Virtual files (locations, volumes, devices) always navigate to their sd_path
|
||||
if (isVirtualFile(file) && file.sd_path) {
|
||||
setCurrentPath(file.sd_path);
|
||||
navigateToPath(file.sd_path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular directories navigate normally
|
||||
if (file.kind === "Directory") {
|
||||
setCurrentPath(file.sd_path);
|
||||
navigateToPath(file.sd_path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,7 +127,8 @@ export const FileCard = memo(
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
data-file-id={file.id}
|
||||
className="relative"
|
||||
tabIndex={-1}
|
||||
className="relative outline-none focus:outline-none"
|
||||
>
|
||||
{/* Drop indicator for folders */}
|
||||
{isFolder && isDropOver && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useExplorer } from "../../context";
|
||||
import { useSelection } from "../../SelectionContext";
|
||||
@@ -10,240 +10,305 @@ import { useVirtualListing } from "../../hooks/useVirtualListing";
|
||||
const VIRTUALIZATION_THRESHOLD = 0; // Disabled - always virtualize
|
||||
|
||||
export function GridView() {
|
||||
const { currentPath, sortBy, viewSettings, setCurrentFiles } = useExplorer();
|
||||
const { isSelected, focusedIndex, selectedFiles, selectFile, clearSelection } = useSelection();
|
||||
const { gridSize, gapSize } = viewSettings;
|
||||
const { currentPath, sortBy, viewSettings, setCurrentFiles } =
|
||||
useExplorer();
|
||||
const {
|
||||
isSelected,
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
clearSelection,
|
||||
setSelectedFiles,
|
||||
} = useSelection();
|
||||
const { gridSize, gapSize } = viewSettings;
|
||||
|
||||
// Check for virtual listing first
|
||||
const { files: virtualFiles, isVirtualView } = useVirtualListing();
|
||||
// Check for virtual listing first
|
||||
const { files: virtualFiles, isVirtualView } = useVirtualListing();
|
||||
|
||||
const directoryQuery = useNormalizedQuery({
|
||||
wireMethod: "query:files.directory_listing",
|
||||
input: currentPath
|
||||
? {
|
||||
path: currentPath,
|
||||
limit: null,
|
||||
include_hidden: false,
|
||||
sort_by: sortBy as DirectorySortBy,
|
||||
folders_first: viewSettings.foldersFirst,
|
||||
}
|
||||
: null!,
|
||||
resourceType: "file",
|
||||
enabled: !!currentPath && !isVirtualView,
|
||||
pathScope: currentPath ?? undefined,
|
||||
});
|
||||
const directoryQuery = useNormalizedQuery({
|
||||
wireMethod: "query:files.directory_listing",
|
||||
input: currentPath
|
||||
? {
|
||||
path: currentPath,
|
||||
limit: null,
|
||||
include_hidden: false,
|
||||
sort_by: sortBy as DirectorySortBy,
|
||||
folders_first: viewSettings.foldersFirst,
|
||||
}
|
||||
: null!,
|
||||
resourceType: "file",
|
||||
enabled: !!currentPath && !isVirtualView,
|
||||
pathScope: currentPath ?? undefined,
|
||||
});
|
||||
|
||||
const files = isVirtualView ? (virtualFiles || []) : (directoryQuery.data?.files || []);
|
||||
const files = isVirtualView
|
||||
? virtualFiles || []
|
||||
: (directoryQuery.data as any)?.files || [];
|
||||
|
||||
// Update current files in explorer context for quick preview navigation
|
||||
useEffect(() => {
|
||||
setCurrentFiles(files);
|
||||
}, [files, setCurrentFiles]);
|
||||
// Update current files in explorer context for quick preview navigation
|
||||
useEffect(() => {
|
||||
setCurrentFiles(files);
|
||||
}, [files, setCurrentFiles]);
|
||||
|
||||
const handleContainerClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
const handleContainerClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
// Conditional virtualization - use simple grid for small directories
|
||||
const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD;
|
||||
// Conditional virtualization - use simple grid for small directories
|
||||
const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD;
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
className="grid p-3 min-h-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))`,
|
||||
gridAutoRows: 'max-content',
|
||||
gap: `${gapSize}px`,
|
||||
}}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
fileIndex={index}
|
||||
allFiles={files}
|
||||
selected={isSelected(file.id)}
|
||||
focused={index === focusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
className="grid p-3 min-h-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))`,
|
||||
gridAutoRows: "max-content",
|
||||
gap: `${gapSize}px`,
|
||||
}}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
fileIndex={index}
|
||||
allFiles={files}
|
||||
selected={isSelected(file.id)}
|
||||
focused={index === focusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualizedGrid
|
||||
files={files}
|
||||
gridSize={gridSize}
|
||||
gapSize={gapSize}
|
||||
isSelected={isSelected}
|
||||
focusedIndex={focusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
onContainerClick={handleContainerClick}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<VirtualizedGrid
|
||||
files={files}
|
||||
gridSize={gridSize}
|
||||
gapSize={gapSize}
|
||||
isSelected={isSelected}
|
||||
focusedIndex={focusedIndex}
|
||||
setFocusedIndex={setFocusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
onContainerClick={handleContainerClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface VirtualizedGridProps {
|
||||
files: File[];
|
||||
gridSize: number;
|
||||
gapSize: number;
|
||||
isSelected: (id: string) => boolean;
|
||||
focusedIndex: number;
|
||||
selectedFiles: File[];
|
||||
selectFile: (file: File, files: File[], multi?: boolean, range?: boolean) => void;
|
||||
onContainerClick: (e: React.MouseEvent) => void;
|
||||
files: File[];
|
||||
gridSize: number;
|
||||
gapSize: number;
|
||||
isSelected: (id: string) => boolean;
|
||||
focusedIndex: number;
|
||||
setFocusedIndex: (index: number) => void;
|
||||
selectedFiles: File[];
|
||||
selectFile: (
|
||||
file: File,
|
||||
files: File[],
|
||||
multi?: boolean,
|
||||
range?: boolean,
|
||||
) => void;
|
||||
setSelectedFiles: (files: File[]) => void;
|
||||
onContainerClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function VirtualizedGrid({
|
||||
files,
|
||||
gridSize,
|
||||
gapSize,
|
||||
isSelected,
|
||||
focusedIndex,
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
onContainerClick,
|
||||
files,
|
||||
gridSize,
|
||||
gapSize,
|
||||
isSelected,
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
setSelectedFiles,
|
||||
onContainerClick,
|
||||
}: VirtualizedGridProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Track container width with ResizeObserver
|
||||
useEffect(() => {
|
||||
const element = parentRef.current;
|
||||
if (!element) return;
|
||||
// Synchronous measurement before paint to prevent layout shift
|
||||
useLayoutEffect(() => {
|
||||
const element = parentRef.current;
|
||||
if (!element) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
const updateWidth = () => {
|
||||
const newWidth = element.offsetWidth;
|
||||
|
||||
const updateWidth = () => {
|
||||
if (rafId) return;
|
||||
if (newWidth > 0) {
|
||||
setContainerWidth(newWidth - 24);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const newWidth = element.offsetWidth;
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
if (newWidth > 0) {
|
||||
// Subtract padding (p-3 = 12px on each side)
|
||||
setContainerWidth(newWidth - 24);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Immediate measurement
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(element);
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set initial width
|
||||
updateWidth();
|
||||
// Calculate columns (mimic auto-fill behavior)
|
||||
const columns = useMemo(() => {
|
||||
if (!containerWidth) return 1;
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, []);
|
||||
// Mimic repeat(auto-fill, minmax(gridSize, 1fr))
|
||||
const minItemWidth = gridSize;
|
||||
const totalGapWidth = gapSize;
|
||||
|
||||
// Calculate columns (mimic auto-fill behavior)
|
||||
const columns = useMemo(() => {
|
||||
if (!containerWidth) return 1;
|
||||
// Calculate how many items fit
|
||||
let cols = 1;
|
||||
while (true) {
|
||||
const totalGaps = (cols - 1) * gapSize;
|
||||
const requiredWidth = cols * minItemWidth + totalGaps;
|
||||
|
||||
// Mimic repeat(auto-fill, minmax(gridSize, 1fr))
|
||||
const minItemWidth = gridSize;
|
||||
const totalGapWidth = gapSize;
|
||||
if (requiredWidth <= containerWidth) {
|
||||
cols++;
|
||||
} else {
|
||||
cols--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate how many items fit
|
||||
let cols = 1;
|
||||
while (true) {
|
||||
const totalGaps = (cols - 1) * gapSize;
|
||||
const requiredWidth = cols * minItemWidth + totalGaps;
|
||||
return Math.max(1, cols);
|
||||
}, [containerWidth, gridSize, gapSize]);
|
||||
|
||||
if (requiredWidth <= containerWidth) {
|
||||
cols++;
|
||||
} else {
|
||||
cols--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const rowCount = Math.ceil(files.length / columns);
|
||||
const rowGap = 4; // Gap between rows
|
||||
|
||||
return Math.max(1, cols);
|
||||
}, [containerWidth, gridSize, gapSize]);
|
||||
// Row virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => gridSize + gapSize + rowGap,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
const rowCount = Math.ceil(files.length / columns);
|
||||
const rowGap = 4; // Gap between rows
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
// Row virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => gridSize + gapSize + rowGap,
|
||||
overscan: 5,
|
||||
});
|
||||
// Keyboard navigation with correct column count
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
e.preventDefault();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto"
|
||||
onClick={onContainerClick}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '12px',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * columns;
|
||||
const endIndex = Math.min(startIndex + columns, files.length);
|
||||
const rowFiles = files.slice(startIndex, endIndex);
|
||||
let newIndex = focusedIndex < 0 ? 0 : focusedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 w-full px-3"
|
||||
style={{
|
||||
top: `${virtualRow.start}px`,
|
||||
height: `${gridSize + gapSize}px`,
|
||||
}}
|
||||
>
|
||||
{/* CSS Grid within row - preserves flex-to-fill */}
|
||||
<div
|
||||
className="grid h-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: `${gapSize}px`,
|
||||
}}
|
||||
>
|
||||
{rowFiles.map((file, idx) => {
|
||||
const fileIndex = startIndex + idx;
|
||||
return (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
fileIndex={fileIndex}
|
||||
allFiles={files}
|
||||
selected={isSelected(file.id)}
|
||||
focused={fileIndex === focusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (e.key === "ArrowUp") {
|
||||
newIndex = Math.max(0, newIndex - columns);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
newIndex = Math.min(files.length - 1, newIndex + columns);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
newIndex = Math.max(0, newIndex - 1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
newIndex = Math.min(files.length - 1, newIndex + 1);
|
||||
}
|
||||
|
||||
if (newIndex !== focusedIndex && files[newIndex]) {
|
||||
setFocusedIndex(newIndex);
|
||||
setSelectedFiles([files[newIndex]]);
|
||||
|
||||
// Scroll into view
|
||||
const element = document.querySelector(
|
||||
`[data-file-id="${files[newIndex].id}"]`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [files, focusedIndex, columns, setFocusedIndex, setSelectedFiles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto"
|
||||
onClick={onContainerClick}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
paddingTop: "12px",
|
||||
paddingBottom: "12px",
|
||||
minHeight: "100%",
|
||||
opacity: isInitialized ? 1 : 0,
|
||||
transition: "opacity 0.1s",
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * columns;
|
||||
const endIndex = Math.min(
|
||||
startIndex + columns,
|
||||
files.length,
|
||||
);
|
||||
const rowFiles = files.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 w-full px-3"
|
||||
style={{
|
||||
top: `${virtualRow.start}px`,
|
||||
height: `${gridSize + gapSize}px`,
|
||||
}}
|
||||
>
|
||||
{/* CSS Grid within row - preserves flex-to-fill */}
|
||||
<div
|
||||
className="grid h-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: `${gapSize}px`,
|
||||
}}
|
||||
>
|
||||
{rowFiles.map((file, idx) => {
|
||||
const fileIndex = startIndex + idx;
|
||||
return (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
fileIndex={fileIndex}
|
||||
allFiles={files}
|
||||
selected={isSelected(file.id)}
|
||||
focused={fileIndex === focusedIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
selectFile={selectFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const TableRow = memo(
|
||||
measureRef,
|
||||
selectFile,
|
||||
}: TableRowProps) {
|
||||
const { setCurrentPath } = useExplorer();
|
||||
const { navigateToPath } = useExplorer();
|
||||
const { selectedFiles } = useSelection();
|
||||
|
||||
const contextMenu = useFileContextMenu({
|
||||
@@ -64,15 +64,15 @@ export const TableRow = memo(
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
// Virtual files (locations, volumes, devices) always navigate to their sd_path
|
||||
if (isVirtualFile(file) && file.sd_path) {
|
||||
setCurrentPath(file.sd_path);
|
||||
navigateToPath(file.sd_path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular directories navigate normally
|
||||
if (file.kind === "Directory") {
|
||||
setCurrentPath(file.sd_path);
|
||||
navigateToPath(file.sd_path);
|
||||
}
|
||||
}, [file, setCurrentPath]);
|
||||
}, [file, navigateToPath]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
@@ -95,7 +95,8 @@ export const TableRow = memo(
|
||||
ref={measureRef}
|
||||
data-index={index}
|
||||
data-file-id={file.id}
|
||||
className="relative"
|
||||
tabIndex={-1}
|
||||
className="relative outline-none focus:outline-none"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useExplorer } from "../../context";
|
||||
import { useSelection } from "../../SelectionContext";
|
||||
import { useNormalizedQuery } from "../../../../context";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { MediaViewItem } from "./MediaViewItem";
|
||||
import { DateHeader, DATE_HEADER_HEIGHT } from "./DateHeader";
|
||||
import { formatDate, getItemDate, normalizeDateToMidnight } from "./utils";
|
||||
@@ -22,7 +23,7 @@ export function MediaView() {
|
||||
setSortBy,
|
||||
setCurrentFiles,
|
||||
} = useExplorer();
|
||||
const { selectedFiles, selectFile, focusedIndex, isSelected, selectedFileIds } = useSelection();
|
||||
const { selectedFiles, selectFile, focusedIndex, setFocusedIndex, setSelectedFiles, isSelected, selectedFileIds } = useSelection();
|
||||
|
||||
// Set default sort to "datetaken" when entering media view
|
||||
useEffect(() => {
|
||||
@@ -145,6 +146,49 @@ export function MediaView() {
|
||||
}
|
||||
}, [files, elementReady]);
|
||||
|
||||
// Keyboard navigation for media view
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Calculate columns based on container width
|
||||
const itemWidth = gridSize + gapSize;
|
||||
const cols = containerWidth > 0
|
||||
? Math.max(4, Math.floor(containerWidth / itemWidth))
|
||||
: 8;
|
||||
|
||||
let newIndex = focusedIndex;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
newIndex = Math.max(0, focusedIndex - cols);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
newIndex = Math.min(files.length - 1, focusedIndex + cols);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
newIndex = Math.max(0, focusedIndex - 1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
newIndex = Math.min(files.length - 1, focusedIndex + 1);
|
||||
}
|
||||
|
||||
if (newIndex !== focusedIndex && files[newIndex]) {
|
||||
setFocusedIndex(newIndex);
|
||||
setSelectedFiles([files[newIndex]]);
|
||||
|
||||
// Scroll selected item into view
|
||||
const element = document.querySelector(`[data-file-id="${files[newIndex].id}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [files, focusedIndex, gridSize, gapSize, containerWidth, setFocusedIndex, setSelectedFiles]);
|
||||
|
||||
// Calculate columns and actual item size to fill available space
|
||||
const { columns, actualItemSize } = useMemo(() => {
|
||||
@@ -347,7 +391,8 @@ export function MediaView() {
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="absolute"
|
||||
tabIndex={-1}
|
||||
className="absolute outline-none focus:outline-none"
|
||||
style={{
|
||||
top: `${rowTop}px`,
|
||||
left: `${left}px`,
|
||||
|
||||
@@ -61,8 +61,9 @@ export const MediaViewItem = memo(function MediaViewItem({
|
||||
return (
|
||||
<div
|
||||
data-file-id={file.id}
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
"relative overflow-hidden cursor-pointer transition-all w-full h-full group",
|
||||
"relative overflow-hidden cursor-pointer transition-all w-full h-full group outline-none focus:outline-none",
|
||||
selected && "ring-2 ring-accent ring-inset",
|
||||
focused && !selected && "ring-2 ring-accent/50 ring-inset",
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,7 @@ function getFileType(file: File): string {
|
||||
}
|
||||
|
||||
export function SizeView() {
|
||||
const { currentPath, sortBy, setCurrentPath, viewSettings } = useExplorer();
|
||||
const { currentPath, sortBy, navigateToPath, viewSettings } = useExplorer();
|
||||
const { selectedFiles, selectFile } = useSelection();
|
||||
|
||||
const directoryQuery = useNormalizedQuery({
|
||||
@@ -156,7 +156,7 @@ export function SizeView() {
|
||||
|
||||
// Use refs for stable function references
|
||||
const selectFileRef = useRef(selectFile);
|
||||
const setCurrentPathRef = useRef(setCurrentPath);
|
||||
const navigateToPathRef = useRef(navigateToPath);
|
||||
const filesRef = useRef(files);
|
||||
const gRef = useRef<d3.Selection<
|
||||
SVGGElement,
|
||||
@@ -168,10 +168,10 @@ export function SizeView() {
|
||||
|
||||
useEffect(() => {
|
||||
selectFileRef.current = selectFile;
|
||||
setCurrentPathRef.current = setCurrentPath;
|
||||
navigateToPathRef.current = navigateToPath;
|
||||
filesRef.current = files;
|
||||
contextMenuRef.current = contextMenu;
|
||||
}, [selectFile, setCurrentPath, files, contextMenu]);
|
||||
}, [selectFile, navigateToPath, files, contextMenu]);
|
||||
|
||||
// Initialize zoom behavior once
|
||||
useEffect(() => {
|
||||
@@ -309,6 +309,14 @@ export function SizeView() {
|
||||
};
|
||||
}, []); // Only run once
|
||||
|
||||
// Reset zoom when path changes
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !zoomBehaviorRef.current) return;
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.call(zoomBehaviorRef.current.transform, d3.zoomIdentity);
|
||||
setCurrentZoom(1);
|
||||
}, [currentPath]);
|
||||
|
||||
const bubbleData = useMemo(() => {
|
||||
const filesWithSize = files.filter((f) => f.size > 0);
|
||||
|
||||
@@ -377,49 +385,39 @@ export function SizeView() {
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// Clear any existing timeout
|
||||
const multi = event.metaKey || event.ctrlKey;
|
||||
const range = event.shiftKey;
|
||||
|
||||
// Select immediately for responsive feedback
|
||||
selectFileRef.current(
|
||||
d.data.file,
|
||||
filesRef.current,
|
||||
multi,
|
||||
range,
|
||||
);
|
||||
|
||||
// Clear any existing zoom timeout
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
clickTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Set timeout for single click
|
||||
clickTimeoutRef.current = setTimeout(() => {
|
||||
const multi = event.metaKey || event.ctrlKey;
|
||||
const range = event.shiftKey;
|
||||
selectFileRef.current(
|
||||
d.data.file,
|
||||
filesRef.current,
|
||||
multi,
|
||||
range,
|
||||
);
|
||||
// Delay zoom-to-focus to allow double-click detection
|
||||
if (!multi && !range && svgRef.current && zoomBehaviorRef.current) {
|
||||
clickTimeoutRef.current = setTimeout(() => {
|
||||
if (!svgRef.current || !zoomBehaviorRef.current) return;
|
||||
|
||||
// Zoom to center this circle
|
||||
if (
|
||||
!multi &&
|
||||
!range &&
|
||||
svgRef.current &&
|
||||
zoomBehaviorRef.current
|
||||
) {
|
||||
const svgElement = svgRef.current;
|
||||
const width = svgElement.clientWidth;
|
||||
const height = svgElement.clientHeight;
|
||||
|
||||
// Calculate the transform needed to center this circle
|
||||
const currentTransform = d3.zoomTransform(svgElement);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Target: make the bubble appear at a consistent size on screen
|
||||
// regardless of its original size
|
||||
const targetBubbleScreenSize =
|
||||
Math.min(width, height) * 0.4; // 40% of viewport
|
||||
const bubbleSize = d.r * 2; // diameter in data coordinates
|
||||
|
||||
// Calculate what scale would make this bubble that size on screen
|
||||
const targetBubbleScreenSize = Math.min(width, height) * 0.4;
|
||||
const bubbleSize = d.r * 2;
|
||||
const targetScale = targetBubbleScreenSize / bubbleSize;
|
||||
|
||||
// Create new transform
|
||||
const newTransform = d3.zoomIdentity
|
||||
.translate(centerX, centerY)
|
||||
.scale(targetScale)
|
||||
@@ -427,13 +425,13 @@ export function SizeView() {
|
||||
|
||||
d3.select(svgElement)
|
||||
.transition()
|
||||
.duration(500)
|
||||
.duration(400)
|
||||
.call(
|
||||
zoomBehaviorRef.current.transform,
|
||||
zoomBehaviorRef.current!.transform,
|
||||
newTransform,
|
||||
);
|
||||
}
|
||||
}, 250); // 250ms delay to detect double click
|
||||
}, 200);
|
||||
}
|
||||
})
|
||||
.on("dblclick", (event, d) => {
|
||||
event.stopPropagation();
|
||||
@@ -446,7 +444,7 @@ export function SizeView() {
|
||||
|
||||
// Navigate if directory
|
||||
if (d.data.file.kind === "Directory") {
|
||||
setCurrentPathRef.current(d.data.file.sd_path);
|
||||
navigateToPathRef.current(d.data.file.sd_path);
|
||||
}
|
||||
})
|
||||
.on("contextmenu", async (event, d) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) {
|
||||
const [showOnlyRunning, setShowOnlyRunning] = useState(true);
|
||||
|
||||
// Unified hook for job data and badge/icon
|
||||
const { activeJobCount, hasRunningJobs, jobs, pause, resume } = useJobs();
|
||||
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs();
|
||||
|
||||
// Reset filter to "active only" when popover opens
|
||||
useEffect(() => {
|
||||
@@ -97,6 +97,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) {
|
||||
setShowOnlyRunning={setShowOnlyRunning}
|
||||
pause={pause}
|
||||
resume={resume}
|
||||
cancel={cancel}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
@@ -109,12 +110,14 @@ function JobManagerPopoverContent({
|
||||
setShowOnlyRunning,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
}: {
|
||||
jobs: any[];
|
||||
showOnlyRunning: boolean;
|
||||
setShowOnlyRunning: (value: boolean) => void;
|
||||
pause: (jobId: string) => Promise<void>;
|
||||
resume: (jobId: string) => Promise<void>;
|
||||
cancel: (jobId: string) => Promise<void>;
|
||||
}) {
|
||||
const filteredJobs = showOnlyRunning
|
||||
? jobs.filter((job) => job.status === "running" || job.status === "paused")
|
||||
@@ -132,7 +135,7 @@ function JobManagerPopoverContent({
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
|
||||
>
|
||||
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} />
|
||||
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pause, Play } from "@phosphor-icons/react";
|
||||
import { Pause, Play, X } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { JobListItem } from "../types";
|
||||
@@ -6,137 +6,182 @@ import { getJobDisplayName, formatDuration, timeAgo } from "../types";
|
||||
import { JobStatusIndicator } from "../components/JobStatusIndicator";
|
||||
|
||||
interface JobRowProps {
|
||||
job: JobListItem;
|
||||
onPause?: (jobId: string) => void;
|
||||
onResume?: (jobId: string) => void;
|
||||
job: JobListItem;
|
||||
onPause?: (jobId: string) => void;
|
||||
onResume?: (jobId: string) => void;
|
||||
onCancel?: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export function JobRow({ job, onPause, onResume }: JobRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
export function JobRow({ job, onPause, onResume, onCancel }: JobRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const displayName = getJobDisplayName(job);
|
||||
const showActionButton = job.status === "running" || job.status === "paused";
|
||||
const canPause = job.status === "running" && onPause;
|
||||
const canResume = job.status === "paused" && onResume;
|
||||
const displayName = getJobDisplayName(job);
|
||||
const showActionButton =
|
||||
job.status === "running" || job.status === "paused";
|
||||
const canPause = job.status === "running" && onPause;
|
||||
const canResume = job.status === "paused" && onResume;
|
||||
const canCancel = (job.status === "running" || job.status === "paused") && onCancel;
|
||||
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (canPause) {
|
||||
onPause(job.id);
|
||||
} else if (canResume) {
|
||||
onResume(job.id);
|
||||
}
|
||||
};
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (canPause) {
|
||||
onPause(job.id);
|
||||
} else if (canResume) {
|
||||
onResume(job.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Format progress percentage
|
||||
const progressPercent = Math.round(job.progress * 100);
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (canCancel) {
|
||||
onCancel(job.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Get phase and message
|
||||
const phase = job.current_phase;
|
||||
const message = job.status_message;
|
||||
// Format progress percentage
|
||||
const progressPercent = Math.round(job.progress * 100);
|
||||
|
||||
// Calculate duration
|
||||
const duration = job.completed_at
|
||||
? new Date(job.completed_at).getTime() - new Date(job.created_at).getTime()
|
||||
: Date.now() - new Date(job.created_at).getTime();
|
||||
// Get phase and message
|
||||
const phase = job.current_phase;
|
||||
const message = job.status_message;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-4 px-4 py-3 border-b border-app-line/30",
|
||||
"hover:bg-app-hover/50 transition-colors cursor-pointer"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<JobStatusIndicator job={job} />
|
||||
</div>
|
||||
// Calculate duration - prefer started_at for accuracy, fallback to created_at
|
||||
const startTime = job.started_at || job.created_at;
|
||||
const duration = startTime
|
||||
? job.completed_at
|
||||
? new Date(job.completed_at).getTime() -
|
||||
new Date(startTime).getTime()
|
||||
: Date.now() - new Date(startTime).getTime()
|
||||
: 0;
|
||||
|
||||
{/* Main info */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-6">
|
||||
{/* Job name and details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-medium text-ink truncate">
|
||||
{displayName}
|
||||
</h3>
|
||||
{phase && (
|
||||
<span className="text-xs text-ink-dull px-2 py-0.5 rounded-full bg-app-box">
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
<p className="text-xs text-ink-dull truncate">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-4 px-4 py-3 border-b border-app-line/30",
|
||||
"hover:bg-app-hover/20",
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<JobStatusIndicator job={job} />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{(job.status === "running" || job.status === "paused") && (
|
||||
<div className="flex-shrink-0 w-32">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-app-line/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-ink-dull w-8 text-right">
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Main info */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-6">
|
||||
{/* Job name and details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm mt-1 font-medium text-ink truncate">
|
||||
{displayName}
|
||||
</h3>
|
||||
{phase && (
|
||||
<span className="text-xs text-ink-dull px-2 py-0.5 rounded-full bg-app-box">
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
<p className="text-xs text-ink-dull truncate">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
<span className="text-xs text-ink-dull">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress / Duration column */}
|
||||
<div className="flex-shrink-0 w-32">
|
||||
{job.status === "running" || job.status === "paused" ? (
|
||||
// Show progress bar for active jobs
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-app-line/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-ink-dull w-8 text-right">
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
) : job.status === "completed" ? (
|
||||
// Show duration for completed jobs
|
||||
<span className="text-xs text-ink-dull">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
) : job.status === "queued" ? (
|
||||
// Show waiting status for queued jobs
|
||||
<span className="text-xs text-ink-dull">
|
||||
Waiting...
|
||||
</span>
|
||||
) : (
|
||||
// Show dash for failed/cancelled jobs
|
||||
<span className="text-xs text-ink-dull">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Created time */}
|
||||
<div className="flex-shrink-0 w-24 text-right">
|
||||
<span className="text-xs text-ink-dull">
|
||||
{timeAgo(job.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Completed/Started time */}
|
||||
<div className="flex-shrink-0 w-24 text-right">
|
||||
<span className="text-xs text-ink-dull">
|
||||
{job.status === "completed" && job.completed_at
|
||||
? timeAgo(job.completed_at)
|
||||
: job.status === "running" && job.started_at
|
||||
? timeAgo(job.started_at)
|
||||
: job.created_at
|
||||
? timeAgo(job.created_at)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
job.status === "running" && "bg-accent/10 text-accent",
|
||||
job.status === "completed" && "bg-app-line/30 text-ink-dull",
|
||||
job.status === "failed" && "bg-red-500/10 text-red-500",
|
||||
job.status === "paused" && "bg-yellow-500/10 text-yellow-500",
|
||||
job.status === "queued" && "bg-app-line/20 text-ink-dull"
|
||||
)}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Status */}
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
job.status === "running" &&
|
||||
"bg-accent/10 text-accent",
|
||||
job.status === "completed" &&
|
||||
"bg-app-line/30 text-ink-dull",
|
||||
job.status === "failed" &&
|
||||
"bg-red-500/10 text-red-500",
|
||||
job.status === "paused" &&
|
||||
"bg-yellow-500/10 text-yellow-500",
|
||||
job.status === "queued" &&
|
||||
"bg-app-line/20 text-ink-dull",
|
||||
)}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{showActionButton && isHovered && (canPause || canResume) && (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-app-selected transition-colors"
|
||||
title={canPause ? "Pause job" : "Resume job"}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={12} weight="fill" className="text-ink" />
|
||||
) : (
|
||||
<Play size={12} weight="fill" className="text-ink" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Action buttons */}
|
||||
{isHovered && (
|
||||
<div className="flex items-center gap-1">
|
||||
{showActionButton && (canPause || canResume) && (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-app-selected transition-colors"
|
||||
title={canPause ? "Pause job" : "Resume job"}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={12} weight="fill" className="text-ink" />
|
||||
) : (
|
||||
<Play size={12} weight="fill" className="text-ink" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-red-500 transition-colors"
|
||||
title="Cancel job"
|
||||
>
|
||||
<X size={12} weight="bold" className="text-ink hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,151 +6,206 @@ import { useJobs } from "../hooks/useJobs";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
export function JobsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { jobs, pause, resume } = useJobs();
|
||||
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { jobs, pause, resume, cancel } = useJobs();
|
||||
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
|
||||
|
||||
// Filter jobs based on toggle
|
||||
const filteredJobs = showOnlyRunning
|
||||
? jobs.filter(job => job.status === "running" || job.status === "paused")
|
||||
: jobs;
|
||||
// Filter jobs based on toggle
|
||||
const filteredJobs = showOnlyRunning
|
||||
? jobs.filter(
|
||||
(job) => job.status === "running" || job.status === "paused",
|
||||
)
|
||||
: jobs;
|
||||
|
||||
// Group jobs by status
|
||||
const runningJobs = filteredJobs.filter(j => j.status === "running");
|
||||
const pausedJobs = filteredJobs.filter(j => j.status === "paused");
|
||||
const queuedJobs = filteredJobs.filter(j => j.status === "queued");
|
||||
const completedJobs = filteredJobs.filter(j => j.status === "completed");
|
||||
const failedJobs = filteredJobs.filter(j => j.status === "failed");
|
||||
// Group jobs by status
|
||||
const runningJobs = filteredJobs.filter((j) => j.status === "running");
|
||||
const pausedJobs = filteredJobs.filter((j) => j.status === "paused");
|
||||
const queuedJobs = filteredJobs.filter((j) => j.status === "queued");
|
||||
const completedJobs = filteredJobs.filter((j) => j.status === "completed");
|
||||
const failedJobs = filteredJobs.filter((j) => j.status === "failed");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-app">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 backdrop-blur-xl bg-app/80 border-b border-app-line">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-ink">Jobs</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-ink-dull">
|
||||
<span>{jobs.length} total</span>
|
||||
{runningJobs.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{runningJobs.length} running</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-app">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 backdrop-blur-xl bg-app/80 border-b border-app-line">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-ink">Jobs</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-ink-dull">
|
||||
<span>{jobs.length} total</span>
|
||||
{runningJobs.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{runningJobs.length} running</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter toggle */}
|
||||
<TopBarButton
|
||||
icon={FunnelSimple}
|
||||
active={showOnlyRunning}
|
||||
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
|
||||
title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter toggle */}
|
||||
<TopBarButton
|
||||
icon={FunnelSimple}
|
||||
active={showOnlyRunning}
|
||||
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
|
||||
title={
|
||||
showOnlyRunning
|
||||
? "Show all jobs"
|
||||
: "Show only active jobs"
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Back button */}
|
||||
<TopBarButton
|
||||
icon={X}
|
||||
onClick={() => navigate(-1)}
|
||||
title="Go back"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Back button */}
|
||||
<TopBarButton
|
||||
icon={X}
|
||||
onClick={() => navigate(-1)}
|
||||
title="Go back"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-ink-dull uppercase tracking-wide bg-app-box/30 border-t border-app-line/30">
|
||||
<div className="flex-shrink-0 w-10" /> {/* Icon spacer */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-6">
|
||||
<div className="flex-1">Name</div>
|
||||
<div className="flex-shrink-0 w-32">Progress</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">Duration</div>
|
||||
<div className="flex-shrink-0 w-24 text-right">Created</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">Status</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-6" /> {/* Action button spacer */}
|
||||
</div>
|
||||
</div>
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-ink-dull uppercase tracking-wide bg-app-box/30 border-t border-app-line/30">
|
||||
<div className="flex-shrink-0 w-10" /> {/* Icon spacer */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-6">
|
||||
<div className="flex-1">Name</div>
|
||||
<div className="flex-shrink-0 w-32">Duration</div>
|
||||
<div className="flex-shrink-0 w-24 text-right">
|
||||
Time
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
Status
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-6" />{" "}
|
||||
{/* Action button spacer */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-ink-dull">No jobs found</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Running Jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
<JobSection title="Running" count={runningJobs.length}>
|
||||
{runningJobs.map(job => (
|
||||
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-ink-dull">
|
||||
No jobs found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Running Jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
<JobSection
|
||||
title="Running"
|
||||
count={runningJobs.length}
|
||||
>
|
||||
{runningJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
|
||||
{/* Paused Jobs */}
|
||||
{pausedJobs.length > 0 && (
|
||||
<JobSection title="Paused" count={pausedJobs.length}>
|
||||
{pausedJobs.map(job => (
|
||||
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
{/* Paused Jobs */}
|
||||
{pausedJobs.length > 0 && (
|
||||
<JobSection
|
||||
title="Paused"
|
||||
count={pausedJobs.length}
|
||||
>
|
||||
{pausedJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
|
||||
{/* Queued Jobs */}
|
||||
{queuedJobs.length > 0 && (
|
||||
<JobSection title="Queued" count={queuedJobs.length}>
|
||||
{queuedJobs.map(job => (
|
||||
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
{/* Queued Jobs */}
|
||||
{queuedJobs.length > 0 && (
|
||||
<JobSection
|
||||
title="Queued"
|
||||
count={queuedJobs.length}
|
||||
>
|
||||
{queuedJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
|
||||
{/* Completed Jobs */}
|
||||
{completedJobs.length > 0 && (
|
||||
<JobSection title="Completed" count={completedJobs.length}>
|
||||
{completedJobs.map(job => (
|
||||
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
{/* Completed Jobs */}
|
||||
{completedJobs.length > 0 && (
|
||||
<JobSection
|
||||
title="Completed"
|
||||
count={completedJobs.length}
|
||||
>
|
||||
{completedJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
|
||||
{/* Failed Jobs */}
|
||||
{failedJobs.length > 0 && (
|
||||
<JobSection title="Failed" count={failedJobs.length}>
|
||||
{failedJobs.map(job => (
|
||||
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Failed Jobs */}
|
||||
{failedJobs.length > 0 && (
|
||||
<JobSection
|
||||
title="Failed"
|
||||
count={failedJobs.length}
|
||||
>
|
||||
{failedJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
))}
|
||||
</JobSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface JobSectionProps {
|
||||
title: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function JobSection({ title, count, children }: JobSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-[73px] z-10 flex items-center gap-2 px-4 py-2 bg-app-box/50 backdrop-blur-sm border-b border-app-line/50">
|
||||
<h2 className="text-xs font-semibold text-ink uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-ink-dull">({count})</span>
|
||||
</div>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-app-box/50 backdrop-blur-sm border-b border-app-line/50">
|
||||
<h2 className="text-xs font-semibold text-ink uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-ink-dull">({count})</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Pause, Play } from "@phosphor-icons/react";
|
||||
import { Pause, Play, X } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import type { JobListItem } from "../types";
|
||||
import {
|
||||
@@ -15,9 +15,10 @@ interface JobCardProps {
|
||||
job: JobListItem;
|
||||
onPause?: (jobId: string) => void;
|
||||
onResume?: (jobId: string) => void;
|
||||
onCancel?: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export function JobCard({ job, onPause, onResume }: JobCardProps) {
|
||||
export function JobCard({ job, onPause, onResume, onCancel }: JobCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const displayName = getJobDisplayName(job);
|
||||
@@ -27,6 +28,7 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
|
||||
const showActionButton = job.status === "running" || job.status === "paused";
|
||||
const canPause = job.status === "running" && onPause;
|
||||
const canResume = job.status === "paused" && onResume;
|
||||
const canCancel = (job.status === "running" || job.status === "paused") && onCancel;
|
||||
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -37,6 +39,13 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (canCancel) {
|
||||
onCancel(job.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex rounded-xl border border-app-line/30 bg-app-box overflow-hidden"
|
||||
@@ -62,18 +71,31 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
|
||||
{statusBadge}
|
||||
</span>
|
||||
|
||||
{showActionButton && isHovered && (canPause || canResume) && (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-app-selected transition-colors"
|
||||
title={canPause ? "Pause job" : "Resume job"}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={10} weight="fill" className="text-ink" />
|
||||
) : (
|
||||
<Play size={10} weight="fill" className="text-ink" />
|
||||
{isHovered && (
|
||||
<div className="flex items-center gap-1">
|
||||
{showActionButton && (canPause || canResume) && (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-app-selected transition-colors"
|
||||
title={canPause ? "Pause job" : "Resume job"}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={10} weight="fill" className="text-ink" />
|
||||
) : (
|
||||
<Play size={10} weight="fill" className="text-ink" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-red-500 transition-colors"
|
||||
title="Cancel job"
|
||||
>
|
||||
<X size={10} weight="bold" className="text-ink hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ interface JobListProps {
|
||||
jobs: JobListItem[];
|
||||
onPause?: (jobId: string) => void;
|
||||
onResume?: (jobId: string) => void;
|
||||
onCancel?: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export function JobList({ jobs, onPause, onResume }: JobListProps) {
|
||||
export function JobList({ jobs, onPause, onResume, onCancel }: JobListProps) {
|
||||
if (jobs.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
@@ -25,7 +26,7 @@ export function JobList({ jobs, onPause, onResume }: JobListProps) {
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }}
|
||||
>
|
||||
<JobCard job={job} onPause={onPause} onResume={onResume} />
|
||||
<JobCard job={job} onPause={onPause} onResume={onResume} onCancel={onCancel} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useLibraryQuery, useLibraryMutation, useSpacedriveClient } from "../../../context";
|
||||
import type { JobListItem } from "../types";
|
||||
import { sounds } from "@sd/assets/sounds";
|
||||
|
||||
// Global set to track which jobs have already played their completion sound
|
||||
// This prevents multiple hook instances from playing the sound multiple times
|
||||
const completedJobSounds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Unified hook for job management and counting.
|
||||
@@ -20,6 +25,7 @@ export function useJobs() {
|
||||
|
||||
const pauseMutation = useLibraryMutation("jobs.pause");
|
||||
const resumeMutation = useLibraryMutation("jobs.resume");
|
||||
const cancelMutation = useLibraryMutation("jobs.cancel");
|
||||
|
||||
// Ref for stable refetch access
|
||||
const refetchRef = useRef(refetch);
|
||||
@@ -44,6 +50,16 @@ export function useJobs() {
|
||||
if ("JobQueued" in event || "JobStarted" in event || "JobCompleted" in event ||
|
||||
"JobFailed" in event || "JobPaused" in event || "JobResumed" in event ||
|
||||
"JobCancelled" in event) {
|
||||
if ("JobCompleted" in event) {
|
||||
const jobId = event.JobCompleted?.job_id;
|
||||
if (jobId && !completedJobSounds.has(jobId)) {
|
||||
completedJobSounds.add(jobId);
|
||||
sounds.jobDone();
|
||||
|
||||
// Clean up old entries after 5 seconds to prevent memory leak
|
||||
setTimeout(() => completedJobSounds.delete(jobId), 5000);
|
||||
}
|
||||
}
|
||||
refetchRef.current();
|
||||
} else if ("JobProgress" in event) {
|
||||
const progressData = event.JobProgress;
|
||||
@@ -88,11 +104,36 @@ export function useJobs() {
|
||||
}, [client]);
|
||||
|
||||
const pause = async (jobId: string) => {
|
||||
await pauseMutation.mutateAsync({ job_id: jobId });
|
||||
try {
|
||||
const result = await pauseMutation.mutateAsync({ job_id: jobId });
|
||||
if (!result.success) {
|
||||
console.error("Failed to pause job:", jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to pause job:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const resume = async (jobId: string) => {
|
||||
await resumeMutation.mutateAsync({ job_id: jobId });
|
||||
try {
|
||||
const result = await resumeMutation.mutateAsync({ job_id: jobId });
|
||||
if (!result.success) {
|
||||
console.error("Failed to resume job:", jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resume job:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async (jobId: string) => {
|
||||
try {
|
||||
const result = await cancelMutation.mutateAsync({ job_id: jobId });
|
||||
if (!result.success) {
|
||||
console.error("Failed to cancel job:", jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel job:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const runningCount = jobs.filter((j) => j.status === "running").length;
|
||||
@@ -104,6 +145,7 @@ export function useJobs() {
|
||||
hasRunningJobs: runningCount > 0,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Cube,
|
||||
} from "@phosphor-icons/react";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import type { VideoControlsState, VideoControlsCallbacks } from "./VideoControls";
|
||||
import { AudioPlayer } from "./AudioPlayer";
|
||||
import { useZoomPan } from "./useZoomPan";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
@@ -37,6 +38,9 @@ const MeshViewerUI = lazy(() =>
|
||||
interface ContentRendererProps {
|
||||
file: File;
|
||||
onZoomChange?: (isZoomed: boolean) => void;
|
||||
onVideoControlsStateChange?: (state: VideoControlsState) => void;
|
||||
onShowVideoControlsChange?: (show: boolean) => void;
|
||||
getVideoCallbacks?: (callbacks: VideoControlsCallbacks) => void;
|
||||
}
|
||||
|
||||
function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
@@ -388,7 +392,7 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
function VideoRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) {
|
||||
const platform = usePlatform();
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
|
||||
@@ -444,7 +448,14 @@ function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoPlayer src={videoUrl} file={file} onZoomChange={onZoomChange} />
|
||||
<VideoPlayer
|
||||
src={videoUrl}
|
||||
file={file}
|
||||
onZoomChange={onZoomChange}
|
||||
onControlsStateChange={onVideoControlsStateChange}
|
||||
onShowControlsChange={onShowVideoControlsChange}
|
||||
getCallbacks={getVideoCallbacks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -614,7 +625,7 @@ function DefaultRenderer({ file }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
export function ContentRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) {
|
||||
// Handle directories first
|
||||
if (file.kind.type === "Directory") {
|
||||
return (
|
||||
@@ -639,7 +650,7 @@ export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
case "image":
|
||||
return <ImageRenderer file={file} onZoomChange={onZoomChange} />;
|
||||
case "video":
|
||||
return <VideoRenderer file={file} onZoomChange={onZoomChange} />;
|
||||
return <VideoRenderer file={file} onZoomChange={onZoomChange} onVideoControlsStateChange={onVideoControlsStateChange} onShowVideoControlsChange={onShowVideoControlsChange} getVideoCallbacks={getVideoCallbacks} />;
|
||||
case "audio":
|
||||
return <AudioRenderer file={file} />;
|
||||
case "mesh":
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ArrowLeft, ArrowRight } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { useNormalizedQuery } from "../../context";
|
||||
import { ContentRenderer } from "./ContentRenderer";
|
||||
import {
|
||||
VideoControls,
|
||||
type VideoControlsState,
|
||||
type VideoControlsCallbacks,
|
||||
} from "./VideoControls";
|
||||
import { TopBarPortal } from "../../TopBar";
|
||||
import { getContentKind } from "../Explorer/utils";
|
||||
import { useExplorer } from "../Explorer/context";
|
||||
|
||||
interface QuickPreviewFullscreenProps {
|
||||
fileId: string;
|
||||
@@ -35,23 +40,27 @@ export function QuickPreviewFullscreen({
|
||||
}: QuickPreviewFullscreenProps) {
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
const [videoControlsState, setVideoControlsState] =
|
||||
useState<VideoControlsState | null>(null);
|
||||
const [showVideoControls, setShowVideoControls] = useState(false);
|
||||
const [videoCallbacks, setVideoCallbacks] =
|
||||
useState<VideoControlsCallbacks | null>(null);
|
||||
const { currentFiles } = useExplorer();
|
||||
|
||||
// Reset zoom when file changes
|
||||
useEffect(() => {
|
||||
setIsZoomed(false);
|
||||
}, [fileId]);
|
||||
|
||||
const {
|
||||
data: file,
|
||||
isLoading,
|
||||
error,
|
||||
} = useNormalizedQuery<{ file_id: string }, File>({
|
||||
wireMethod: "query:files.by_id",
|
||||
input: { file_id: fileId },
|
||||
resourceType: "file",
|
||||
resourceId: fileId,
|
||||
enabled: !!fileId && isOpen,
|
||||
});
|
||||
// Get file directly from currentFiles - instant, no network request
|
||||
const file = useMemo(
|
||||
() => currentFiles.find((f) => f.id === fileId) ?? null,
|
||||
[currentFiles, fileId],
|
||||
);
|
||||
|
||||
// No query needed - files are already loaded by the explorer views
|
||||
const isLoading = false;
|
||||
const error = null;
|
||||
|
||||
// Find portal target on mount
|
||||
useEffect(() => {
|
||||
@@ -107,11 +116,11 @@ export function QuickPreviewFullscreen({
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`absolute inset-0 flex flex-col ${getBackgroundClass()}`}
|
||||
>
|
||||
{isLoading || !file ? (
|
||||
{!file && isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-ink">
|
||||
<div className="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : !file && error ? (
|
||||
<div className="flex h-full items-center justify-center text-red-400">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-medium">
|
||||
@@ -120,6 +129,10 @@ export function QuickPreviewFullscreen({
|
||||
<div className="text-sm">{error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !file ? (
|
||||
<div className="flex h-full items-center justify-center text-ink-dull">
|
||||
<div>File not found</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TopBar content via portal */}
|
||||
@@ -174,14 +187,45 @@ export function QuickPreviewFullscreen({
|
||||
style={{
|
||||
paddingLeft: isZoomed ? 0 : sidebarWidth,
|
||||
paddingRight: isZoomed ? 0 : inspectorWidth,
|
||||
transition: "padding 0.3s ease-out",
|
||||
}}
|
||||
>
|
||||
<ContentRenderer
|
||||
file={file}
|
||||
onZoomChange={setIsZoomed}
|
||||
onVideoControlsStateChange={
|
||||
setVideoControlsState
|
||||
}
|
||||
onShowVideoControlsChange={
|
||||
setShowVideoControls
|
||||
}
|
||||
getVideoCallbacks={setVideoCallbacks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video Controls Overlay - fixed position, always uses sidebar/inspector padding */}
|
||||
{videoControlsState &&
|
||||
videoCallbacks &&
|
||||
getContentKind(file) === "video" && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
paddingTop: "56px", // TopBar height
|
||||
paddingBottom: "40px", // Footer height
|
||||
pointerEvents: "none", // Let clicks through except on controls themselves
|
||||
}}
|
||||
>
|
||||
<VideoControls
|
||||
file={file}
|
||||
state={videoControlsState}
|
||||
callbacks={videoCallbacks}
|
||||
showControls={showVideoControls}
|
||||
sidebarWidth={sidebarWidth}
|
||||
inspectorWidth={inspectorWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 py-3">
|
||||
<div className="text-center text-xs text-white/50">
|
||||
|
||||
@@ -7,6 +7,8 @@ interface TimelineScrubberProps {
|
||||
hoverPercent: number;
|
||||
mouseX: number;
|
||||
duration: number;
|
||||
sidebarWidth?: number;
|
||||
inspectorWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,8 @@ export const TimelineScrubber = memo(function TimelineScrubber({
|
||||
hoverPercent,
|
||||
mouseX,
|
||||
duration,
|
||||
sidebarWidth = 0,
|
||||
inspectorWidth = 0,
|
||||
}: TimelineScrubberProps) {
|
||||
const { buildSidecarUrl } = useServer();
|
||||
|
||||
@@ -75,12 +79,15 @@ export const TimelineScrubber = memo(function TimelineScrubber({
|
||||
const previewWidth = 160;
|
||||
const previewHeight = 90;
|
||||
|
||||
// Position horizontally following mouse, clamped to screen bounds
|
||||
// Position horizontally following mouse, clamped to controls bounds
|
||||
// Adjust for sidebar offset and clamp within the controls area
|
||||
const controlsWidth = window.innerWidth - sidebarWidth - inspectorWidth;
|
||||
const mouseXRelativeToControls = mouseX - sidebarWidth;
|
||||
const leftPosition = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
mouseX - previewWidth / 2,
|
||||
window.innerWidth - previewWidth - 10,
|
||||
mouseXRelativeToControls - previewWidth / 2,
|
||||
controlsWidth - previewWidth - 10,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -89,10 +96,10 @@ export const TimelineScrubber = memo(function TimelineScrubber({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none"
|
||||
className="absolute z-50 pointer-events-none"
|
||||
style={{
|
||||
left: leftPosition,
|
||||
bottom: 160, // Well above the timeline
|
||||
bottom: 80, // Just above the timeline
|
||||
width: previewWidth,
|
||||
}}
|
||||
>
|
||||
|
||||
288
packages/interface/src/components/QuickPreview/VideoControls.tsx
Normal file
288
packages/interface/src/components/QuickPreview/VideoControls.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
ArrowsOut,
|
||||
ClosedCaptioning,
|
||||
MagnifyingGlassPlus,
|
||||
MagnifyingGlassMinus,
|
||||
ArrowCounterClockwise,
|
||||
Gear,
|
||||
Repeat,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { TimelineScrubber } from "./TimelineScrubber";
|
||||
|
||||
export interface VideoControlsState {
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
loop: boolean;
|
||||
zoom: number;
|
||||
subtitlesEnabled: boolean;
|
||||
showSubtitleSettings: boolean;
|
||||
seeking: boolean;
|
||||
timelineHover: { percent: number; mouseX: number } | null;
|
||||
}
|
||||
|
||||
export interface VideoControlsCallbacks {
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onTimelineHover: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onTimelineLeave: () => void;
|
||||
onSeekingStart: () => void;
|
||||
onSeekingEnd: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onMuteToggle: () => void;
|
||||
onLoopToggle: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomReset: () => void;
|
||||
onSubtitlesToggle: () => void;
|
||||
onSubtitleSettingsToggle: () => void;
|
||||
onFullscreenToggle: () => void;
|
||||
onMouseMove: () => void;
|
||||
}
|
||||
|
||||
interface VideoControlsProps {
|
||||
file: File;
|
||||
state: VideoControlsState;
|
||||
callbacks: VideoControlsCallbacks;
|
||||
showControls: boolean;
|
||||
sidebarWidth?: number;
|
||||
inspectorWidth?: number;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function VideoControls({
|
||||
file,
|
||||
state,
|
||||
callbacks,
|
||||
showControls,
|
||||
sidebarWidth = 0,
|
||||
inspectorWidth = 0,
|
||||
}: VideoControlsProps) {
|
||||
const hasSubs = file.sidecars?.some(
|
||||
(s) => s.kind === "transcript" && s.variant === "srt",
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute bottom-0 z-50 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-4 pt-16"
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
left: sidebarWidth,
|
||||
right: inspectorWidth,
|
||||
}}
|
||||
onMouseMove={callbacks.onMouseMove}
|
||||
>
|
||||
{/* Timeline Scrubber Preview */}
|
||||
{state.timelineHover && (
|
||||
<TimelineScrubber
|
||||
file={file}
|
||||
hoverPercent={state.timelineHover.percent}
|
||||
mouseX={state.timelineHover.mouseX}
|
||||
duration={state.duration}
|
||||
sidebarWidth={sidebarWidth}
|
||||
inspectorWidth={inspectorWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress Bar with Thick Hover Area */}
|
||||
<div
|
||||
className="group mb-3 cursor-pointer relative py-2 -my-2"
|
||||
onMouseDown={(e) => {
|
||||
callbacks.onSeekingStart();
|
||||
callbacks.onSeek(e);
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (state.seeking) {
|
||||
callbacks.onSeek(e);
|
||||
} else {
|
||||
callbacks.onTimelineHover(e);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={callbacks.onTimelineHover}
|
||||
onMouseUp={callbacks.onSeekingEnd}
|
||||
onMouseLeave={callbacks.onTimelineLeave}
|
||||
>
|
||||
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/20 transition-all group-hover:h-1.5">
|
||||
{/* Progress */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-accent transition-all"
|
||||
style={{
|
||||
width: `${(state.currentTime / state.duration) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 transition-all"
|
||||
style={{
|
||||
left: `${(state.currentTime / state.duration) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className="size-3 -translate-x-1/2 rounded-full bg-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={callbacks.onTogglePlay}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
{state.playing ? (
|
||||
<Pause size={20} weight="fill" />
|
||||
) : (
|
||||
<Play size={20} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Loop */}
|
||||
<button
|
||||
onClick={callbacks.onLoopToggle}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
state.loop
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-white hover:bg-white/10"
|
||||
}`}
|
||||
title="Loop (L)"
|
||||
>
|
||||
<Repeat size={20} weight="bold" />
|
||||
</button>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-sm font-medium text-white">
|
||||
{formatTime(state.currentTime)} /{" "}
|
||||
{formatTime(state.duration)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Subtitles Controls */}
|
||||
{hasSubs && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={callbacks.onSubtitlesToggle}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
state.subtitlesEnabled
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-white hover:bg-white/10"
|
||||
}`}
|
||||
title="Toggle subtitles"
|
||||
>
|
||||
<ClosedCaptioning size={20} weight="fill" />
|
||||
</button>
|
||||
{state.subtitlesEnabled && (
|
||||
<button
|
||||
onClick={
|
||||
callbacks.onSubtitleSettingsToggle
|
||||
}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
title="Subtitle settings"
|
||||
>
|
||||
<Gear size={20} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={callbacks.onZoomOut}
|
||||
disabled={state.zoom <= 1}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
title="Zoom out (-)"
|
||||
>
|
||||
<MagnifyingGlassMinus size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={callbacks.onZoomIn}
|
||||
disabled={state.zoom >= 5}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
title="Zoom in (+)"
|
||||
>
|
||||
<MagnifyingGlassPlus size={18} weight="bold" />
|
||||
</button>
|
||||
{state.zoom > 1 && (
|
||||
<button
|
||||
onClick={callbacks.onZoomReset}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
title="Reset zoom (0)"
|
||||
>
|
||||
<ArrowCounterClockwise
|
||||
size={18}
|
||||
weight="bold"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="group flex items-center gap-2">
|
||||
<button
|
||||
onClick={callbacks.onMuteToggle}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
{state.muted || state.volume === 0 ? (
|
||||
<SpeakerSlash size={20} weight="fill" />
|
||||
) : (
|
||||
<SpeakerHigh size={20} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<div className="w-0 overflow-hidden transition-all group-hover:w-20">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={state.volume}
|
||||
onChange={(e) =>
|
||||
callbacks.onVolumeChange(
|
||||
parseFloat(e.target.value),
|
||||
)
|
||||
}
|
||||
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={callbacks.onFullscreenToggle}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
<ArrowsOut size={20} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,44 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Play, Pause, SpeakerHigh, SpeakerSlash, ArrowsOut, ClosedCaptioning, MagnifyingGlassPlus, MagnifyingGlassMinus, ArrowCounterClockwise, Gear, Repeat } from '@phosphor-icons/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { File } from '@sd/ts-client';
|
||||
import { Subtitles, type SubtitleSettings } from './Subtitles';
|
||||
import { SubtitleSettingsMenu } from './SubtitleSettingsMenu';
|
||||
import { useZoomPan } from './useZoomPan';
|
||||
import { TimelineScrubber } from './TimelineScrubber';
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { Subtitles, type SubtitleSettings } from "./Subtitles";
|
||||
import { SubtitleSettingsMenu } from "./SubtitleSettingsMenu";
|
||||
import { useZoomPan } from "./useZoomPan";
|
||||
import type {
|
||||
VideoControlsState,
|
||||
VideoControlsCallbacks,
|
||||
} from "./VideoControls";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
file: File;
|
||||
onZoomChange?: (isZoomed: boolean) => void;
|
||||
onControlsStateChange?: (state: VideoControlsState) => void;
|
||||
onShowControlsChange?: (show: boolean) => void;
|
||||
getCallbacks?: (callbacks: VideoControlsCallbacks) => void;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
file,
|
||||
onZoomChange,
|
||||
onControlsStateChange,
|
||||
onShowControlsChange,
|
||||
getCallbacks,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(() => {
|
||||
const saved = localStorage.getItem("sd-video-volume");
|
||||
return saved ? parseFloat(saved) : 1;
|
||||
});
|
||||
const [muted, setMuted] = useState(() => {
|
||||
const saved = localStorage.getItem("sd-video-muted");
|
||||
return saved === "true";
|
||||
});
|
||||
const [loop, setLoop] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [seeking, setSeeking] = useState(false);
|
||||
@@ -40,20 +46,114 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
const [showSubtitleSettings, setShowSubtitleSettings] = useState(false);
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleSettings>({
|
||||
fontSize: 1.5,
|
||||
position: 'bottom',
|
||||
backgroundOpacity: 0.9
|
||||
position: "bottom",
|
||||
backgroundOpacity: 0.9,
|
||||
});
|
||||
const [timelineHover, setTimelineHover] = useState<{ percent: number; mouseX: number } | null>(null);
|
||||
const hideControlsTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = useZoomPan(videoContainerRef);
|
||||
const [timelineHover, setTimelineHover] = useState<{
|
||||
percent: number;
|
||||
mouseX: number;
|
||||
} | null>(null);
|
||||
const hideControlsTimeout = useRef<number | undefined>(undefined);
|
||||
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } =
|
||||
useZoomPan(videoContainerRef);
|
||||
|
||||
// Expose controls state to parent
|
||||
useEffect(() => {
|
||||
onControlsStateChange?.({
|
||||
playing,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
muted,
|
||||
loop,
|
||||
zoom,
|
||||
subtitlesEnabled,
|
||||
showSubtitleSettings,
|
||||
seeking,
|
||||
timelineHover,
|
||||
});
|
||||
}, [
|
||||
playing,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
muted,
|
||||
loop,
|
||||
zoom,
|
||||
subtitlesEnabled,
|
||||
showSubtitleSettings,
|
||||
seeking,
|
||||
timelineHover,
|
||||
onControlsStateChange,
|
||||
]);
|
||||
|
||||
// Expose showControls state to parent
|
||||
useEffect(() => {
|
||||
onShowControlsChange?.(showControls);
|
||||
}, [showControls, onShowControlsChange]);
|
||||
|
||||
// Notify parent of zoom state changes
|
||||
useEffect(() => {
|
||||
onZoomChange?.(isZoomed);
|
||||
}, [isZoomed, onZoomChange]);
|
||||
|
||||
// Show controls on mouse move, hide after 3s of inactivity
|
||||
const handleMouseMove = () => {
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (playing) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
videoRef.current.currentTime = percent * duration;
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const handleTimelineHover = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
setTimelineHover({ percent, mouseX: e.clientX });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelineLeave = useCallback(() => {
|
||||
setSeeking(false);
|
||||
setTimelineHover(null);
|
||||
}, []);
|
||||
|
||||
const handleSeekingStart = useCallback(() => setSeeking(true), []);
|
||||
const handleSeekingEnd = useCallback(() => setSeeking(false), []);
|
||||
const handleMuteToggle = useCallback(() => setMuted((m) => !m), []);
|
||||
const handleLoopToggle = useCallback(() => setLoop((l) => !l), []);
|
||||
const handleSubtitlesToggle = useCallback(
|
||||
() => setSubtitlesEnabled((s) => !s),
|
||||
[],
|
||||
);
|
||||
const handleSubtitleSettingsToggle = useCallback(
|
||||
() => setShowSubtitleSettings((s) => !s),
|
||||
[],
|
||||
);
|
||||
|
||||
// Show controls on mouse move, hide after 1s of inactivity
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideControlsTimeout.current) {
|
||||
clearTimeout(hideControlsTimeout.current);
|
||||
@@ -61,9 +161,48 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
if (playing) {
|
||||
hideControlsTimeout.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
// Provide callbacks to parent
|
||||
useEffect(() => {
|
||||
getCallbacks?.({
|
||||
onTogglePlay: togglePlay,
|
||||
onSeek: handleSeek,
|
||||
onTimelineHover: handleTimelineHover,
|
||||
onTimelineLeave: handleTimelineLeave,
|
||||
onSeekingStart: handleSeekingStart,
|
||||
onSeekingEnd: handleSeekingEnd,
|
||||
onVolumeChange: setVolume,
|
||||
onMuteToggle: handleMuteToggle,
|
||||
onLoopToggle: handleLoopToggle,
|
||||
onZoomIn: zoomIn,
|
||||
onZoomOut: zoomOut,
|
||||
onZoomReset: reset,
|
||||
onSubtitlesToggle: handleSubtitlesToggle,
|
||||
onSubtitleSettingsToggle: handleSubtitleSettingsToggle,
|
||||
onFullscreenToggle: toggleFullscreen,
|
||||
onMouseMove: handleMouseMove,
|
||||
});
|
||||
}, [
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
handleTimelineHover,
|
||||
handleTimelineLeave,
|
||||
handleSeekingStart,
|
||||
handleSeekingEnd,
|
||||
handleMuteToggle,
|
||||
handleLoopToggle,
|
||||
handleSubtitlesToggle,
|
||||
handleSubtitleSettingsToggle,
|
||||
toggleFullscreen,
|
||||
handleMouseMove,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
reset,
|
||||
getCallbacks,
|
||||
]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -71,61 +210,73 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
case "Space":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 5);
|
||||
videoRef.current.currentTime = Math.max(
|
||||
0,
|
||||
videoRef.current.currentTime - 5,
|
||||
);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
videoRef.current.currentTime = Math.min(
|
||||
duration,
|
||||
videoRef.current.currentTime + 5
|
||||
videoRef.current.currentTime + 5,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume((v) => Math.min(1, v + 0.1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume((v) => Math.max(0, v - 0.1));
|
||||
break;
|
||||
case 'KeyM':
|
||||
case "KeyM":
|
||||
e.preventDefault();
|
||||
setMuted((m) => !m);
|
||||
handleMuteToggle();
|
||||
break;
|
||||
case 'KeyF':
|
||||
case "KeyF":
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'KeyC':
|
||||
case "KeyC":
|
||||
e.preventDefault();
|
||||
setSubtitlesEnabled((s) => !s);
|
||||
handleSubtitlesToggle();
|
||||
break;
|
||||
case 'KeyL':
|
||||
case "KeyL":
|
||||
e.preventDefault();
|
||||
setLoop((l) => !l);
|
||||
handleLoopToggle();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [duration, playing]);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
duration,
|
||||
togglePlay,
|
||||
toggleFullscreen,
|
||||
handleMuteToggle,
|
||||
handleSubtitlesToggle,
|
||||
handleLoopToggle,
|
||||
]);
|
||||
|
||||
// Sync video element state
|
||||
// Sync video element state and persist to localStorage
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.volume = volume;
|
||||
localStorage.setItem("sd-video-volume", volume.toString());
|
||||
}, [volume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.muted = muted;
|
||||
localStorage.setItem("sd-video-muted", muted.toString());
|
||||
}, [muted]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,45 +284,11 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
videoRef.current.loop = loop;
|
||||
}, [loop]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (playing) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
videoRef.current.currentTime = percent * duration;
|
||||
};
|
||||
|
||||
const handleTimelineHover = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
setTimelineHover({ percent, mouseX: e.clientX });
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const hasSubs = file.sidecars?.some(s => s.kind === 'transcript' && s.variant === 'srt');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-full w-full items-center justify-center bg-black"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => playing && setShowControls(false)}
|
||||
>
|
||||
{/* Zoom level indicator */}
|
||||
{zoom > 1 && (
|
||||
@@ -183,9 +300,12 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
{/* Video container with zoom/pan */}
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
className={`relative flex h-full w-full items-center justify-center ${isZoomed ? 'overflow-visible' : 'overflow-hidden'}`}
|
||||
className={`relative flex h-full w-full items-center justify-center ${isZoomed ? "overflow-visible" : "overflow-hidden"}`}
|
||||
>
|
||||
<div style={transform} className="flex items-center justify-center">
|
||||
<div
|
||||
style={transform}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
@@ -194,16 +314,29 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
className="max-h-screen max-w-screen"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => !seeking && setCurrentTime(e.currentTarget.currentTime)}
|
||||
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
onTimeUpdate={(e) =>
|
||||
!seeking &&
|
||||
setCurrentTime(e.currentTarget.currentTime)
|
||||
}
|
||||
onDurationChange={(e) =>
|
||||
setDuration(e.currentTarget.duration)
|
||||
}
|
||||
onLoadedMetadata={(e) =>
|
||||
setDuration(e.currentTarget.duration)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
{subtitlesEnabled && (
|
||||
<Subtitles file={file} videoElement={videoRef.current} settings={subtitleSettings} />
|
||||
<div className="absolute inset-0 z-10 pointer-events-none">
|
||||
<Subtitles
|
||||
file={file}
|
||||
videoElement={videoRef.current}
|
||||
settings={subtitleSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtitle Settings Menu */}
|
||||
@@ -213,188 +346,6 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
onSettingsChange={setSubtitleSettings}
|
||||
onClose={() => setShowSubtitleSettings(false)}
|
||||
/>
|
||||
|
||||
{/* Controls Overlay */}
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-4 pt-16"
|
||||
>
|
||||
{/* Timeline Scrubber Preview */}
|
||||
{timelineHover && (
|
||||
<TimelineScrubber
|
||||
file={file}
|
||||
hoverPercent={timelineHover.percent}
|
||||
mouseX={timelineHover.mouseX}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress Bar with Thick Hover Area */}
|
||||
<div
|
||||
className="group mb-3 cursor-pointer relative py-2 -my-2"
|
||||
onMouseDown={(e) => {
|
||||
setSeeking(true);
|
||||
handleSeek(e);
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (seeking) {
|
||||
handleSeek(e);
|
||||
} else {
|
||||
handleTimelineHover(e);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={handleTimelineHover}
|
||||
onMouseUp={() => setSeeking(false)}
|
||||
onMouseLeave={() => {
|
||||
setSeeking(false);
|
||||
setTimelineHover(null);
|
||||
}}
|
||||
>
|
||||
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/20 transition-all group-hover:h-1.5">
|
||||
{/* Progress */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-accent transition-all"
|
||||
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||
/>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 transition-all"
|
||||
style={{ left: `${(currentTime / duration) * 100}%` }}
|
||||
>
|
||||
<div className="size-3 -translate-x-1/2 rounded-full bg-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
{playing ? <Pause size={20} weight="fill" /> : <Play size={20} weight="fill" />}
|
||||
</button>
|
||||
|
||||
{/* Loop */}
|
||||
<button
|
||||
onClick={() => setLoop(!loop)}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
loop
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-white hover:bg-white/10'
|
||||
}`}
|
||||
title="Loop (L)"
|
||||
>
|
||||
<Repeat size={20} weight="bold" />
|
||||
</button>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-sm font-medium text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Subtitles Controls */}
|
||||
{hasSubs && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSubtitlesEnabled(!subtitlesEnabled)}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
subtitlesEnabled
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-white hover:bg-white/10'
|
||||
}`}
|
||||
title="Toggle subtitles"
|
||||
>
|
||||
<ClosedCaptioning size={20} weight="fill" />
|
||||
</button>
|
||||
{subtitlesEnabled && (
|
||||
<button
|
||||
onClick={() => setShowSubtitleSettings(!showSubtitleSettings)}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
title="Subtitle settings"
|
||||
>
|
||||
<Gear size={20} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
title="Zoom out (-)"
|
||||
>
|
||||
<MagnifyingGlassMinus size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= 5}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
title="Zoom in (+)"
|
||||
>
|
||||
<MagnifyingGlassPlus size={18} weight="bold" />
|
||||
</button>
|
||||
{zoom > 1 && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
title="Reset zoom (0)"
|
||||
>
|
||||
<ArrowCounterClockwise size={18} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="group flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setMuted(!muted)}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<SpeakerSlash size={20} weight="fill" />
|
||||
) : (
|
||||
<SpeakerHigh size={20} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<div className="w-0 overflow-hidden transition-all group-hover:w-20">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
<ArrowsOut size={20} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from 'react';
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseZoomPanOptions {
|
||||
minZoom?: number;
|
||||
@@ -8,9 +8,9 @@ interface UseZoomPanOptions {
|
||||
|
||||
export function useZoomPan(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UseZoomPanOptions = {}
|
||||
options: UseZoomPanOptions = {},
|
||||
) {
|
||||
const { minZoom = 1, maxZoom = 5, zoomStep = 0.2 } = options;
|
||||
const { minZoom = 1, maxZoom = 5, zoomStep = 0.1 } = options;
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
@@ -46,17 +46,25 @@ export function useZoomPan(
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Only zoom if not scrolling controls or other UI
|
||||
if ((e.target as HTMLElement).closest('input, button, [role="slider"]')) {
|
||||
if (
|
||||
(e.target as HTMLElement).closest(
|
||||
'input, button, [role="slider"]',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const delta = -e.deltaY;
|
||||
const zoomChange = delta > 0 ? zoomStep : -zoomStep;
|
||||
// Scale the wheel delta proportionally (typical deltaY is ~100 per notch)
|
||||
// Divide by 500 for responsive zoom: 100 deltaY = 0.2 zoom change
|
||||
const zoomChange = -e.deltaY / 500;
|
||||
|
||||
setZoom((z) => {
|
||||
const newZoom = Math.max(minZoom, Math.min(maxZoom, z + zoomChange));
|
||||
const newZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(maxZoom, z + zoomChange),
|
||||
);
|
||||
// Reset pan when zooming back to 1x
|
||||
if (newZoom === 1) {
|
||||
setPan({ x: 0, y: 0 });
|
||||
@@ -65,9 +73,9 @@ export function useZoomPan(
|
||||
});
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [containerRef, minZoom, maxZoom, zoomStep]);
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => container.removeEventListener("wheel", handleWheel);
|
||||
}, [containerRef, minZoom, maxZoom]);
|
||||
|
||||
// Pan with mouse drag (only when zoomed in)
|
||||
useEffect(() => {
|
||||
@@ -76,13 +84,17 @@ export function useZoomPan(
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't pan if clicking on controls
|
||||
if ((e.target as HTMLElement).closest('button, input, [role="slider"]')) {
|
||||
if (
|
||||
(e.target as HTMLElement).closest(
|
||||
'button, input, [role="slider"]',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
container.style.cursor = 'grabbing';
|
||||
container.style.cursor = "grabbing";
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
@@ -90,31 +102,31 @@ export function useZoomPan(
|
||||
|
||||
setPan({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
if (zoom > 1) {
|
||||
container.style.cursor = 'grab';
|
||||
container.style.cursor = "grab";
|
||||
} else {
|
||||
container.style.cursor = 'default';
|
||||
container.style.cursor = "default";
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('mousedown', handleMouseDown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
container.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// Set cursor
|
||||
container.style.cursor = zoom > 1 ? 'grab' : 'default';
|
||||
container.style.cursor = zoom > 1 ? "grab" : "default";
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', handleMouseDown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
container.style.cursor = 'default';
|
||||
container.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
container.style.cursor = "default";
|
||||
};
|
||||
}, [containerRef, zoom, pan, isDragging, dragStart]);
|
||||
|
||||
@@ -122,24 +134,24 @@ export function useZoomPan(
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't interfere with inputs
|
||||
if ((e.target as HTMLElement).tagName === 'INPUT') {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
if (e.key === "=" || e.key === "+") {
|
||||
e.preventDefault();
|
||||
zoomIn();
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
} else if (e.key === "-" || e.key === "_") {
|
||||
e.preventDefault();
|
||||
zoomOut();
|
||||
} else if (e.key === '0') {
|
||||
} else if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [zoomIn, zoomOut, reset]);
|
||||
|
||||
return {
|
||||
@@ -151,7 +163,7 @@ export function useZoomPan(
|
||||
isZoomed: zoom > 1,
|
||||
transform: {
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.1s ease-out'
|
||||
}
|
||||
transition: isDragging ? "none" : "transform 0.05s ease-out",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DevicesGroup({
|
||||
sortableAttributes,
|
||||
sortableListeners,
|
||||
}: DevicesGroupProps) {
|
||||
const { navigateToView } = useExplorer();
|
||||
const { navigateToView, loadPreferencesForSpaceItem } = useExplorer();
|
||||
|
||||
// Use normalized query for automatic updates when device events are emitted
|
||||
const { data: devices, isLoading } = useNormalizedQuery<
|
||||
@@ -53,6 +53,18 @@ export function DevicesGroup({
|
||||
onClick: async () => {
|
||||
await revokeDevice.mutateAsync({
|
||||
device_id: device.id,
|
||||
remove_from_library: false, // Keep device in library
|
||||
});
|
||||
},
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
icon: Trash,
|
||||
label: "Remove Device Completely",
|
||||
onClick: async () => {
|
||||
await revokeDevice.mutateAsync({
|
||||
device_id: device.id,
|
||||
remove_from_library: true, // Remove from library too
|
||||
});
|
||||
},
|
||||
variant: "danger" as const,
|
||||
@@ -106,7 +118,10 @@ export function DevicesGroup({
|
||||
item={deviceItem as any}
|
||||
customIcon={getDeviceIcon(device)}
|
||||
customLabel={device.name}
|
||||
onClick={() => navigateToView("device", device.id)}
|
||||
onClick={() => {
|
||||
loadPreferencesForSpaceItem(`device:${device.id}`);
|
||||
navigateToView("device", device.id);
|
||||
}}
|
||||
onContextMenu={handleDeviceContextMenu(device)}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === devices.length - 1}
|
||||
|
||||
@@ -1,216 +1,219 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
House,
|
||||
Clock,
|
||||
Heart,
|
||||
Folder,
|
||||
HardDrive,
|
||||
Tag as TagIcon,
|
||||
FolderOpen,
|
||||
MagnifyingGlass,
|
||||
Trash,
|
||||
Database,
|
||||
Folders,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Location } from "@sd/assets/icons";
|
||||
import type {
|
||||
SpaceItem as SpaceItemType,
|
||||
ItemType,
|
||||
File,
|
||||
} from "@sd/ts-client";
|
||||
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
|
||||
import { Thumb } from "../Explorer/File/Thumb";
|
||||
import { useContextMenu } from "../../hooks/useContextMenu";
|
||||
import { usePlatform } from "../../platform";
|
||||
import { useLibraryMutation } from "../../context";
|
||||
import { useDroppable, useDndContext } from "@dnd-kit/core";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useExplorer } from "../Explorer/context";
|
||||
|
||||
interface SpaceItemProps {
|
||||
item: SpaceItemType;
|
||||
/** Optional component to render on the right side (e.g., badges, status indicators) */
|
||||
rightComponent?: React.ReactNode;
|
||||
/** Optional className to override default styling */
|
||||
className?: string;
|
||||
/** Optional icon weight (default: "bold") */
|
||||
iconWeight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
/** Optional onClick handler to override default navigation */
|
||||
onClick?: () => void;
|
||||
/** Volume data for constructing explorer path */
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
/** Optional custom icon (as image path) to override default icon */
|
||||
customIcon?: string;
|
||||
/** Optional custom label to override automatic label detection */
|
||||
customLabel?: string;
|
||||
/** Whether this is the last item in the list (for showing bottom insertion line) */
|
||||
isLastItem?: boolean;
|
||||
/** Whether this item supports insertion (reordering) - false for system groups */
|
||||
allowInsertion?: boolean;
|
||||
/** The space ID this item belongs to (for adding items on insertion) */
|
||||
spaceId?: string;
|
||||
/** The group ID this item belongs to (for adding items on insertion) */
|
||||
groupId?: string | null;
|
||||
/** Whether this item is sortable (can be reordered) */
|
||||
sortable?: boolean;
|
||||
/** Optional onContextMenu handler to override default context menu */
|
||||
import {
|
||||
resolveItemMetadata,
|
||||
isRawLocation,
|
||||
type IconData,
|
||||
} from "./hooks/spaceItemUtils";
|
||||
import { useSpaceItemActive } from "./hooks/useSpaceItemActive";
|
||||
import { useSpaceItemDropZones } from "./hooks/useSpaceItemDropZones";
|
||||
import { useSpaceItemContextMenu } from "./hooks/useSpaceItemContextMenu";
|
||||
import { useExplorer, getSpaceItemKeyFromRoute } from "../Explorer/context";
|
||||
|
||||
// Overrides for customizing item appearance and behavior
|
||||
export interface SpaceItemOverrides {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function getItemIcon(itemType: ItemType): any {
|
||||
if (itemType === "Overview") return { type: "component", icon: House };
|
||||
if (itemType === "Recents") return { type: "component", icon: Clock };
|
||||
if (itemType === "Favorites") return { type: "component", icon: Heart };
|
||||
if (itemType === "FileKinds") return { type: "component", icon: Folders };
|
||||
if (typeof itemType === "object" && "Location" in itemType)
|
||||
return { type: "image", icon: Location };
|
||||
if (typeof itemType === "object" && "Volume" in itemType)
|
||||
return { type: "component", icon: HardDrive };
|
||||
if (typeof itemType === "object" && "Tag" in itemType)
|
||||
return { type: "component", icon: TagIcon };
|
||||
if (typeof itemType === "object" && "Path" in itemType)
|
||||
return { type: "image", icon: Location };
|
||||
return { type: "image", icon: Location };
|
||||
export interface SpaceItemProps {
|
||||
item: SpaceItemType;
|
||||
spaceId?: string;
|
||||
groupId?: string | null;
|
||||
// Behavior flags
|
||||
sortable?: boolean;
|
||||
allowInsertion?: boolean;
|
||||
isLastItem?: boolean;
|
||||
// Overrides
|
||||
overrides?: SpaceItemOverrides;
|
||||
rightComponent?: React.ReactNode;
|
||||
// Legacy props (for backwards compatibility during migration)
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
customIcon?: string;
|
||||
customLabel?: string;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getItemLabel(itemType: ItemType): string {
|
||||
if (itemType === "Overview") return "Overview";
|
||||
if (itemType === "Recents") return "Recents";
|
||||
if (itemType === "Favorites") return "Favorites";
|
||||
if (itemType === "FileKinds") return "File Kinds";
|
||||
if (typeof itemType === "object" && "Location" in itemType) {
|
||||
return itemType.Location.name || "Unnamed Location";
|
||||
// Icon component that handles both component icons and image icons
|
||||
function ItemIcon({ icon }: { icon: IconData }) {
|
||||
if (icon.type === "image") {
|
||||
return <img src={icon.icon} alt="" className="size-4 shrink-0" />;
|
||||
}
|
||||
if (typeof itemType === "object" && "Volume" in itemType) {
|
||||
return itemType.Volume.name || "Unnamed Volume";
|
||||
}
|
||||
if (typeof itemType === "object" && "Tag" in itemType) {
|
||||
return itemType.Tag.name || "Unnamed Tag";
|
||||
}
|
||||
if (typeof itemType === "object" && "Path" in itemType) {
|
||||
// Extract name from path
|
||||
const path = itemType.Path.sd_path;
|
||||
if (typeof path === "object" && "Physical" in path) {
|
||||
const parts = path.Physical.path.split("/");
|
||||
return parts[parts.length - 1] || "Path";
|
||||
}
|
||||
return "Path";
|
||||
}
|
||||
return "Unknown";
|
||||
const IconComponent = icon.icon;
|
||||
return (
|
||||
<span className="shrink-0">
|
||||
<IconComponent size={16} weight="bold" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getItemPath(
|
||||
itemType: ItemType,
|
||||
volumeData?: { device_slug: string; mount_path: string },
|
||||
resolvedFile?: File,
|
||||
itemSdPath?: any
|
||||
): string | null {
|
||||
if (itemType === "Overview") return "/";
|
||||
if (itemType === "Recents") return "/recents";
|
||||
if (itemType === "Favorites") return "/favorites";
|
||||
if (itemType === "FileKinds") return "/file-kinds";
|
||||
if (typeof itemType === "object" && "Location" in itemType) {
|
||||
// Use explorer route with location's SD path (passed from item.sd_path)
|
||||
if (itemSdPath) {
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`;
|
||||
}
|
||||
return null;
|
||||
// Insertion line indicator
|
||||
function InsertionLine({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom insertion line (for last items)
|
||||
function BottomInsertionLine({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
// Drop highlight ring for drop-into targets
|
||||
function DropHighlight({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
|
||||
);
|
||||
}
|
||||
|
||||
// Drop zone overlays (invisible hit areas)
|
||||
interface DropZoneOverlaysProps {
|
||||
isDropTarget: boolean;
|
||||
setTopRef: (node: HTMLElement | null) => void;
|
||||
setBottomRef: (node: HTMLElement | null) => void;
|
||||
setMiddleRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
function DropZoneOverlays({
|
||||
isDropTarget,
|
||||
setTopRef,
|
||||
setBottomRef,
|
||||
setMiddleRef,
|
||||
}: DropZoneOverlaysProps) {
|
||||
if (isDropTarget) {
|
||||
return (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
top: "-2px",
|
||||
height: "calc(25% + 2px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
{/* Middle zone - drop into folder */}
|
||||
<div
|
||||
ref={setMiddleRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "25%", height: "50%", zIndex: 11 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
bottom: "-2px",
|
||||
height: "calc(25% + 2px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (typeof itemType === "object" && "Volume" in itemType) {
|
||||
// Navigate to explorer with volume's root path
|
||||
if (volumeData) {
|
||||
const sdPath = {
|
||||
Physical: {
|
||||
device_slug: volumeData.device_slug,
|
||||
path: volumeData.mount_path || "/",
|
||||
},
|
||||
};
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof itemType === "object" && "Tag" in itemType)
|
||||
return `/tag/${itemType.Tag.tag_id}`;
|
||||
if (typeof itemType === "object" && "Path" in itemType) {
|
||||
// Navigate to explorer with the SD path
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
bottom: "-2px",
|
||||
height: "calc(50% + 2px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaceItem({
|
||||
item,
|
||||
rightComponent,
|
||||
className,
|
||||
iconWeight = "bold",
|
||||
onClick,
|
||||
volumeData,
|
||||
customIcon,
|
||||
customLabel,
|
||||
isLastItem = false,
|
||||
allowInsertion = true,
|
||||
spaceId,
|
||||
groupId,
|
||||
sortable = false,
|
||||
onContextMenu,
|
||||
allowInsertion = true,
|
||||
isLastItem = false,
|
||||
overrides,
|
||||
rightComponent,
|
||||
// Legacy props
|
||||
volumeData,
|
||||
customIcon,
|
||||
customLabel,
|
||||
onClick: legacyOnClick,
|
||||
onContextMenu: legacyOnContextMenu,
|
||||
className,
|
||||
}: SpaceItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const platform = usePlatform();
|
||||
const deleteItem = useLibraryMutation("spaces.delete_item");
|
||||
const indexVolume = useLibraryMutation("volumes.index");
|
||||
const { active } = useDndContext();
|
||||
const { currentView, currentPath } = useExplorer();
|
||||
|
||||
// Disable insertion drop zones when dragging groups or space items (they have 'label' in their data)
|
||||
const isDraggingSortableItem = active?.data?.current?.label != null;
|
||||
const { loadPreferencesForSpaceItem } = useExplorer();
|
||||
|
||||
// Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type')
|
||||
const isRawLocation =
|
||||
"name" in item && "sd_path" in item && !item.item_type;
|
||||
// Merge legacy props into overrides
|
||||
const effectiveOverrides: SpaceItemOverrides = {
|
||||
...overrides,
|
||||
label: overrides?.label ?? customLabel,
|
||||
icon: overrides?.icon ?? customIcon,
|
||||
onClick: overrides?.onClick ?? legacyOnClick,
|
||||
onContextMenu: overrides?.onContextMenu ?? legacyOnContextMenu,
|
||||
};
|
||||
|
||||
// Check if we have a resolved file
|
||||
const resolvedFile = item.resolved_file as File | undefined;
|
||||
|
||||
let iconData, label, path;
|
||||
|
||||
if (isRawLocation) {
|
||||
// Handle raw location object
|
||||
iconData = { type: "image", icon: Location };
|
||||
label = (item as any).name || "Unnamed Location";
|
||||
// Use explorer path with the location's sd_path
|
||||
const sdPath = (item as any).sd_path;
|
||||
path = sdPath ? `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}` : null;
|
||||
} else {
|
||||
// Handle proper SpaceItem
|
||||
iconData = getItemIcon(item.item_type);
|
||||
// Use resolved file name if available, otherwise parse from item_type
|
||||
label = resolvedFile?.name || getItemLabel(item.item_type);
|
||||
// Pass item.sd_path for locations (available on SpaceItem objects)
|
||||
path = getItemPath(item.item_type, volumeData, resolvedFile, (item as any).sd_path);
|
||||
}
|
||||
|
||||
// Override with custom icon if provided
|
||||
if (customIcon) {
|
||||
iconData = { type: "image", icon: customIcon };
|
||||
}
|
||||
|
||||
// Override with custom label if provided
|
||||
if (customLabel) {
|
||||
label = customLabel;
|
||||
}
|
||||
|
||||
// Sortable hook (for reordering) - must be after label is defined
|
||||
const sortableProps = useSortable({
|
||||
id: item.id,
|
||||
disabled: !sortable,
|
||||
data: {
|
||||
label: label,
|
||||
},
|
||||
// Resolve metadata (icon, label, path)
|
||||
const { icon, label, path } = resolveItemMetadata(item, {
|
||||
volumeData,
|
||||
customIcon: effectiveOverrides.icon,
|
||||
customLabel: effectiveOverrides.label,
|
||||
});
|
||||
|
||||
// Get resolved file for thumbnail rendering
|
||||
const resolvedFile = isRawLocation(item)
|
||||
? undefined
|
||||
: (item as SpaceItemType).resolved_file;
|
||||
|
||||
// Active state detection
|
||||
const isActive = useSpaceItemActive({
|
||||
item: item as SpaceItemType,
|
||||
path,
|
||||
hasCustomOnClick: !!effectiveOverrides.onClick,
|
||||
});
|
||||
|
||||
// Drop zone management
|
||||
const dropZones = useSpaceItemDropZones({
|
||||
item: item as SpaceItemType,
|
||||
allowInsertion,
|
||||
spaceId,
|
||||
groupId,
|
||||
volumeData,
|
||||
});
|
||||
|
||||
// Context menu
|
||||
const contextMenu = useSpaceItemContextMenu({
|
||||
item: item as SpaceItemType,
|
||||
path,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
// Sortable drag/drop
|
||||
const {
|
||||
attributes: sortableAttributes,
|
||||
listeners: sortableListeners,
|
||||
@@ -218,194 +221,37 @@ export function SpaceItem({
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = sortableProps;
|
||||
} = useSortable({
|
||||
id: (item as SpaceItemType).id,
|
||||
disabled: !sortable,
|
||||
data: { label },
|
||||
});
|
||||
|
||||
const style = sortable ? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} : undefined;
|
||||
|
||||
// Check if this item is active
|
||||
const isActive = (() => {
|
||||
// For items with custom onClick (like virtual device views), ONLY check virtual view state
|
||||
// These items represent virtual views and should never use path-based matching
|
||||
if (onClick) {
|
||||
if (currentView) {
|
||||
// Check if this item matches the current virtual view
|
||||
// Convert both IDs to strings for comparison since URL params are always strings
|
||||
const itemIdStr = String(item.id);
|
||||
const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr;
|
||||
|
||||
console.log("[SpaceItem] Virtual view check (with onClick):", {
|
||||
label: customLabel || label,
|
||||
currentView,
|
||||
itemId: item.id,
|
||||
itemIdStr,
|
||||
isViewMatch,
|
||||
});
|
||||
|
||||
return isViewMatch;
|
||||
const style = sortable
|
||||
? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// No current view active - virtual items are never active on regular routes
|
||||
console.log("[SpaceItem] Virtual item on regular route:", {
|
||||
label: customLabel || label,
|
||||
hasOnClick: true,
|
||||
currentView: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check virtual view state for items without custom onClick
|
||||
if (currentView) {
|
||||
const itemIdStr = String(item.id);
|
||||
const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr;
|
||||
|
||||
console.log("[SpaceItem] Virtual view check (no onClick):", {
|
||||
label: customLabel || label,
|
||||
currentView,
|
||||
itemId: item.id,
|
||||
isViewMatch,
|
||||
});
|
||||
|
||||
if (isViewMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path-based navigation
|
||||
if (currentPath && path && path.startsWith("/explorer?")) {
|
||||
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
|
||||
if (itemPathParam) {
|
||||
try {
|
||||
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
|
||||
return JSON.stringify(currentPath) === JSON.stringify(itemSdPath);
|
||||
} catch {
|
||||
// Fall through to URL-based comparison
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!path) return false;
|
||||
|
||||
// Special routes: exact pathname match
|
||||
if (!path.startsWith("/explorer?")) {
|
||||
return location.pathname === path;
|
||||
}
|
||||
|
||||
// Fallback: Explorer routes via URL comparison
|
||||
if (location.pathname === "/explorer") {
|
||||
const currentSearchParams = new URLSearchParams(location.search);
|
||||
const currentPathParam = currentSearchParams.get("path");
|
||||
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
|
||||
|
||||
if (currentPathParam && itemPathParam) {
|
||||
try {
|
||||
const currentSdPath = JSON.parse(decodeURIComponent(currentPathParam));
|
||||
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
|
||||
return JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath);
|
||||
} catch {
|
||||
return currentPathParam === itemPathParam;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
// Event handlers
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (effectiveOverrides.onClick) {
|
||||
effectiveOverrides.onClick(e);
|
||||
} else if (path) {
|
||||
// Extract pathname and search from the path
|
||||
const [pathname, search] = path.includes("?")
|
||||
? [path.split("?")[0], "?" + path.split("?")[1]]
|
||||
: [path, ""];
|
||||
const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search);
|
||||
loadPreferencesForSpaceItem(spaceItemKey);
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
// Context menu for space items
|
||||
const contextMenu = useContextMenu({
|
||||
items: [
|
||||
{
|
||||
icon: FolderOpen,
|
||||
label: "Open",
|
||||
onClick: () => {
|
||||
if (path) navigate(path);
|
||||
},
|
||||
condition: () => !!path,
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
label: "Index Volume",
|
||||
onClick: async () => {
|
||||
if (typeof item.item_type === "object" && "Volume" in item.item_type) {
|
||||
const volumeItem = item.item_type.Volume;
|
||||
// Extract volume fingerprint from the item
|
||||
// We'll need to get this from the volume data
|
||||
const fingerprint = (item as any).fingerprint || volumeItem.volume_id;
|
||||
|
||||
try {
|
||||
const result = await indexVolume.mutateAsync({
|
||||
fingerprint: fingerprint.toString(),
|
||||
scope: "Recursive",
|
||||
});
|
||||
console.log("Volume indexed:", result.message);
|
||||
} catch (err) {
|
||||
console.error("Failed to index volume:", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
condition: () => typeof item.item_type === "object" && "Volume" in item.item_type,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
icon: MagnifyingGlass,
|
||||
label: "Show in Finder",
|
||||
onClick: async () => {
|
||||
// For Path items, get the physical path
|
||||
if (typeof item.item_type === "object" && "Path" in item.item_type) {
|
||||
const sdPath = item.item_type.Path.sd_path;
|
||||
if (typeof sdPath === "object" && "Physical" in sdPath) {
|
||||
const physicalPath = sdPath.Physical.path;
|
||||
if (platform.revealFile) {
|
||||
try {
|
||||
await platform.revealFile(physicalPath);
|
||||
} catch (err) {
|
||||
console.error("Failed to reveal file:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
keybind: "⌘⇧R",
|
||||
condition: () => {
|
||||
if (typeof item.item_type === "object" && "Path" in item.item_type) {
|
||||
const sdPath = item.item_type.Path.sd_path;
|
||||
return typeof sdPath === "object" && "Physical" in sdPath && !!platform.revealFile;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
icon: Trash,
|
||||
label: "Remove from Space",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await deleteItem.mutateAsync({ item_id: item.id });
|
||||
} catch (err) {
|
||||
console.error("Failed to remove item:", err);
|
||||
}
|
||||
},
|
||||
variant: "danger" as const,
|
||||
// All space items can be removed (Overview, Recents, Favorites, FileKinds, Locations, Volumes, Tags, Paths)
|
||||
condition: () => spaceId != null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleContextMenu = async (e: React.MouseEvent) => {
|
||||
// Use custom handler if provided, otherwise use default
|
||||
if (onContextMenu) {
|
||||
onContextMenu(e);
|
||||
if (effectiveOverrides.onContextMenu) {
|
||||
effectiveOverrides.onContextMenu(e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,196 +260,70 @@ export function SpaceItem({
|
||||
await contextMenu.show(e);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop Target Detection
|
||||
*
|
||||
* SpaceItems can be drop targets in two ways:
|
||||
*
|
||||
* 1. Insertion Points (all items):
|
||||
* - Show blue line above/below
|
||||
* - Allows reordering sidebar items
|
||||
* - Top/bottom zones (25% or 50% of height)
|
||||
*
|
||||
* 2. Move-Into Targets (locations/volumes/folders only):
|
||||
* - Show blue ring around entire item
|
||||
* - Allows moving files into that location
|
||||
* - Middle zone (50% of height, only for drop targets)
|
||||
*
|
||||
* Target Types:
|
||||
* - "location": Indexed location (raw or ItemType::Location)
|
||||
* - "volume": Storage volume (ItemType::Volume)
|
||||
* - "folder": Directory path (ItemType::Path with kind=Directory)
|
||||
*/
|
||||
const isDropTarget =
|
||||
isRawLocation ||
|
||||
(typeof item.item_type === "object" &&
|
||||
("Location" in item.item_type ||
|
||||
"Volume" in item.item_type ||
|
||||
("Path" in item.item_type && resolvedFile?.kind === "Directory")));
|
||||
|
||||
let targetType: "location" | "volume" | "folder" | "other" = "other";
|
||||
if (isRawLocation) {
|
||||
targetType = "location";
|
||||
} else if (typeof item.item_type === "object") {
|
||||
if ("Location" in item.item_type) targetType = "location";
|
||||
else if ("Volume" in item.item_type) targetType = "volume";
|
||||
else if ("Path" in item.item_type && resolvedFile?.kind === "Directory") targetType = "folder";
|
||||
}
|
||||
|
||||
// Debug logging for folder drop targets
|
||||
useEffect(() => {
|
||||
if (typeof item.item_type === "object" && "Path" in item.item_type) {
|
||||
console.log("[SpaceItem] Folder item:", {
|
||||
label,
|
||||
isDropTarget,
|
||||
targetType,
|
||||
hasResolvedFile: !!resolvedFile,
|
||||
resolvedFileKind: resolvedFile?.kind,
|
||||
sdPath: item.item_type.Path.sd_path,
|
||||
});
|
||||
}
|
||||
}, [item, isDropTarget, targetType, resolvedFile, label]);
|
||||
|
||||
const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({
|
||||
id: `space-item-${item.id}-top`,
|
||||
disabled: !allowInsertion || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "insert-before",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({
|
||||
id: `space-item-${item.id}-bottom`,
|
||||
disabled: !allowInsertion || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "insert-after",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
// Build the target path for drop operations
|
||||
const targetPath = isRawLocation
|
||||
? (item as any).sd_path
|
||||
: targetType === "folder" && typeof item.item_type === "object" && "Path" in item.item_type
|
||||
? item.item_type.Path.sd_path
|
||||
: targetType === "volume" && typeof item.item_type === "object" && "Volume" in item.item_type && volumeData
|
||||
? { Physical: { device_slug: volumeData.device_slug, path: volumeData.mount_path || "/" } }
|
||||
: targetType === "location" && typeof item.item_type === "object" && "Location" in item.item_type && (item as any).sd_path
|
||||
? (item as any).sd_path
|
||||
: undefined;
|
||||
|
||||
// Debug log the drop data
|
||||
useEffect(() => {
|
||||
if (isDropTarget && targetType === "folder") {
|
||||
console.log("[SpaceItem] Drop zone data for folder:", {
|
||||
label,
|
||||
targetType,
|
||||
targetPath,
|
||||
itemId: item.id,
|
||||
});
|
||||
}
|
||||
}, [isDropTarget, targetType, targetPath, label, item.id]);
|
||||
|
||||
const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({
|
||||
id: `space-item-${item.id}-middle`,
|
||||
disabled: !isDropTarget || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "move-into",
|
||||
targetType,
|
||||
targetId: item.id,
|
||||
targetPath,
|
||||
},
|
||||
});
|
||||
// Computed visibility for indicators
|
||||
const showTopLine =
|
||||
dropZones.isOverTop &&
|
||||
!isSortableDragging &&
|
||||
!dropZones.isDraggingSortableItem;
|
||||
const showBottomLine =
|
||||
dropZones.isOverBottom &&
|
||||
isLastItem &&
|
||||
!dropZones.isDraggingSortableItem;
|
||||
const showDropHighlight =
|
||||
dropZones.isOverMiddle &&
|
||||
dropZones.isDropTarget &&
|
||||
!isSortableDragging &&
|
||||
!dropZones.isDraggingSortableItem;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setSortableRef}
|
||||
style={style}
|
||||
className={clsx("relative", isSortableDragging && "opacity-50 z-50")}
|
||||
className={clsx(
|
||||
"relative",
|
||||
isSortableDragging && "opacity-50 z-50",
|
||||
)}
|
||||
>
|
||||
{/* Insertion line indicator - only show top (bottom of previous item handles gaps) */}
|
||||
{isOverTop && !isSortableDragging && !isDraggingSortableItem && (
|
||||
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Ring highlight for drop-into */}
|
||||
{isOverMiddle && isDropTarget && !isSortableDragging && !isDraggingSortableItem && (
|
||||
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
|
||||
)}
|
||||
<InsertionLine visible={showTopLine} />
|
||||
<DropHighlight visible={showDropHighlight} />
|
||||
|
||||
<div className="relative">
|
||||
{/* Drop zones - invisible overlays, only active during drag */}
|
||||
{isDropTarget ? (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
{/* Middle zone - drop into folder */}
|
||||
<div
|
||||
ref={setMiddleRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "25%", height: "50%", zIndex: 11 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ bottom: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ bottom: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DropZoneOverlays
|
||||
isDropTarget={dropZones.isDropTarget}
|
||||
setTopRef={dropZones.setTopRef}
|
||||
setBottomRef={dropZones.setBottomRef}
|
||||
setMiddleRef={dropZones.setMiddleRef}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...(sortable ? { ...sortableAttributes, ...sortableListeners } : {})}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-selected/30 text-sidebar-ink"
|
||||
: (className || "text-sidebar-inkDull"),
|
||||
isOverMiddle && isDropTarget && !isDraggingSortableItem && "bg-accent/10",
|
||||
)}
|
||||
{...(sortable
|
||||
? { ...sortableAttributes, ...sortableListeners }
|
||||
: {})}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-selected/30 text-sidebar-ink"
|
||||
: className || "text-sidebar-inkDull",
|
||||
showDropHighlight && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
{resolvedFile ? (
|
||||
<Thumb file={resolvedFile} size={16} className="shrink-0" />
|
||||
) : iconData.type === "image" ? (
|
||||
<img src={iconData.icon} alt="" className="size-4" />
|
||||
<Thumb
|
||||
file={resolvedFile}
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<iconData.icon className="size-4" weight={iconWeight} />
|
||||
<ItemIcon icon={icon} />
|
||||
)}
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
{rightComponent}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Insertion line indicator - bottom (only for last item to allow dropping at end) */}
|
||||
{isOverBottom && isLastItem && !isDraggingSortableItem && (
|
||||
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
)}
|
||||
<BottomInsertionLine visible={showBottomLine} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import clsx from 'clsx';
|
||||
import { useNormalizedQuery, useLibraryMutation } from '../../context';
|
||||
import type { Tag } from '@sd/ts-client';
|
||||
import { GroupHeader } from './GroupHeader';
|
||||
import { useExplorer } from '../Explorer/context';
|
||||
|
||||
interface TagsGroupProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -20,6 +21,7 @@ interface TagItemProps {
|
||||
|
||||
function TagItem({ tag, depth = 0 }: TagItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const { loadPreferencesForSpaceItem } = useExplorer();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// TODO: Fetch children when hierarchy is implemented
|
||||
@@ -27,6 +29,7 @@ function TagItem({ tag, depth = 0 }: TagItemProps) {
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
loadPreferencesForSpaceItem(`tag:${tag.id}`);
|
||||
navigate(`/tag/${tag.id}`);
|
||||
};
|
||||
|
||||
@@ -88,6 +91,7 @@ export function TagsGroup({
|
||||
sortableListeners,
|
||||
}: TagsGroupProps) {
|
||||
const navigate = useNavigate();
|
||||
const { loadPreferencesForSpaceItem } = useExplorer();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
|
||||
@@ -115,6 +119,7 @@ export function TagsGroup({
|
||||
|
||||
// Navigate to the new tag
|
||||
if (result?.tag?.id) {
|
||||
loadPreferencesForSpaceItem(`tag:${result.tag.id}`);
|
||||
navigate(`/tag/${result.tag.id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Space item utilities
|
||||
export {
|
||||
isOverviewItem,
|
||||
isRecentsItem,
|
||||
isFavoritesItem,
|
||||
isFileKindsItem,
|
||||
isLocationItem,
|
||||
isVolumeItem,
|
||||
isTagItem,
|
||||
isPathItem,
|
||||
isRawLocation,
|
||||
isDropTargetItem,
|
||||
getDropTargetType,
|
||||
buildDropTargetPath,
|
||||
resolveItemMetadata,
|
||||
type IconData,
|
||||
type ItemMetadata,
|
||||
type ResolveMetadataOptions,
|
||||
type DropTargetType,
|
||||
} from "./spaceItemUtils";
|
||||
|
||||
// Space item hooks
|
||||
export { useSpaceItemActive } from "./useSpaceItemActive";
|
||||
export { useSpaceItemDropZones, type UseSpaceItemDropZonesResult } from "./useSpaceItemDropZones";
|
||||
export { useSpaceItemContextMenu } from "./useSpaceItemContextMenu";
|
||||
|
||||
// Space data hooks
|
||||
export { useSpaces, useSpaceLayout } from "./useSpaces";
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
House,
|
||||
Clock,
|
||||
Heart,
|
||||
Folder,
|
||||
HardDrive,
|
||||
Tag as TagIcon,
|
||||
Folders,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Location } from "@sd/assets/icons";
|
||||
import type {
|
||||
SpaceItem as SpaceItemType,
|
||||
ItemType,
|
||||
File,
|
||||
SdPath,
|
||||
} from "@sd/ts-client";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
|
||||
// Icon data returned from metadata resolution
|
||||
export type IconData =
|
||||
| { type: "component"; icon: Icon }
|
||||
| { type: "image"; icon: string };
|
||||
|
||||
// Metadata resolved for a space item
|
||||
export interface ItemMetadata {
|
||||
icon: IconData;
|
||||
label: string;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
// Type guards for ItemType discrimination
|
||||
export function isOverviewItem(t: ItemType): t is "Overview" {
|
||||
return t === "Overview";
|
||||
}
|
||||
|
||||
export function isRecentsItem(t: ItemType): t is "Recents" {
|
||||
return t === "Recents";
|
||||
}
|
||||
|
||||
export function isFavoritesItem(t: ItemType): t is "Favorites" {
|
||||
return t === "Favorites";
|
||||
}
|
||||
|
||||
export function isFileKindsItem(t: ItemType): t is "FileKinds" {
|
||||
return t === "FileKinds";
|
||||
}
|
||||
|
||||
export function isLocationItem(
|
||||
t: ItemType,
|
||||
): t is { Location: { location_id: string } } {
|
||||
return typeof t === "object" && "Location" in t;
|
||||
}
|
||||
|
||||
export function isVolumeItem(
|
||||
t: ItemType,
|
||||
): t is { Volume: { volume_id: string } } {
|
||||
return typeof t === "object" && "Volume" in t;
|
||||
}
|
||||
|
||||
export function isTagItem(t: ItemType): t is { Tag: { tag_id: string } } {
|
||||
return typeof t === "object" && "Tag" in t;
|
||||
}
|
||||
|
||||
export function isPathItem(t: ItemType): t is { Path: { sd_path: SdPath } } {
|
||||
return typeof t === "object" && "Path" in t;
|
||||
}
|
||||
|
||||
// Check if item is a "raw" location (legacy format with name/sd_path but no item_type)
|
||||
export function isRawLocation(
|
||||
item: SpaceItemType | Record<string, unknown>,
|
||||
): boolean {
|
||||
return "name" in item && "sd_path" in item && !("item_type" in item);
|
||||
}
|
||||
|
||||
// Get icon data for an item type
|
||||
function getItemIcon(itemType: ItemType): IconData {
|
||||
if (isOverviewItem(itemType)) return { type: "component", icon: House };
|
||||
if (isRecentsItem(itemType)) return { type: "component", icon: Clock };
|
||||
if (isFavoritesItem(itemType)) return { type: "component", icon: Heart };
|
||||
if (isFileKindsItem(itemType)) return { type: "component", icon: Folders };
|
||||
if (isLocationItem(itemType)) return { type: "image", icon: Location };
|
||||
if (isVolumeItem(itemType)) return { type: "component", icon: HardDrive };
|
||||
if (isTagItem(itemType)) return { type: "component", icon: TagIcon };
|
||||
if (isPathItem(itemType)) return { type: "image", icon: Location };
|
||||
return { type: "image", icon: Location };
|
||||
}
|
||||
|
||||
// Get label for an item type
|
||||
function getItemLabel(itemType: ItemType, resolvedFile?: File | null): string {
|
||||
if (isOverviewItem(itemType)) return "Overview";
|
||||
if (isRecentsItem(itemType)) return "Recents";
|
||||
if (isFavoritesItem(itemType)) return "Favorites";
|
||||
if (isFileKindsItem(itemType)) return "File Kinds";
|
||||
if (isLocationItem(itemType)) return itemType.Location.name || "Unnamed Location";
|
||||
if (isVolumeItem(itemType)) return itemType.Volume.name || "Unnamed Volume";
|
||||
if (isTagItem(itemType)) return itemType.Tag.name || "Unnamed Tag";
|
||||
if (isPathItem(itemType)) {
|
||||
// Use resolved file name if available, otherwise extract from path
|
||||
if (resolvedFile?.name) return resolvedFile.name;
|
||||
const sdPath = itemType.Path.sd_path;
|
||||
if (typeof sdPath === "object" && "Physical" in sdPath) {
|
||||
const parts = (
|
||||
sdPath as { Physical: { path: string } }
|
||||
).Physical.path.split("/");
|
||||
return parts[parts.length - 1] || "Path";
|
||||
}
|
||||
return "Path";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Build navigation path for an item
|
||||
function getItemPath(
|
||||
itemType: ItemType,
|
||||
volumeData?: { device_slug: string; mount_path: string },
|
||||
itemSdPath?: SdPath,
|
||||
): string | null {
|
||||
if (isOverviewItem(itemType)) return "/";
|
||||
if (isRecentsItem(itemType)) return "/recents";
|
||||
if (isFavoritesItem(itemType)) return "/favorites";
|
||||
if (isFileKindsItem(itemType)) return "/file-kinds";
|
||||
|
||||
if (isLocationItem(itemType)) {
|
||||
// Use explorer route with location's SD path (passed from item.sd_path)
|
||||
if (itemSdPath) {
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isVolumeItem(itemType)) {
|
||||
// Navigate to explorer with volume's root path
|
||||
if (volumeData) {
|
||||
const sdPath = {
|
||||
Physical: {
|
||||
device_slug: volumeData.device_slug,
|
||||
path: volumeData.mount_path || "/",
|
||||
},
|
||||
};
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTagItem(itemType)) {
|
||||
return `/tag/${itemType.Tag.tag_id}`;
|
||||
}
|
||||
|
||||
if (isPathItem(itemType)) {
|
||||
// Navigate to explorer with the SD path
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Options for resolving item metadata
|
||||
export interface ResolveMetadataOptions {
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
customIcon?: string;
|
||||
customLabel?: string;
|
||||
}
|
||||
|
||||
// Resolve all metadata for a space item in one call
|
||||
export function resolveItemMetadata(
|
||||
item: SpaceItemType | Record<string, unknown>,
|
||||
options: ResolveMetadataOptions = {},
|
||||
): ItemMetadata {
|
||||
const { volumeData, customIcon, customLabel } = options;
|
||||
|
||||
// Handle raw location object (legacy format)
|
||||
if (isRawLocation(item)) {
|
||||
const rawItem = item as { name?: string; sd_path?: SdPath };
|
||||
const label = customLabel || rawItem.name || "Unnamed Location";
|
||||
const path = rawItem.sd_path
|
||||
? `/explorer?path=${encodeURIComponent(JSON.stringify(rawItem.sd_path))}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
icon: customIcon
|
||||
? { type: "image", icon: customIcon }
|
||||
: { type: "image", icon: Location },
|
||||
label,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle proper SpaceItem
|
||||
const spaceItem = item as SpaceItemType;
|
||||
const resolvedFile = spaceItem.resolved_file;
|
||||
const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath })
|
||||
.sd_path;
|
||||
|
||||
const icon: IconData = customIcon
|
||||
? { type: "image", icon: customIcon }
|
||||
: getItemIcon(spaceItem.item_type);
|
||||
|
||||
const label =
|
||||
customLabel ||
|
||||
resolvedFile?.name ||
|
||||
getItemLabel(spaceItem.item_type, resolvedFile);
|
||||
|
||||
const path = getItemPath(spaceItem.item_type, volumeData, itemSdPath);
|
||||
|
||||
return { icon, label, path };
|
||||
}
|
||||
|
||||
// Determine if an item can be a drop target (for files to be moved into)
|
||||
export function isDropTargetItem(
|
||||
item: SpaceItemType | Record<string, unknown>,
|
||||
): boolean {
|
||||
if (isRawLocation(item)) return true;
|
||||
|
||||
const spaceItem = item as SpaceItemType;
|
||||
const itemType = spaceItem.item_type;
|
||||
const resolvedFile = spaceItem.resolved_file;
|
||||
|
||||
return (
|
||||
isLocationItem(itemType) ||
|
||||
isVolumeItem(itemType) ||
|
||||
(isPathItem(itemType) && resolvedFile?.kind === "Directory")
|
||||
);
|
||||
}
|
||||
|
||||
// Get the target type for drop operations
|
||||
export type DropTargetType = "location" | "volume" | "folder" | "other";
|
||||
|
||||
export function getDropTargetType(
|
||||
item: SpaceItemType | Record<string, unknown>,
|
||||
): DropTargetType {
|
||||
if (isRawLocation(item)) return "location";
|
||||
|
||||
const spaceItem = item as SpaceItemType;
|
||||
const itemType = spaceItem.item_type;
|
||||
const resolvedFile = spaceItem.resolved_file;
|
||||
|
||||
if (isLocationItem(itemType)) return "location";
|
||||
if (isVolumeItem(itemType)) return "volume";
|
||||
if (isPathItem(itemType) && resolvedFile?.kind === "Directory")
|
||||
return "folder";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
// Build target path for drop operations
|
||||
export function buildDropTargetPath(
|
||||
item: SpaceItemType | Record<string, unknown>,
|
||||
volumeData?: { device_slug: string; mount_path: string },
|
||||
): SdPath | undefined {
|
||||
if (isRawLocation(item)) {
|
||||
return (item as { sd_path?: SdPath }).sd_path;
|
||||
}
|
||||
|
||||
const spaceItem = item as SpaceItemType;
|
||||
const itemType = spaceItem.item_type;
|
||||
const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath })
|
||||
.sd_path;
|
||||
|
||||
if (isPathItem(itemType)) {
|
||||
return itemType.Path.sd_path;
|
||||
}
|
||||
|
||||
if (isVolumeItem(itemType) && volumeData) {
|
||||
return {
|
||||
Physical: {
|
||||
device_slug: volumeData.device_slug,
|
||||
path: volumeData.mount_path || "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isLocationItem(itemType) && itemSdPath) {
|
||||
return itemSdPath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
|
||||
import { useExplorer } from "../../Explorer/context";
|
||||
|
||||
interface UseSpaceItemActiveOptions {
|
||||
item: SpaceItemType;
|
||||
path: string | null;
|
||||
hasCustomOnClick: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a space item is currently "active" (selected/highlighted).
|
||||
*
|
||||
* Active state is determined by matching the current route/view to the item:
|
||||
* - Virtual views (devices) match by view type and ID
|
||||
* - Explorer routes match by comparing SD paths
|
||||
* - Special routes (/, /recents, etc.) match by exact pathname
|
||||
*/
|
||||
export function useSpaceItemActive({
|
||||
item,
|
||||
path,
|
||||
hasCustomOnClick,
|
||||
}: UseSpaceItemActiveOptions): boolean {
|
||||
const location = useLocation();
|
||||
const { currentView, currentPath } = useExplorer();
|
||||
|
||||
// Items with custom onClick represent virtual views (like device views).
|
||||
// They should ONLY match via virtual view state, never path-based matching.
|
||||
if (hasCustomOnClick) {
|
||||
if (!currentView) return false;
|
||||
|
||||
const itemIdStr = String(item.id);
|
||||
return currentView.view === "device" && currentView.id === itemIdStr;
|
||||
}
|
||||
|
||||
// Check virtual view state for items without custom onClick
|
||||
if (currentView) {
|
||||
const itemIdStr = String(item.id);
|
||||
const isViewMatch =
|
||||
currentView.view === "device" && currentView.id === itemIdStr;
|
||||
|
||||
if (isViewMatch) return true;
|
||||
|
||||
// When a virtual view is active, regular items should NOT be active
|
||||
// even if their path happens to match. Virtual views own the display.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check path-based navigation via explorer context
|
||||
// Only use currentPath matching when we're actually on the explorer route
|
||||
if (
|
||||
location.pathname === "/explorer" &&
|
||||
currentPath &&
|
||||
path &&
|
||||
path.startsWith("/explorer?")
|
||||
) {
|
||||
const itemPathParam = new URLSearchParams(path.split("?")[1]).get(
|
||||
"path",
|
||||
);
|
||||
if (itemPathParam) {
|
||||
try {
|
||||
const itemSdPath = JSON.parse(
|
||||
decodeURIComponent(itemPathParam),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(currentPath) === JSON.stringify(itemSdPath)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to URL-based comparison
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!path) return false;
|
||||
|
||||
// Special routes (/, /recents, /favorites, etc.): exact pathname match
|
||||
if (!path.startsWith("/explorer?")) {
|
||||
return location.pathname === path;
|
||||
}
|
||||
|
||||
// Explorer routes: compare SD paths via URL
|
||||
if (location.pathname === "/explorer") {
|
||||
const currentSearchParams = new URLSearchParams(location.search);
|
||||
const currentPathParam = currentSearchParams.get("path");
|
||||
const itemPathParam = new URLSearchParams(path.split("?")[1]).get(
|
||||
"path",
|
||||
);
|
||||
|
||||
if (currentPathParam && itemPathParam) {
|
||||
try {
|
||||
const currentSdPath = JSON.parse(
|
||||
decodeURIComponent(currentPathParam),
|
||||
);
|
||||
const itemSdPath = JSON.parse(
|
||||
decodeURIComponent(itemPathParam),
|
||||
);
|
||||
return (
|
||||
JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath)
|
||||
);
|
||||
} catch {
|
||||
return currentPathParam === itemPathParam;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
FolderOpen,
|
||||
MagnifyingGlass,
|
||||
Trash,
|
||||
Database,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
|
||||
import {
|
||||
useContextMenu,
|
||||
type ContextMenuItem,
|
||||
type ContextMenuResult,
|
||||
} from "../../../hooks/useContextMenu";
|
||||
import { usePlatform } from "../../../platform";
|
||||
import { useLibraryMutation } from "../../../context";
|
||||
import { isVolumeItem, isPathItem } from "./spaceItemUtils";
|
||||
import { useExplorer, getSpaceItemKeyFromRoute } from "../../Explorer/context";
|
||||
|
||||
interface UseSpaceItemContextMenuOptions {
|
||||
item: SpaceItemType;
|
||||
path: string | null;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides context menu functionality for space items.
|
||||
*
|
||||
* Menu items include:
|
||||
* - Open: Navigate to the item's path
|
||||
* - Index Volume: Trigger indexing for volume items
|
||||
* - Show in Finder: Reveal file in OS file manager (Path items only)
|
||||
* - Remove from Space: Delete the item from the current space
|
||||
*/
|
||||
export function useSpaceItemContextMenu({
|
||||
item,
|
||||
path,
|
||||
spaceId,
|
||||
}: UseSpaceItemContextMenuOptions): ContextMenuResult {
|
||||
const navigate = useNavigate();
|
||||
const platform = usePlatform();
|
||||
const { loadPreferencesForSpaceItem } = useExplorer();
|
||||
const deleteItem = useLibraryMutation("spaces.delete_item");
|
||||
const indexVolume = useLibraryMutation("volumes.index");
|
||||
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
icon: FolderOpen,
|
||||
label: "Open",
|
||||
onClick: () => {
|
||||
if (path) {
|
||||
// Extract pathname and search from the path
|
||||
const [pathname, search] = path.includes("?")
|
||||
? [path.split("?")[0], "?" + path.split("?")[1]]
|
||||
: [path, ""];
|
||||
const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search);
|
||||
loadPreferencesForSpaceItem(spaceItemKey);
|
||||
navigate(path);
|
||||
}
|
||||
},
|
||||
condition: () => !!path,
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
label: "Index Volume",
|
||||
onClick: async () => {
|
||||
if (isVolumeItem(item.item_type)) {
|
||||
const fingerprint =
|
||||
(item as SpaceItemType & { fingerprint?: string })
|
||||
.fingerprint || item.item_type.Volume.volume_id;
|
||||
|
||||
try {
|
||||
const result = await indexVolume.mutateAsync({
|
||||
fingerprint: fingerprint.toString(),
|
||||
scope: "Recursive",
|
||||
});
|
||||
console.log("Volume indexed:", result.message);
|
||||
} catch (err) {
|
||||
console.error("Failed to index volume:", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
condition: () => isVolumeItem(item.item_type),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
icon: MagnifyingGlass,
|
||||
label: "Show in Finder",
|
||||
onClick: async () => {
|
||||
if (isPathItem(item.item_type)) {
|
||||
const sdPath = item.item_type.Path.sd_path;
|
||||
if (typeof sdPath === "object" && "Physical" in sdPath) {
|
||||
const physicalPath = (
|
||||
sdPath as { Physical: { path: string } }
|
||||
).Physical.path;
|
||||
if (platform.revealFile) {
|
||||
try {
|
||||
await platform.revealFile(physicalPath);
|
||||
} catch (err) {
|
||||
console.error("Failed to reveal file:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
keybind: "⌘⇧R",
|
||||
condition: () => {
|
||||
if (!isPathItem(item.item_type)) return false;
|
||||
const sdPath = item.item_type.Path.sd_path;
|
||||
return (
|
||||
typeof sdPath === "object" &&
|
||||
"Physical" in sdPath &&
|
||||
!!platform.revealFile
|
||||
);
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
icon: Trash,
|
||||
label: "Remove from Space",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await deleteItem.mutateAsync({ item_id: item.id });
|
||||
} catch (err) {
|
||||
console.error("Failed to remove item:", err);
|
||||
}
|
||||
},
|
||||
variant: "danger" as const,
|
||||
condition: () => spaceId != null,
|
||||
},
|
||||
];
|
||||
|
||||
return useContextMenu({ items });
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useDroppable, useDndContext } from "@dnd-kit/core";
|
||||
import type { SpaceItem as SpaceItemType, SdPath } from "@sd/ts-client";
|
||||
import {
|
||||
isDropTargetItem,
|
||||
getDropTargetType,
|
||||
buildDropTargetPath,
|
||||
type DropTargetType,
|
||||
} from "./spaceItemUtils";
|
||||
|
||||
interface UseSpaceItemDropZonesOptions {
|
||||
item: SpaceItemType;
|
||||
allowInsertion: boolean;
|
||||
spaceId?: string;
|
||||
groupId?: string | null;
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
}
|
||||
|
||||
interface DropZoneRefs {
|
||||
setTopRef: (node: HTMLElement | null) => void;
|
||||
setBottomRef: (node: HTMLElement | null) => void;
|
||||
setMiddleRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface DropZoneState {
|
||||
isOverTop: boolean;
|
||||
isOverBottom: boolean;
|
||||
isOverMiddle: boolean;
|
||||
isDropTarget: boolean;
|
||||
targetType: DropTargetType;
|
||||
targetPath: SdPath | undefined;
|
||||
isDraggingSortableItem: boolean;
|
||||
}
|
||||
|
||||
export type UseSpaceItemDropZonesResult = DropZoneRefs & DropZoneState;
|
||||
|
||||
/**
|
||||
* Manages drop zones for a space item.
|
||||
*
|
||||
* SpaceItems support two types of drop interactions:
|
||||
* 1. Insertion (reordering): Blue line above/below for sidebar item reordering
|
||||
* 2. Move-into (file operations): Blue ring for moving files into location/folder
|
||||
*/
|
||||
export function useSpaceItemDropZones({
|
||||
item,
|
||||
allowInsertion,
|
||||
spaceId,
|
||||
groupId,
|
||||
volumeData,
|
||||
}: UseSpaceItemDropZonesOptions): UseSpaceItemDropZonesResult {
|
||||
const { active } = useDndContext();
|
||||
|
||||
// Disable insertion zones when dragging groups or space items (they have 'label' in data)
|
||||
const isDraggingSortableItem = active?.data?.current?.label != null;
|
||||
|
||||
// Determine if this item can receive file drops
|
||||
const isDropTarget = isDropTargetItem(item);
|
||||
const targetType = getDropTargetType(item);
|
||||
const targetPath = buildDropTargetPath(item, volumeData);
|
||||
|
||||
// Top zone: insertion above
|
||||
const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({
|
||||
id: `space-item-${item.id}-top`,
|
||||
disabled: !allowInsertion || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "insert-before",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
// Bottom zone: insertion below
|
||||
const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({
|
||||
id: `space-item-${item.id}-bottom`,
|
||||
disabled: !allowInsertion || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "insert-after",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
// Middle zone: drop into folder/location
|
||||
const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({
|
||||
id: `space-item-${item.id}-middle`,
|
||||
disabled: !isDropTarget || isDraggingSortableItem,
|
||||
data: {
|
||||
action: "move-into",
|
||||
targetType,
|
||||
targetId: item.id,
|
||||
targetPath,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
setTopRef,
|
||||
setBottomRef,
|
||||
setMiddleRef,
|
||||
isOverTop,
|
||||
isOverBottom,
|
||||
isOverMiddle,
|
||||
isDropTarget,
|
||||
targetType,
|
||||
targetPath,
|
||||
isDraggingSortableItem,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { GearSix, Palette } from "@phosphor-icons/react";
|
||||
import { GearSix, Palette, ArrowsClockwise, ListBullets, CircleNotch, ArrowsOut, FunnelSimple } from "@phosphor-icons/react";
|
||||
import { useSidebarStore, useLibraryMutation } from "@sd/ts-client";
|
||||
import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client";
|
||||
import { TopBarButton, Popover, usePopover } from "@sd/ui";
|
||||
import { useSpaces, useSpaceLayout } from "./hooks/useSpaces";
|
||||
import { SpaceSwitcher } from "./SpaceSwitcher";
|
||||
import { SpaceGroup } from "./SpaceGroup";
|
||||
@@ -11,12 +12,19 @@ import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel";
|
||||
import { useSpacedriveClient } from "../../context";
|
||||
import { useLibraries } from "../../hooks/useLibraries";
|
||||
import { usePlatform } from "../../platform";
|
||||
import { JobManagerPopover } from "../JobManager/JobManagerPopover";
|
||||
import { SyncMonitorPopover } from "../SyncMonitor";
|
||||
import { useJobs } from "../JobManager/hooks/useJobs";
|
||||
import { useSyncCount } from "../SyncMonitor/hooks/useSyncCount";
|
||||
import { useSyncMonitor } from "../SyncMonitor/hooks/useSyncMonitor";
|
||||
import { PeerList } from "../SyncMonitor/components/PeerList";
|
||||
import { ActivityFeed } from "../SyncMonitor/components/ActivityFeed";
|
||||
import { JobList } from "../JobManager/components/JobList";
|
||||
import { motion } from "framer-motion";
|
||||
import { CARD_HEIGHT } from "../JobManager/types";
|
||||
import clsx from "clsx";
|
||||
import { useDroppable, useDndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Wrapper that adds a space-level drop zone before each group and makes it sortable
|
||||
function SpaceGroupWithDropZone({
|
||||
@@ -86,6 +94,205 @@ function SpaceGroupWithDropZone({
|
||||
);
|
||||
}
|
||||
|
||||
// Sync Monitor Button with Popover
|
||||
function SyncButton() {
|
||||
const popover = usePopover();
|
||||
const navigate = useNavigate();
|
||||
const [showActivityFeed, setShowActivityFeed] = useState(false);
|
||||
const { onlinePeerCount, isSyncing } = useSyncCount();
|
||||
const sync = useSyncMonitor();
|
||||
|
||||
useEffect(() => {
|
||||
if (popover.open) {
|
||||
setShowActivityFeed(false);
|
||||
}
|
||||
}, [popover.open]);
|
||||
|
||||
const getStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "Ready":
|
||||
return "bg-green-500";
|
||||
case "Backfilling":
|
||||
return "bg-yellow-500";
|
||||
case "CatchingUp":
|
||||
return "bg-accent";
|
||||
case "Uninitialized":
|
||||
return "bg-ink-faint";
|
||||
case "Paused":
|
||||
return "bg-ink-dull";
|
||||
default:
|
||||
return "bg-ink-faint";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
popover={popover}
|
||||
trigger={
|
||||
<TopBarButton
|
||||
icon={({ className, ...props }) =>
|
||||
isSyncing ? (
|
||||
<CircleNotch className={clsx(className, "animate-spin")} {...props} />
|
||||
) : (
|
||||
<ArrowsClockwise className={className} {...props} />
|
||||
)
|
||||
}
|
||||
title="Sync Monitor"
|
||||
/>
|
||||
}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[380px] max-h-[520px] z-50 !p-0 !bg-app !rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-app-line">
|
||||
<h3 className="text-sm font-semibold text-ink">Sync Monitor</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onlinePeerCount > 0 && (
|
||||
<span className="text-xs text-ink-dull">
|
||||
{onlinePeerCount} {onlinePeerCount === 1 ? "peer" : "peers"} online
|
||||
</span>
|
||||
)}
|
||||
|
||||
<TopBarButton
|
||||
icon={ArrowsOut}
|
||||
onClick={() => navigate("/sync")}
|
||||
title="Open full sync monitor"
|
||||
/>
|
||||
|
||||
<TopBarButton
|
||||
icon={FunnelSimple}
|
||||
active={showActivityFeed}
|
||||
onClick={() => setShowActivityFeed(!showActivityFeed)}
|
||||
title={showActivityFeed ? "Show peers" : "Show activity feed"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{popover.open && (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b border-app-line bg-app-box/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`size-2 rounded-full ${getStateColor(sync.currentState)}`} />
|
||||
<span className="text-xs font-medium text-ink-dull">{sync.currentState}</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="overflow-y-auto no-scrollbar"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: showActivityFeed
|
||||
? Math.min(sync.recentActivity.length * 40 + 16, 400)
|
||||
: Math.min(sync.peers.length * 80 + 16, 400),
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
|
||||
>
|
||||
{showActivityFeed ? (
|
||||
<ActivityFeed activities={sync.recentActivity} />
|
||||
) : (
|
||||
<PeerList peers={sync.peers} currentState={sync.currentState} />
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Jobs Button with Popover
|
||||
function JobsButton({
|
||||
activeJobCount,
|
||||
hasRunningJobs,
|
||||
jobs,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
navigate
|
||||
}: {
|
||||
activeJobCount: number;
|
||||
hasRunningJobs: boolean;
|
||||
jobs: any[];
|
||||
pause: (jobId: string) => Promise<void>;
|
||||
resume: (jobId: string) => Promise<void>;
|
||||
cancel: (jobId: string) => Promise<void>;
|
||||
navigate: any;
|
||||
}) {
|
||||
const popover = usePopover();
|
||||
const [showOnlyRunning, setShowOnlyRunning] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (popover.open) {
|
||||
setShowOnlyRunning(true);
|
||||
}
|
||||
}, [popover.open]);
|
||||
|
||||
const filteredJobs = showOnlyRunning
|
||||
? jobs.filter((job) => job.status === "running" || job.status === "paused")
|
||||
: jobs;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
popover={popover}
|
||||
trigger={
|
||||
<TopBarButton
|
||||
icon={({ className, ...props }) =>
|
||||
hasRunningJobs ? (
|
||||
<CircleNotch className={clsx(className, "animate-spin")} {...props} />
|
||||
) : (
|
||||
<ListBullets className={className} {...props} />
|
||||
)
|
||||
}
|
||||
title="Job Manager"
|
||||
/>
|
||||
}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[360px] max-h-[480px] z-50 !p-0 !bg-app !rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-app-line">
|
||||
<h3 className="text-sm font-semibold text-ink">Job Manager</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeJobCount > 0 && (
|
||||
<span className="text-xs text-ink-dull">{activeJobCount} active</span>
|
||||
)}
|
||||
|
||||
<TopBarButton
|
||||
icon={ArrowsOut}
|
||||
onClick={() => navigate("/jobs")}
|
||||
title="Open full jobs screen"
|
||||
/>
|
||||
|
||||
<TopBarButton
|
||||
icon={FunnelSimple}
|
||||
active={showOnlyRunning}
|
||||
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
|
||||
title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{popover.open && (
|
||||
<motion.div
|
||||
className="overflow-y-auto no-scrollbar"
|
||||
initial={false}
|
||||
animate={{
|
||||
height:
|
||||
filteredJobs.length === 0
|
||||
? CARD_HEIGHT + 16
|
||||
: Math.min(filteredJobs.length * (CARD_HEIGHT + 8) + 16, 400),
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
|
||||
>
|
||||
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
|
||||
</motion.div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpacesSidebarProps {
|
||||
isPreviewActive?: boolean;
|
||||
}
|
||||
@@ -93,12 +300,17 @@ interface SpacesSidebarProps {
|
||||
export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
const client = useSpacedriveClient();
|
||||
const platform = usePlatform();
|
||||
const navigate = useNavigate();
|
||||
const { data: libraries } = useLibraries();
|
||||
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(
|
||||
() => client.getCurrentLibraryId(),
|
||||
);
|
||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||
|
||||
// Get sync and job status for icons
|
||||
const { onlinePeerCount, isSyncing } = useSyncCount();
|
||||
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs();
|
||||
|
||||
const { currentSpaceId, setCurrentSpace } = useSidebarStore();
|
||||
const { data: spacesData } = useSpaces();
|
||||
const spaces = spacesData?.spaces;
|
||||
@@ -209,20 +421,27 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */}
|
||||
<div className="space-y-0.5">
|
||||
<SyncMonitorPopover />
|
||||
<JobManagerPopover />
|
||||
<button
|
||||
onClick={() => setCustomizePanelOpen(true)}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
|
||||
"text-sidebar-inkDull cursor-default",
|
||||
)}
|
||||
>
|
||||
<Palette className="size-4" weight="bold" />
|
||||
<span className="truncate">Customize</span>
|
||||
</button>
|
||||
<button
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncButton />
|
||||
<JobsButton
|
||||
activeJobCount={activeJobCount}
|
||||
hasRunningJobs={hasRunningJobs}
|
||||
jobs={jobs}
|
||||
pause={pause}
|
||||
resume={resume}
|
||||
cancel={cancel}
|
||||
navigate={navigate}
|
||||
/>
|
||||
<TopBarButton
|
||||
icon={Palette}
|
||||
title="Customize"
|
||||
onClick={() => setCustomizePanelOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<TopBarButton
|
||||
icon={GearSix}
|
||||
title="Settings"
|
||||
onClick={() => {
|
||||
if (platform.showWindow) {
|
||||
platform.showWindow({ type: "Settings", page: "general" }).catch(err =>
|
||||
@@ -230,14 +449,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
|
||||
"text-sidebar-inkDull cursor-default",
|
||||
)}
|
||||
>
|
||||
<GearSix className="size-4" weight="bold" />
|
||||
<span className="truncate">Settings</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ContextMenuConfig {
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuResult {
|
||||
export interface ContextMenuResult {
|
||||
show: (e: React.MouseEvent) => Promise<void>;
|
||||
menuData: ContextMenuItem[] | null;
|
||||
closeMenu: () => void;
|
||||
|
||||
@@ -13,6 +13,7 @@ export { LocationCacheDemo } from "./LocationCacheDemo";
|
||||
export { Inspector, PopoutInspector } from "./Inspector";
|
||||
export type { InspectorVariant } from "./Inspector";
|
||||
export { QuickPreview } from "./components/QuickPreview";
|
||||
export { JobsScreen } from "./components/JobManager";
|
||||
export { Settings } from "./Settings";
|
||||
export { Spacedrop } from "./Spacedrop";
|
||||
export { PairingModal } from "./components/PairingModal";
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { HardDrive, Plus, Database } from "@phosphor-icons/react";
|
||||
import {
|
||||
HardDrive,
|
||||
Plus,
|
||||
Database,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import Masonry from "react-masonry-css";
|
||||
import DriveIcon from "@sd/assets/icons/Drive.png";
|
||||
import HDDIcon from "@sd/assets/icons/HDD.png";
|
||||
@@ -10,6 +16,7 @@ import DriveAmazonS3Icon from "@sd/assets/icons/Drive-AmazonS3.png";
|
||||
import DriveGoogleDriveIcon from "@sd/assets/icons/Drive-GoogleDrive.png";
|
||||
import DriveDropboxIcon from "@sd/assets/icons/Drive-Dropbox.png";
|
||||
import LocationIcon from "@sd/assets/icons/Location.png";
|
||||
import { TopBarButton } from "@sd/ui/TopBarButton";
|
||||
import {
|
||||
useNormalizedQuery,
|
||||
useLibraryMutation,
|
||||
@@ -386,54 +393,11 @@ function DeviceCard({
|
||||
|
||||
{/* Locations for this device */}
|
||||
{locations.length > 0 && (
|
||||
<div className="px-3 py-3 border-b border-app-line">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{locations.map((location) => {
|
||||
const isSelected =
|
||||
selectedLocationId === location.id;
|
||||
return (
|
||||
<button
|
||||
key={location.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onLocationSelect?.(null);
|
||||
} else {
|
||||
onLocationSelect?.(location);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center gap-2 p-1 rounded-lg transition-all min-w-[80px]"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2",
|
||||
isSelected
|
||||
? "bg-app-box"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={LocationIcon}
|
||||
alt={location.name}
|
||||
className="size-12 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"text-xs truncate px-2 py-0.5 rounded-md inline-block max-w-full",
|
||||
isSelected
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
)}
|
||||
>
|
||||
{location.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<LocationsScroller
|
||||
locations={locations}
|
||||
selectedLocationId={selectedLocationId}
|
||||
onLocationSelect={onLocationSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Volumes for this device */}
|
||||
@@ -460,6 +424,128 @@ function DeviceCard({
|
||||
);
|
||||
}
|
||||
|
||||
interface LocationsScrollerProps {
|
||||
locations: Location[];
|
||||
selectedLocationId: string | null;
|
||||
onLocationSelect?: (location: Location | null) => void;
|
||||
}
|
||||
|
||||
function LocationsScroller({
|
||||
locations,
|
||||
selectedLocationId,
|
||||
onLocationSelect,
|
||||
}: LocationsScrollerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
return () => window.removeEventListener("resize", updateScrollState);
|
||||
}, [locations]);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
if (!scrollRef.current) return;
|
||||
const scrollAmount = 200;
|
||||
scrollRef.current.scrollBy({
|
||||
left: direction === "left" ? -scrollAmount : scrollAmount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-3 border-b border-app-line">
|
||||
<div className="relative">
|
||||
{/* Left fade and button */}
|
||||
{canScrollLeft && (
|
||||
<>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-app-darkBox to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20">
|
||||
<TopBarButton
|
||||
icon={CaretLeft}
|
||||
onClick={() => scroll("left")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateScrollState}
|
||||
className="flex gap-2 overflow-x-auto scrollbar-hide"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{locations.map((location) => {
|
||||
const isSelected = selectedLocationId === location.id;
|
||||
return (
|
||||
<button
|
||||
key={location.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onLocationSelect?.(null);
|
||||
} else {
|
||||
onLocationSelect?.(location);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center gap-2 p-1 rounded-lg transition-all min-w-[80px] flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2",
|
||||
isSelected
|
||||
? "bg-app-box"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={LocationIcon}
|
||||
alt={location.name}
|
||||
className="size-12 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"text-xs truncate px-2 py-0.5 rounded-md inline-block max-w-full",
|
||||
isSelected
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
)}
|
||||
>
|
||||
{location.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right fade and button */}
|
||||
{canScrollRight && (
|
||||
<>
|
||||
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-app-darkBox to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-20">
|
||||
<TopBarButton
|
||||
icon={CaretRight}
|
||||
onClick={() => scroll("right")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VolumeBarProps {
|
||||
volume: VolumeItem;
|
||||
index: number;
|
||||
|
||||
@@ -419,7 +419,14 @@ export type DeviceInfo = { id: string; name: string; os: string; hardware_model:
|
||||
*/
|
||||
export type DeviceMetricsSnapshot = { device_id: string; device_name: string; entries_received: number; last_seen: string; is_online: boolean };
|
||||
|
||||
export type DeviceRevokeInput = { device_id: string };
|
||||
export type DeviceRevokeInput = { device_id: string;
|
||||
/**
|
||||
* Whether to also remove the device from all library databases
|
||||
*
|
||||
* If false (default), only unpairs from network but keeps device history in libraries.
|
||||
* If true, completely removes device from libraries (deletes all records).
|
||||
*/
|
||||
remove_from_library?: boolean };
|
||||
|
||||
export type DeviceRevokeOutput = { revoked: boolean };
|
||||
|
||||
@@ -614,6 +621,28 @@ export type EntryKind =
|
||||
*/
|
||||
"Symlink";
|
||||
|
||||
/**
|
||||
* Input for resetting the ephemeral cache
|
||||
*/
|
||||
export type EphemeralCacheResetInput = {
|
||||
/**
|
||||
* Confirmation flag to prevent accidental cache clearing
|
||||
*/
|
||||
confirm: boolean };
|
||||
|
||||
/**
|
||||
* Output from resetting the ephemeral cache
|
||||
*/
|
||||
export type EphemeralCacheResetOutput = {
|
||||
/**
|
||||
* Number of paths that were cleared from the cache
|
||||
*/
|
||||
cleared_paths: number;
|
||||
/**
|
||||
* Message describing the result
|
||||
*/
|
||||
message: string };
|
||||
|
||||
/**
|
||||
* Status of the unified ephemeral index cache
|
||||
*/
|
||||
@@ -714,7 +743,23 @@ export type Event = "CoreStarted" | "CoreShutdown" | { LibraryCreated: { id: str
|
||||
/**
|
||||
* How the library was created (manual, sync, cloud import)
|
||||
*/
|
||||
source?: LibraryCreationSource } } | { LibraryOpened: { id: string; name: string; path: string } } | { LibraryClosed: { id: string; name: string } } | { LibraryDeleted: { id: string; name: string; deleted_data: boolean } } | { LibraryStatisticsUpdated: { library_id: string; statistics: LibraryStatistics } } |
|
||||
source?: LibraryCreationSource } } | { LibraryOpened: { id: string; name: string; path: string } } | { LibraryClosed: { id: string; name: string } } | { LibraryDeleted: { id: string; name: string; deleted_data: boolean } } | { LibraryLoadFailed: {
|
||||
/**
|
||||
* Library ID if config was readable, None otherwise
|
||||
*/
|
||||
id: string | null;
|
||||
/**
|
||||
* Path to the library directory
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
error: string;
|
||||
/**
|
||||
* Error type for frontend categorization (e.g., "DatabaseError", "ConfigError")
|
||||
*/
|
||||
error_type: string } } | { LibraryStatisticsUpdated: { library_id: string; statistics: LibraryStatistics } } |
|
||||
/**
|
||||
* Refresh event - signals that all frontend caches should be invalidated
|
||||
* Emitted after major data recalculations (e.g., volume unique_bytes refresh)
|
||||
@@ -1623,13 +1668,13 @@ export type JobCancelOutput = { job_id: string; success: boolean };
|
||||
*/
|
||||
export type JobId = string;
|
||||
|
||||
export type JobInfoOutput = { id: string; name: string; status: JobStatus; progress: number; started_at: string; completed_at: string | null; error_message: string | null };
|
||||
export type JobInfoOutput = { id: string; name: string; status: JobStatus; progress: number; created_at: string; started_at: string | null; completed_at: string | null; error_message: string | null };
|
||||
|
||||
export type JobInfoQueryInput = { job_id: string };
|
||||
|
||||
export type JobListInput = { status: JobStatus | null };
|
||||
|
||||
export type JobListItem = { id: string; name: string; device_id: string; status: JobStatus; progress: number; action_type: string | null; action_context: ActionContextInfo | null };
|
||||
export type JobListItem = { id: string; name: string; device_id: string; status: JobStatus; progress: number; action_type: string | null; action_context: ActionContextInfo | null; created_at: string; started_at: string | null; completed_at: string | null };
|
||||
|
||||
export type JobListOutput = { jobs: JobListItem[] };
|
||||
|
||||
@@ -4052,213 +4097,215 @@ success: boolean };
|
||||
// ===== API Type Unions =====
|
||||
|
||||
export type CoreAction =
|
||||
{ type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
|
||||
{ type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
|
||||
| { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput }
|
||||
| { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput }
|
||||
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
|
||||
| { type: 'core.ephemeral_reset'; input: EphemeralCacheResetInput; output: EphemeralCacheResetOutput }
|
||||
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
|
||||
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
|
||||
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
|
||||
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
|
||||
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
|
||||
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
|
||||
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
|
||||
| { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
|
||||
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
|
||||
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
|
||||
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
|
||||
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
|
||||
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
|
||||
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
|
||||
| { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
|
||||
| { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput }
|
||||
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
|
||||
;
|
||||
|
||||
export type LibraryAction =
|
||||
{ type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
|
||||
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
|
||||
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
|
||||
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
|
||||
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
|
||||
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
|
||||
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
|
||||
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
|
||||
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
|
||||
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
|
||||
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
|
||||
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
|
||||
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
|
||||
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
|
||||
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
|
||||
| { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput }
|
||||
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
|
||||
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
|
||||
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
|
||||
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
|
||||
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
|
||||
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
|
||||
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
|
||||
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
|
||||
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
|
||||
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
|
||||
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
|
||||
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
|
||||
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
|
||||
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
|
||||
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
|
||||
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
|
||||
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
|
||||
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
|
||||
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
|
||||
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
|
||||
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
|
||||
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
|
||||
{ type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
|
||||
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
|
||||
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
|
||||
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
|
||||
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
|
||||
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
|
||||
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
|
||||
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
|
||||
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
|
||||
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
|
||||
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
|
||||
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
|
||||
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
|
||||
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
|
||||
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
|
||||
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
|
||||
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
|
||||
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
|
||||
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
|
||||
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
|
||||
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
|
||||
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
|
||||
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
|
||||
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
|
||||
| { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput }
|
||||
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
|
||||
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
|
||||
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
|
||||
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
|
||||
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
|
||||
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
|
||||
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
|
||||
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
|
||||
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
|
||||
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
|
||||
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
|
||||
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
|
||||
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
|
||||
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
|
||||
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
|
||||
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
|
||||
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
|
||||
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
|
||||
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
|
||||
;
|
||||
|
||||
export type CoreQuery =
|
||||
{ type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
|
||||
{ type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
|
||||
| { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput }
|
||||
| { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput }
|
||||
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
|
||||
| { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
|
||||
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
|
||||
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
|
||||
| { type: 'core.status'; input: Empty; output: CoreStatus }
|
||||
| { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput }
|
||||
| { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput }
|
||||
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
|
||||
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
|
||||
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
|
||||
| { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
|
||||
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
|
||||
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
|
||||
;
|
||||
|
||||
export type LibraryQuery =
|
||||
{ type: 'files.by_id'; input: FileByIdQuery; output: File }
|
||||
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
|
||||
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
|
||||
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
|
||||
{ type: 'test.ping'; input: PingInput; output: PingOutput }
|
||||
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
|
||||
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
|
||||
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
|
||||
| { type: 'files.content_kind_stats'; input: ContentKindStatsInput; output: ContentKindStatsOutput }
|
||||
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
|
||||
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
|
||||
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
|
||||
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
|
||||
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
|
||||
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
|
||||
| { type: 'test.ping'; input: PingInput; output: PingOutput }
|
||||
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
|
||||
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
|
||||
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
|
||||
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
|
||||
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
|
||||
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
|
||||
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
|
||||
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
|
||||
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
|
||||
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
|
||||
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
|
||||
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
|
||||
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
|
||||
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
|
||||
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
|
||||
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
|
||||
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
|
||||
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] }
|
||||
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
|
||||
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
|
||||
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
|
||||
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
|
||||
| { type: 'files.by_id'; input: FileByIdQuery; output: File }
|
||||
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
|
||||
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
|
||||
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
|
||||
;
|
||||
|
||||
// ===== Wire Method Mappings =====
|
||||
|
||||
export const WIRE_METHODS = {
|
||||
coreActions: {
|
||||
'libraries.delete': 'action:libraries.delete.input',
|
||||
'libraries.create': 'action:libraries.create.input',
|
||||
'models.whisper.delete': 'action:models.whisper.delete.input',
|
||||
'models.whisper.download': 'action:models.whisper.download.input',
|
||||
'libraries.create': 'action:libraries.create.input',
|
||||
'core.ephemeral_reset': 'action:core.ephemeral_reset.input',
|
||||
'network.pair.cancel': 'action:network.pair.cancel.input',
|
||||
'network.sync_setup': 'action:network.sync_setup.input',
|
||||
'network.start': 'action:network.start.input',
|
||||
'network.pair.generate': 'action:network.pair.generate.input',
|
||||
'libraries.open': 'action:libraries.open.input',
|
||||
'network.spacedrop.send': 'action:network.spacedrop.send.input',
|
||||
'network.device.revoke': 'action:network.device.revoke.input',
|
||||
'network.stop': 'action:network.stop.input',
|
||||
'network.pair.join': 'action:network.pair.join.input',
|
||||
'network.spacedrop.send': 'action:network.spacedrop.send.input',
|
||||
'network.pair.generate': 'action:network.pair.generate.input',
|
||||
'network.device.revoke': 'action:network.device.revoke.input',
|
||||
'network.sync_setup': 'action:network.sync_setup.input',
|
||||
'network.pair.cancel': 'action:network.pair.cancel.input',
|
||||
'libraries.delete': 'action:libraries.delete.input',
|
||||
'core.reset': 'action:core.reset.input',
|
||||
'libraries.open': 'action:libraries.open.input',
|
||||
},
|
||||
|
||||
libraryActions: {
|
||||
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
|
||||
'indexing.verify': 'action:indexing.verify.input',
|
||||
'locations.export': 'action:locations.export.input',
|
||||
'jobs.cancel': 'action:jobs.cancel.input',
|
||||
'files.delete': 'action:files.delete.input',
|
||||
'spaces.delete_item': 'action:spaces.delete_item.input',
|
||||
'locations.remove': 'action:locations.remove.input',
|
||||
'spaces.delete_group': 'action:spaces.delete_group.input',
|
||||
'volumes.speed_test': 'action:volumes.speed_test.input',
|
||||
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
|
||||
'locations.add': 'action:locations.add.input',
|
||||
'media.speech.transcribe': 'action:media.speech.transcribe.input',
|
||||
'spaces.update_group': 'action:spaces.update_group.input',
|
||||
'spaces.update': 'action:spaces.update.input',
|
||||
'spaces.add_group': 'action:spaces.add_group.input',
|
||||
'media.splat.generate': 'action:media.splat.generate.input',
|
||||
'tags.create': 'action:tags.create.input',
|
||||
'locations.update': 'action:locations.update.input',
|
||||
'jobs.pause': 'action:jobs.pause.input',
|
||||
'libraries.export': 'action:libraries.export.input',
|
||||
'locations.enable_indexing': 'action:locations.enable_indexing.input',
|
||||
'files.copy': 'action:files.copy.input',
|
||||
'tags.apply': 'action:tags.apply.input',
|
||||
'volumes.untrack': 'action:volumes.untrack.input',
|
||||
'spaces.delete': 'action:spaces.delete.input',
|
||||
'spaces.add_item': 'action:spaces.add_item.input',
|
||||
'volumes.index': 'action:volumes.index.input',
|
||||
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
|
||||
'media.thumbnail': 'action:media.thumbnail.input',
|
||||
'locations.rescan': 'action:locations.rescan.input',
|
||||
'volumes.track': 'action:volumes.track.input',
|
||||
'spaces.create': 'action:spaces.create.input',
|
||||
'libraries.rename': 'action:libraries.rename.input',
|
||||
'volumes.add_cloud': 'action:volumes.add_cloud.input',
|
||||
'locations.import': 'action:locations.import.input',
|
||||
'media.proxy.generate': 'action:media.proxy.generate.input',
|
||||
'spaces.reorder_items': 'action:spaces.reorder_items.input',
|
||||
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
|
||||
'jobs.resume': 'action:jobs.resume.input',
|
||||
'volumes.refresh': 'action:volumes.refresh.input',
|
||||
'volumes.track': 'action:volumes.track.input',
|
||||
'spaces.update': 'action:spaces.update.input',
|
||||
'volumes.speed_test': 'action:volumes.speed_test.input',
|
||||
'volumes.untrack': 'action:volumes.untrack.input',
|
||||
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
|
||||
'locations.update': 'action:locations.update.input',
|
||||
'locations.triggerJob': 'action:locations.triggerJob.input',
|
||||
'indexing.start': 'action:indexing.start.input',
|
||||
'spaces.delete': 'action:spaces.delete.input',
|
||||
'spaces.add_item': 'action:spaces.add_item.input',
|
||||
'libraries.export': 'action:libraries.export.input',
|
||||
'spaces.reorder_items': 'action:spaces.reorder_items.input',
|
||||
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
|
||||
'volumes.add_cloud': 'action:volumes.add_cloud.input',
|
||||
'spaces.update_group': 'action:spaces.update_group.input',
|
||||
'locations.enable_indexing': 'action:locations.enable_indexing.input',
|
||||
'locations.add': 'action:locations.add.input',
|
||||
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
|
||||
'spaces.create': 'action:spaces.create.input',
|
||||
'locations.remove': 'action:locations.remove.input',
|
||||
'jobs.pause': 'action:jobs.pause.input',
|
||||
'media.splat.generate': 'action:media.splat.generate.input',
|
||||
'files.copy': 'action:files.copy.input',
|
||||
'media.speech.transcribe': 'action:media.speech.transcribe.input',
|
||||
'indexing.verify': 'action:indexing.verify.input',
|
||||
'spaces.add_group': 'action:spaces.add_group.input',
|
||||
'tags.apply': 'action:tags.apply.input',
|
||||
'locations.import': 'action:locations.import.input',
|
||||
'media.ocr.extract': 'action:media.ocr.extract.input',
|
||||
'indexing.start': 'action:indexing.start.input',
|
||||
'media.proxy.generate': 'action:media.proxy.generate.input',
|
||||
'locations.rescan': 'action:locations.rescan.input',
|
||||
'libraries.rename': 'action:libraries.rename.input',
|
||||
'locations.export': 'action:locations.export.input',
|
||||
'spaces.delete_item': 'action:spaces.delete_item.input',
|
||||
'tags.create': 'action:tags.create.input',
|
||||
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
|
||||
'media.thumbnail': 'action:media.thumbnail.input',
|
||||
'jobs.cancel': 'action:jobs.cancel.input',
|
||||
'volumes.index': 'action:volumes.index.input',
|
||||
'files.delete': 'action:files.delete.input',
|
||||
},
|
||||
|
||||
coreQueries: {
|
||||
'network.status': 'query:network.status',
|
||||
'network.devices.list': 'query:network.devices.list',
|
||||
'jobs.remote.all_devices': 'query:jobs.remote.all_devices',
|
||||
'jobs.remote.for_device': 'query:jobs.remote.for_device',
|
||||
'core.events.list': 'query:core.events.list',
|
||||
'core.ephemeral_status': 'query:core.ephemeral_status',
|
||||
'network.sync_setup.discover': 'query:network.sync_setup.discover',
|
||||
'network.pair.status': 'query:network.pair.status',
|
||||
'core.status': 'query:core.status',
|
||||
'jobs.remote.all_devices': 'query:jobs.remote.all_devices',
|
||||
'jobs.remote.for_device': 'query:jobs.remote.for_device',
|
||||
'models.whisper.list': 'query:models.whisper.list',
|
||||
'network.devices.list': 'query:network.devices.list',
|
||||
'libraries.list': 'query:libraries.list',
|
||||
'network.status': 'query:network.status',
|
||||
'models.whisper.list': 'query:models.whisper.list',
|
||||
'network.pair.status': 'query:network.pair.status',
|
||||
},
|
||||
|
||||
libraryQueries: {
|
||||
'files.by_id': 'query:files.by_id',
|
||||
'volumes.list': 'query:volumes.list',
|
||||
'jobs.info': 'query:jobs.info',
|
||||
'jobs.list': 'query:jobs.list',
|
||||
'test.ping': 'query:test.ping',
|
||||
'files.by_path': 'query:files.by_path',
|
||||
'files.unique_to_location': 'query:files.unique_to_location',
|
||||
'spaces.get_layout': 'query:spaces.get_layout',
|
||||
'files.content_kind_stats': 'query:files.content_kind_stats',
|
||||
'locations.validate_path': 'query:locations.validate_path',
|
||||
'tags.search': 'query:tags.search',
|
||||
'sync.eventLog': 'query:sync.eventLog',
|
||||
'search.files': 'query:search.files',
|
||||
'files.unique_to_location': 'query:files.unique_to_location',
|
||||
'files.directory_listing': 'query:files.directory_listing',
|
||||
'test.ping': 'query:test.ping',
|
||||
'locations.suggested': 'query:locations.suggested',
|
||||
'spaces.get': 'query:spaces.get',
|
||||
'locations.list': 'query:locations.list',
|
||||
'files.by_path': 'query:files.by_path',
|
||||
'sync.activity': 'query:sync.activity',
|
||||
'sync.metrics': 'query:sync.metrics',
|
||||
'jobs.info': 'query:jobs.info',
|
||||
'files.media_listing': 'query:files.media_listing',
|
||||
'files.directory_listing': 'query:files.directory_listing',
|
||||
'locations.validate_path': 'query:locations.validate_path',
|
||||
'locations.list': 'query:locations.list',
|
||||
'sync.activity': 'query:sync.activity',
|
||||
'search.files': 'query:search.files',
|
||||
'jobs.active': 'query:jobs.active',
|
||||
'libraries.info': 'query:libraries.info',
|
||||
'sync.eventLog': 'query:sync.eventLog',
|
||||
'volumes.list': 'query:volumes.list',
|
||||
'devices.list': 'query:devices.list',
|
||||
'spaces.list': 'query:spaces.list',
|
||||
'files.media_listing': 'query:files.media_listing',
|
||||
'jobs.list': 'query:jobs.list',
|
||||
'libraries.info': 'query:libraries.info',
|
||||
'files.by_id': 'query:files.by_id',
|
||||
'spaces.get': 'query:spaces.get',
|
||||
'tags.search': 'query:tags.search',
|
||||
'locations.suggested': 'query:locations.suggested',
|
||||
},
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user