mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
Merge branch 'main' into eng-1748-spacedrop-refactor-spacedrop-cloud
This commit is contained in:
commit
f0e81b9e33
17
.github/actions/publish-artifacts/index.ts
vendored
17
.github/actions/publish-artifacts/index.ts
vendored
|
@ -7,7 +7,7 @@ type OS = 'darwin' | 'windows' | 'linux';
|
|||
type Arch = 'x64' | 'arm64';
|
||||
type TargetConfig = { bundle: string; ext: string };
|
||||
type BuildTarget = {
|
||||
updater: { bundle: string; bundleExt: string; archiveExt: string };
|
||||
updater: false | { bundle: string; bundleExt: string; archiveExt: string };
|
||||
standalone: Array<TargetConfig>;
|
||||
};
|
||||
|
||||
|
@ -29,15 +29,8 @@ const OS_TARGETS = {
|
|||
standalone: [{ ext: 'msi', bundle: 'msi' }]
|
||||
},
|
||||
linux: {
|
||||
updater: {
|
||||
bundle: 'appimage',
|
||||
bundleExt: 'AppImage',
|
||||
archiveExt: 'tar.gz'
|
||||
},
|
||||
standalone: [
|
||||
{ ext: 'deb', bundle: 'deb' },
|
||||
{ ext: 'AppImage', bundle: 'appimage' }
|
||||
]
|
||||
updater: false,
|
||||
standalone: [{ ext: 'deb', bundle: 'deb' }]
|
||||
}
|
||||
} satisfies Record<OS, BuildTarget>;
|
||||
|
||||
|
@ -57,7 +50,9 @@ async function globFiles(pattern: string) {
|
|||
return await globber.glob();
|
||||
}
|
||||
|
||||
async function uploadUpdater({ bundle, bundleExt, archiveExt }: BuildTarget['updater']) {
|
||||
async function uploadUpdater(updater: BuildTarget['updater']) {
|
||||
if (!updater) return;
|
||||
const { bundle, bundleExt, archiveExt } = updater;
|
||||
const fullExt = `${bundleExt}.${archiveExt}`;
|
||||
const files = await globFiles(`${BUNDLE_DIR}/${bundle}/*.${fullExt}*`);
|
||||
|
||||
|
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
@ -112,16 +112,6 @@ jobs:
|
|||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Build AppImage in Docker
|
||||
if: ${{ runner.os == 'Linux' && ( matrix.settings.target == 'x86_64-unknown-linux-gnu' || matrix.settings.target == 'aarch64-unknown-linux-gnu' ) }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker run --rm -v $(pwd):/srv -e 'CI=true' -e 'TARGET=${{ matrix.settings.target }}' -w /srv debian:bookworm scripts/appimage/build_appimage.sh
|
||||
cd 'target/${{ matrix.settings.target }}/release/bundle/appimage'
|
||||
sudo chown "$(id -u):$(id -g)" -R .
|
||||
tar -czf Updater.AppImage.tar.gz *.AppImage
|
||||
pnpm tauri signer sign -k '${{ secrets.TAURI_PRIVATE_KEY }}' -p '${{ secrets.TAURI_KEY_PASSWORD }}' "$(pwd)/Updater.AppImage.tar.gz"
|
||||
|
||||
- name: Publish Artifacts
|
||||
uses: ./.github/actions/publish-artifacts
|
||||
with:
|
||||
|
|
|
@ -120,10 +120,6 @@ To run the mobile app:
|
|||
- `xcrun simctl launch --console booted com.spacedrive.app` allows you to view the console output of the iOS app from `tracing`. However, the application must be built in `debug` mode for this.
|
||||
- `pnpm mobile start` (runs the metro bundler only)
|
||||
|
||||
##### AppImage
|
||||
|
||||
Specific instructions on how to build an AppImage release are located [here](scripts/appimage/README.md)
|
||||
|
||||
### Pull Request
|
||||
|
||||
Once you have finished making your changes, create a pull request (PR) to submit them.
|
||||
|
@ -169,6 +165,10 @@ Once that has completed, run `xcode-select --install` in the terminal to install
|
|||
|
||||
Also ensure that Rosetta is installed, as a few of our dependencies require it. You can install Rosetta with `softwareupdate --install-rosetta --agree-to-license`.
|
||||
|
||||
### Translations
|
||||
Check out the [i18n README](interface/locales/README.md) for more information on how to contribute to translations.
|
||||
|
||||
|
||||
### Credits
|
||||
|
||||
This CONTRIBUTING.md file was inspired by the [github/docs CONTRIBUTING.md](https://github.com/github/docs/blob/main/CONTRIBUTING.md) file, and we extend our gratitude to the original author.
|
||||
|
|
6
Cargo.lock
generated
6
Cargo.lock
generated
|
@ -1219,7 +1219,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.5.0"
|
||||
source = "git+https://github.com/brxken128/blake3?rev=d3aab416c12a75c2bfabce33bcd594e428a79069#d3aab416c12a75c2bfabce33bcd594e428a79069"
|
||||
source = "git+https://github.com/spacedriveapp/blake3.git?rev=d3aab416c12a75c2bfabce33bcd594e428a79069#d3aab416c12a75c2bfabce33bcd594e428a79069"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
|
@ -8178,7 +8178,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "sd-core"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
dependencies = [
|
||||
"aovec",
|
||||
"async-channel",
|
||||
|
@ -8425,7 +8425,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "sd-desktop"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"directories 5.0.1",
|
||||
|
|
44
Cargo.toml
44
Cargo.toml
|
@ -1,17 +1,17 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"core",
|
||||
"core/crates/*",
|
||||
"crates/*",
|
||||
"apps/cli",
|
||||
"apps/p2p-relay",
|
||||
"apps/desktop/src-tauri",
|
||||
"apps/desktop/crates/*",
|
||||
"apps/mobile/modules/sd-core/core",
|
||||
"apps/mobile/modules/sd-core/android/crate",
|
||||
"apps/mobile/modules/sd-core/ios/crate",
|
||||
"apps/server",
|
||||
"core",
|
||||
"core/crates/*",
|
||||
"crates/*",
|
||||
"apps/cli",
|
||||
"apps/p2p-relay",
|
||||
"apps/desktop/src-tauri",
|
||||
"apps/desktop/crates/*",
|
||||
"apps/mobile/modules/sd-core/core",
|
||||
"apps/mobile/modules/sd-core/android/crate",
|
||||
"apps/mobile/modules/sd-core/ios/crate",
|
||||
"apps/server",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
@ -22,19 +22,19 @@ repository = "https://github.com/spacedriveapp/spacedrive"
|
|||
[workspace.dependencies]
|
||||
# First party dependencies
|
||||
prisma-client-rust = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "f99d6f5566570f3ab1edecb7a172ad25b03d95af", features = [
|
||||
"specta",
|
||||
"sqlite-create-many",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
"specta",
|
||||
"sqlite-create-many",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
], default-features = false }
|
||||
prisma-client-rust-cli = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "f99d6f5566570f3ab1edecb7a172ad25b03d95af", features = [
|
||||
"specta",
|
||||
"sqlite-create-many",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
"specta",
|
||||
"sqlite-create-many",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
], default-features = false }
|
||||
prisma-client-rust-sdk = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "f99d6f5566570f3ab1edecb7a172ad25b03d95af", features = [
|
||||
"sqlite",
|
||||
"sqlite",
|
||||
], default-features = false }
|
||||
|
||||
tracing = "0.1.40"
|
||||
|
@ -108,9 +108,7 @@ libp2p-core = { git = "https://github.com/spacedriveapp/rust-libp2p.git", rev =
|
|||
libp2p-swarm = { git = "https://github.com/spacedriveapp/rust-libp2p.git", rev = "a005656df7e82059a0eb2e333ebada4731d23f8c" }
|
||||
libp2p-stream = { git = "https://github.com/spacedriveapp/rust-libp2p.git", rev = "a005656df7e82059a0eb2e333ebada4731d23f8c" }
|
||||
|
||||
# aes = { git = "https://github.com/RustCrypto/block-ciphers", rev = "5837233f86419dbe75b8e3824349e30f6bc40b22" }
|
||||
|
||||
blake3 = { git = "https://github.com/brxken128/blake3", rev = "d3aab416c12a75c2bfabce33bcd594e428a79069" }
|
||||
blake3 = { git = "https://github.com/spacedriveapp/blake3.git", rev = "d3aab416c12a75c2bfabce33bcd594e428a79069" }
|
||||
|
||||
[profile.dev]
|
||||
# Make compilation faster on macOS
|
||||
|
|
|
@ -37,31 +37,6 @@ thread_local! {
|
|||
// )).unwrap_or_default();
|
||||
|
||||
let ctx = AppLaunchContext::default();
|
||||
|
||||
if let Some(appdir) = std::env::var_os("APPDIR").map(PathBuf::from) {
|
||||
// Remove AppImage paths from environment variables to avoid external applications attempting to use the AppImage's libraries
|
||||
// https://github.com/AppImage/AppImageKit/blob/701b711f42250584b65a88f6427006b1d160164d/src/AppRun.c#L168-L194
|
||||
ctx.unsetenv("PYTHONHOME");
|
||||
ctx.unsetenv("GTK_DATA_PREFIX");
|
||||
ctx.unsetenv("GTK_THEME");
|
||||
ctx.unsetenv("GDK_BACKEND");
|
||||
ctx.unsetenv("GTK_EXE_PREFIX");
|
||||
ctx.unsetenv("GTK_IM_MODULE_FILE");
|
||||
ctx.unsetenv("GDK_PIXBUF_MODULE_FILE");
|
||||
|
||||
remove_prefix_from_env_in_ctx(&ctx, "PATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "LD_LIBRARY_PATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "PYTHONPATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "XDG_DATA_DIRS", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "PERLLIB", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "GSETTINGS_SCHEMA_DIR", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "QT_PLUGIN_PATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "GST_PLUGIN_SYSTEM_PATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "GST_PLUGIN_SYSTEM_PATH_1_0", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "GTK_PATH", &appdir);
|
||||
remove_prefix_from_env_in_ctx(&ctx, "GIO_EXTRA_MODULES", &appdir);
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::{
|
|||
collections::HashSet,
|
||||
env,
|
||||
ffi::{CStr, OsStr, OsString},
|
||||
io, mem,
|
||||
mem,
|
||||
os::unix::ffi::OsStrExt,
|
||||
path::{Path, PathBuf},
|
||||
ptr,
|
||||
|
@ -191,56 +191,6 @@ pub fn normalize_environment() {
|
|||
],
|
||||
)
|
||||
.expect("PATH must be successfully normalized");
|
||||
|
||||
if let Ok(appdir) = get_appdir() {
|
||||
println!("Running from APPIMAGE");
|
||||
|
||||
// Workaround for https://github.com/AppImageCrafters/appimage-builder/issues/175
|
||||
env::set_current_dir(appdir.join({
|
||||
let appimage_libc_version = version(
|
||||
std::env::var("APPDIR_LIBC_VERSION")
|
||||
.expect("AppImage Libc version must be set")
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let system_lic_version = version({
|
||||
#[cfg(target_env = "gnu")]
|
||||
{
|
||||
use libc::gnu_get_libc_version;
|
||||
|
||||
let ptr = unsafe { gnu_get_libc_version() };
|
||||
if ptr.is_null() {
|
||||
panic!("Couldn't read glic version");
|
||||
}
|
||||
|
||||
unsafe { CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.expect("Couldn't read glic version")
|
||||
}
|
||||
#[cfg(not(target_env = "gnu"))]
|
||||
{
|
||||
// Use the same version as gcompat
|
||||
// https://git.adelielinux.org/adelie/gcompat/-/blob/current/libgcompat/version.c
|
||||
std::env::var("GLIBC_FAKE_VERSION").unwrap_or_else(|_| "2.8".to_string())
|
||||
}
|
||||
});
|
||||
|
||||
if system_lic_version < appimage_libc_version {
|
||||
"runtime/compat"
|
||||
} else {
|
||||
"runtime/default"
|
||||
}
|
||||
}))
|
||||
.expect("Failed to set current directory to $APPDIR");
|
||||
|
||||
// Bubblewrap does not work from inside appimage
|
||||
env::set_var("WEBKIT_FORCE_SANDBOX", "0");
|
||||
env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1");
|
||||
|
||||
// FIX-ME: This is required because appimage-builder generates a broken GstRegistry, which breaks video playback
|
||||
env::remove_var("GST_REGISTRY");
|
||||
env::remove_var("GST_REGISTRY_UPDATE");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_prefix_from_pathlist(
|
||||
|
@ -271,21 +221,6 @@ pub fn is_snap() -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
fn get_appdir() -> io::Result<PathBuf> {
|
||||
if let Some(appdir) = std::env::var_os("APPDIR").map(PathBuf::from) {
|
||||
if appdir.is_absolute() && appdir.is_dir() {
|
||||
return Ok(appdir);
|
||||
}
|
||||
}
|
||||
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "AppDir not found"))
|
||||
}
|
||||
|
||||
// Check if appimage by looking if APPDIR is set and is a valid directory
|
||||
pub fn is_appimage() -> bool {
|
||||
get_appdir().is_ok()
|
||||
}
|
||||
|
||||
// Check if flatpak by looking if FLATPAK_ID is set and not empty and that the .flatpak-info file exists
|
||||
pub fn is_flatpak() -> bool {
|
||||
if let Some(flatpak_id) = std::env::var_os("FLATPAK_ID") {
|
||||
|
|
|
@ -4,4 +4,4 @@ mod app_info;
|
|||
mod env;
|
||||
|
||||
pub use app_info::{list_apps_associated_with_ext, open_file_path, open_files_path_with};
|
||||
pub use env::{get_current_user_home, is_appimage, is_flatpak, is_snap, normalize_environment};
|
||||
pub use env::{get_current_user_home, is_flatpak, is_snap, normalize_environment};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "sd-desktop"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
description = "The universal file manager."
|
||||
authors = ["Spacedrive Technology Inc <support@spacedrive.com>"]
|
||||
default-run = "sd-desktop"
|
||||
|
@ -10,10 +10,7 @@ edition = { workspace = true }
|
|||
|
||||
[dependencies]
|
||||
# Spacedrive Sub-crates
|
||||
sd-core = { path = "../../../core", features = [
|
||||
"ffmpeg",
|
||||
"heif",
|
||||
] }
|
||||
sd-core = { path = "../../../core", features = ["ffmpeg", "heif"] }
|
||||
sd-fda = { path = "../../../crates/fda" }
|
||||
sd-prisma = { path = "../../../crates/prisma" }
|
||||
|
||||
|
@ -34,17 +31,17 @@ thiserror.workspace = true
|
|||
|
||||
opener = { version = "0.6.1", features = ["reveal"] }
|
||||
tauri = { version = "=1.5.3", features = [
|
||||
"macos-private-api",
|
||||
"path-all",
|
||||
"protocol-all",
|
||||
"os-all",
|
||||
"shell-all",
|
||||
"dialog-all",
|
||||
"linux-protocol-headers",
|
||||
"updater",
|
||||
"window-all",
|
||||
"native-tls-vendored",
|
||||
"tracing",
|
||||
"macos-private-api",
|
||||
"path-all",
|
||||
"protocol-all",
|
||||
"os-all",
|
||||
"shell-all",
|
||||
"dialog-all",
|
||||
"linux-protocol-headers",
|
||||
"updater",
|
||||
"window-all",
|
||||
"native-tls-vendored",
|
||||
"tracing",
|
||||
] }
|
||||
directories = "5.0.1"
|
||||
|
||||
|
|
|
@ -373,21 +373,6 @@ pub async fn open_ephemeral_file_with(paths_and_urls: Vec<PathAndUrl>) -> Result
|
|||
|
||||
fn inner_reveal_paths(paths: impl Iterator<Item = PathBuf>) {
|
||||
for path in paths {
|
||||
#[cfg(target_os = "linux")]
|
||||
if sd_desktop_linux::is_appimage() {
|
||||
// This is a workaround for the app, when package inside an AppImage, crashing when using opener::reveal.
|
||||
if let Err(e) = sd_desktop_linux::open_file_path(if path.is_file() {
|
||||
path.parent().unwrap_or(&path)
|
||||
} else {
|
||||
&path
|
||||
}) {
|
||||
error!("Failed to open logs dir: {e:#?}");
|
||||
}
|
||||
} else if let Err(e) = opener::reveal(path) {
|
||||
error!("Failed to open logs dir: {e:#?}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Err(e) = opener::reveal(path) {
|
||||
error!("Failed to open logs dir: {e:#?}");
|
||||
}
|
||||
|
|
|
@ -98,11 +98,8 @@ pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
|
|||
tauri::plugin::Builder::new("sd-updater")
|
||||
.on_page_load(|window, _| {
|
||||
#[cfg(target_os = "linux")]
|
||||
let updater_available = {
|
||||
let env = window.env();
|
||||
let updater_available = false;
|
||||
|
||||
env.appimage.is_some()
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let updater_available = true;
|
||||
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import Image from 'next/image';
|
||||
import { tw } from '@sd/ui';
|
||||
|
||||
const AppFrameOuter = tw.div`relative m-auto flex w-full max-w-7xl rounded-lg transition-opacity px-4`;
|
||||
const AppFrameInner = tw.div`z-30 flex w-full rounded-lg border-t border-app-line/50 backdrop-blur`;
|
||||
|
||||
const AppImage = () => {
|
||||
return (
|
||||
<div className="w-screen">
|
||||
<div className="relative mx-auto max-w-full sm:w-full sm:max-w-[1400px]">
|
||||
<div className="bloom burst bloom-one" />
|
||||
<div className="bloom burst bloom-three" />
|
||||
<div className="bloom burst bloom-two" />
|
||||
</div>
|
||||
<div className="fade-in-app-embed relative z-30 mt-8 h-[255px] w-full px-1 text-center sm:mt-16 sm:h-[428px] md:h-[428px] lg:h-[628px]">
|
||||
<AppFrameOuter>
|
||||
<AppFrameInner>
|
||||
<Image
|
||||
className="rounded-lg"
|
||||
alt="spacedrive"
|
||||
src="/images/app/1.webp"
|
||||
loading="eager"
|
||||
width={1278}
|
||||
height={626}
|
||||
quality={100}
|
||||
/>
|
||||
</AppFrameInner>
|
||||
</AppFrameOuter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppImage;
|
|
@ -1,8 +1,8 @@
|
|||
import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { Tag, useLibraryQuery } from '@sd/client';
|
||||
import { useRef } from 'react';
|
||||
import { ColorValue, Pressable, Text, View } from 'react-native';
|
||||
import { Tag, useLibraryQuery } from '@sd/client';
|
||||
import { ModalRef } from '~/components/layout/Modal';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
|
||||
|
@ -19,11 +19,9 @@ type DrawerTagItemProps = {
|
|||
const DrawerTagItem: React.FC<DrawerTagItemProps> = (props) => {
|
||||
const { tagName, tagColor, onPress } = props;
|
||||
return (
|
||||
<Pressable onPress={onPress} testID="drawer-tag">
|
||||
<Pressable style={tw`flex-1`} onPress={onPress} testID="drawer-tag">
|
||||
<View
|
||||
style={twStyle(
|
||||
'h-auto flex-row items-center gap-2 rounded-md border border-app-inputborder/50 bg-app-darkBox p-2'
|
||||
)}
|
||||
style={tw`flex-row items-center gap-2 rounded-md border border-app-inputborder/50 bg-app-darkBox p-2`}
|
||||
>
|
||||
<View style={twStyle('h-4 w-4 rounded-full', { backgroundColor: tagColor })} />
|
||||
<Text style={twStyle('text-xs font-medium text-ink')} numberOfLines={1}>
|
||||
|
@ -50,7 +48,7 @@ const DrawerTags = () => {
|
|||
>
|
||||
<View style={tw`mt-2 flex-row justify-between gap-1`}>
|
||||
<TagColumn tags={tagData} dataAmount={[0, 2]} />
|
||||
<TagColumn tags={tagData} dataAmount={[2, 4]} />
|
||||
{tagData?.length > 2 && <TagColumn tags={tagData} dataAmount={[2, 4]} />}
|
||||
</View>
|
||||
<View style={tw`mt-2 flex-row flex-wrap gap-1`}>
|
||||
{/* Add Tag */}
|
||||
|
@ -92,8 +90,10 @@ interface TagColumnProps {
|
|||
const TagColumn = ({ tags, dataAmount }: TagColumnProps) => {
|
||||
const navigation = useNavigation<DrawerNavigationHelpers>();
|
||||
return (
|
||||
<View style={tw`w-[49%] flex-col gap-1`}>
|
||||
{tags?.slice(dataAmount[0], dataAmount[1]).map((tag: any) => (
|
||||
<View style={twStyle(`gap-1`,
|
||||
tags && tags.length > 2 ? 'w-[49%] flex-col' : 'flex-1 flex-row'
|
||||
)}>
|
||||
{tags?.slice(dataAmount[0], dataAmount[1]).map((tag: Tag) => (
|
||||
<DrawerTagItem
|
||||
key={tag.id}
|
||||
tagName={tag.name!}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { SearchData, isPath, type ExplorerItem } from '@sd/client';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import { ActivityIndicator, Pressable } from 'react-native';
|
||||
import { isPath, SearchData, type ExplorerItem } from '@sd/client';
|
||||
import Layout from '~/constants/Layout';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
|
||||
import { useExplorerStore } from '~/stores/explorerStore';
|
||||
import { useActionsModalStore } from '~/stores/modalStore';
|
||||
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import ScreenContainer from '../layout/ScreenContainer';
|
||||
import FileItem from './FileItem';
|
||||
import FileRow from './FileRow';
|
||||
|
@ -21,21 +21,29 @@ type ExplorerProps = {
|
|||
loadMore: () => void;
|
||||
query: UseInfiniteQueryResult<SearchData<ExplorerItem>>;
|
||||
count?: number;
|
||||
};
|
||||
empty?: never;
|
||||
isEmpty?: never;
|
||||
}
|
||||
|
||||
const Explorer = (props: ExplorerProps) => {
|
||||
type Props = |
|
||||
ExplorerProps
|
||||
| ({
|
||||
// isEmpty and empty are mutually exclusive
|
||||
emptyComponent: React.ReactElement; // component to show when FlashList has no data
|
||||
isEmpty: boolean; // if true - show empty component
|
||||
} & Omit<ExplorerProps, 'empty' | 'isEmpty'>);
|
||||
|
||||
const Explorer = (props: Props) => {
|
||||
const navigation = useNavigation<BrowseStackScreenProps<'Location'>['navigation']>();
|
||||
|
||||
const store = useExplorerStore();
|
||||
|
||||
const { modalRef, setData } = useActionsModalStore();
|
||||
|
||||
function handlePress(data: ExplorerItem) {
|
||||
if (isPath(data) && data.item.is_dir && data.item.location_id !== null) {
|
||||
navigation.push('Location', {
|
||||
id: data.item.location_id,
|
||||
path: `${data.item.materialized_path}${data.item.name}/`
|
||||
});
|
||||
navigation.push('Location', {
|
||||
id: data.item.location_id,
|
||||
path: `${data.item.materialized_path}${data.item.name}/`
|
||||
});
|
||||
} else {
|
||||
setData(data);
|
||||
modalRef.current?.present();
|
||||
|
@ -45,38 +53,44 @@ const Explorer = (props: ExplorerProps) => {
|
|||
return (
|
||||
<ScreenContainer tabHeight={props.tabHeight} scrollview={false} style={'gap-0 py-0'}>
|
||||
<Menu />
|
||||
{/* Flashlist not supporting empty centering: https://github.com/Shopify/flash-list/discussions/517
|
||||
So needs to be done this way */}
|
||||
{/* Items */}
|
||||
<FlashList
|
||||
key={store.layoutMode}
|
||||
numColumns={store.layoutMode === 'grid' ? store.gridNumColumns : 1}
|
||||
data={props.items ?? []}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'NonIndexedPath'
|
||||
? item.item.path
|
||||
: item.type === 'SpacedropPeer'
|
||||
? item.item.name
|
||||
: item.item.id.toString()
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => handlePress(item)}>
|
||||
{store.layoutMode === 'grid' ? (
|
||||
<FileItem data={item} />
|
||||
) : (
|
||||
<FileRow data={item} />
|
||||
{props.isEmpty ? (
|
||||
props.emptyComponent
|
||||
) :
|
||||
<FlashList
|
||||
key={store.layoutMode}
|
||||
numColumns={store.layoutMode === 'grid' ? store.gridNumColumns : 1}
|
||||
data={props.items ?? []}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'NonIndexedPath'
|
||||
? item.item.path
|
||||
: item.type === 'SpacedropPeer'
|
||||
? item.item.name
|
||||
: item.item.id.toString()
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => handlePress(item)}>
|
||||
{store.layoutMode === 'grid' ? (
|
||||
<FileItem data={item} />
|
||||
) : (
|
||||
<FileRow data={item} />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
contentContainerStyle={tw`px-2 py-5`}
|
||||
extraData={store.layoutMode}
|
||||
estimatedItemSize={
|
||||
store.layoutMode === 'grid'
|
||||
? Layout.window.width / store.gridNumColumns
|
||||
: store.listItemSize
|
||||
}
|
||||
onEndReached={() => props.loadMore?.()}
|
||||
onEndReachedThreshold={0.6}
|
||||
ListFooterComponent={props.query.isFetchingNextPage ? <ActivityIndicator /> : null}
|
||||
/>
|
||||
contentContainerStyle={tw`px-2 py-5`}
|
||||
extraData={store.layoutMode}
|
||||
estimatedItemSize={
|
||||
store.layoutMode === 'grid'
|
||||
? Layout.window.width / store.gridNumColumns
|
||||
: store.listItemSize
|
||||
}
|
||||
onEndReached={() => props.loadMore?.()}
|
||||
onEndReachedThreshold={0.6}
|
||||
ListFooterComponent={props.query.isFetchingNextPage ? <ActivityIndicator /> : null}
|
||||
/>
|
||||
}
|
||||
</ScreenContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { Location, arraysEqual, byteSize, useOnlineLocations } from '@sd/client';
|
||||
import { DotsThreeOutlineVertical } from 'phosphor-react-native';
|
||||
import { useRef } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { arraysEqual, byteSize, Location, useOnlineLocations } from '@sd/client';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
|
||||
|
||||
|
@ -63,7 +63,7 @@ const ListLocation = ({ location }: ListLocationProps) => {
|
|||
</View>
|
||||
<View style={tw`flex-row items-center gap-3`}>
|
||||
<View
|
||||
style={tw`rounded-md border border-app-lightborder bg-app-highlight p-1.5`}
|
||||
style={tw`rounded-md border border-app-box bg-app p-1.5`}
|
||||
>
|
||||
<Text
|
||||
style={tw`text-left text-xs font-medium text-ink-dull`}
|
||||
|
|
56
apps/mobile/src/components/modal/search/SaveSearchModal.tsx
Normal file
56
apps/mobile/src/components/modal/search/SaveSearchModal.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { forwardRef, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Modal, ModalRef } from '~/components/layout/Modal';
|
||||
import { Button } from '~/components/primitive/Button';
|
||||
import { ModalInput } from '~/components/primitive/Input';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { useSearchStore } from '~/stores/searchStore';
|
||||
|
||||
const SaveSearchModal = forwardRef<ModalRef>((_, ref) => {
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const navigation = useNavigation();
|
||||
const searchStore = useSearchStore();
|
||||
const saveSearch = useLibraryMutation('search.saved.create', {
|
||||
onSuccess: () => {
|
||||
searchStore.applyFilters();
|
||||
navigation.navigate('SearchStack', {
|
||||
screen: 'Search'
|
||||
});
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Modal snapPoints={['22']} title="Save search" ref={ref}>
|
||||
<View style={tw`p-4`}>
|
||||
<ModalInput
|
||||
autoFocus
|
||||
value={searchName}
|
||||
onChangeText={(text) => setSearchName(text)}
|
||||
placeholder="Search Name..."
|
||||
/>
|
||||
<Button
|
||||
disabled={searchName.length === 0}
|
||||
style={tw`mt-2`}
|
||||
variant="accent"
|
||||
onPress={async () => {
|
||||
await saveSearch.mutateAsync(
|
||||
{
|
||||
name: searchName,
|
||||
filters: JSON.stringify(searchStore.mergedFilters),
|
||||
description: null,
|
||||
icon: null,
|
||||
search: null
|
||||
}
|
||||
);
|
||||
setSearchName('');
|
||||
}}
|
||||
>
|
||||
<Text style={tw`font-medium text-ink`}>Save</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default SaveSearchModal;
|
|
@ -1,8 +1,10 @@
|
|||
import { formatNumber } from '@sd/client';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { ClassInput } from 'twrnc';
|
||||
import { formatNumber } from '@sd/client';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSearchStore } from '~/stores/searchStore';
|
||||
import { Icon, IconName } from '../icons/Icon';
|
||||
|
||||
interface CategoryItemProps {
|
||||
|
@ -16,7 +18,9 @@ interface CategoryItemProps {
|
|||
style?: ClassInput;
|
||||
}
|
||||
|
||||
const CategoryItem = ({ name, icon, items, style }: CategoryItemProps) => {
|
||||
const CategoryItem = ({ name, icon, items, style, kind }: CategoryItemProps) => {
|
||||
const navigation = useNavigation();
|
||||
const searchStore = useSearchStore();
|
||||
return (
|
||||
<Pressable
|
||||
style={twStyle(
|
||||
|
@ -25,7 +29,14 @@ const CategoryItem = ({ name, icon, items, style }: CategoryItemProps) => {
|
|||
style
|
||||
)}
|
||||
onPress={() => {
|
||||
//TODO: implement
|
||||
searchStore.updateFilters('kind', {
|
||||
name,
|
||||
icon: icon + '20' as IconName,
|
||||
id: kind
|
||||
}, true);
|
||||
navigation.navigate('SearchStack', {
|
||||
screen: 'Search',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={56} />
|
||||
|
|
|
@ -20,20 +20,22 @@ import {
|
|||
KindItem,
|
||||
SearchFilters,
|
||||
TagItem,
|
||||
getSearchStore,
|
||||
useSearchStore
|
||||
} from '~/stores/searchStore';
|
||||
|
||||
const FiltersBar = () => {
|
||||
const { filters, appliedFilters } = useSearchStore();
|
||||
const searchStore = useSearchStore();
|
||||
const navigation = useNavigation<SearchStackScreenProps<'Filters'>['navigation']>();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
|
||||
|
||||
// Scroll to start when there are less than 2 filters.
|
||||
useEffect(() => {
|
||||
if (Object.entries(appliedFilters).length < 2) {
|
||||
flatListRef.current?.scrollToOffset({ animated: true, offset: 0 });
|
||||
// If there are applied filters, update the searchStore filters
|
||||
if (appliedFiltersLength > 0) {
|
||||
Object.assign(getSearchStore().filters, searchStore.appliedFilters);
|
||||
}
|
||||
}, [appliedFilters]);
|
||||
}, [appliedFiltersLength, searchStore.appliedFilters]);
|
||||
|
||||
return (
|
||||
<View
|
||||
|
@ -52,8 +54,13 @@ const FiltersBar = () => {
|
|||
ref={flatListRef}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
horizontal
|
||||
data={Object.entries(appliedFilters)}
|
||||
extraData={filters}
|
||||
onContentSizeChange={() => {
|
||||
if (flatListRef.current && appliedFiltersLength < 2) {
|
||||
flatListRef.current.scrollToOffset({ animated: true, offset: 0 });
|
||||
}
|
||||
}}
|
||||
data={Object.entries(searchStore.appliedFilters)}
|
||||
extraData={searchStore.filters}
|
||||
keyExtractor={(item) => item[0]}
|
||||
renderItem={({ item }) => (
|
||||
<FilterItem filter={item[0] as SearchFilters} value={item[1]} />
|
||||
|
@ -75,6 +82,10 @@ const FilterItem = ({ filter, value }: FilterItemProps) => {
|
|||
const boxStyle = tw`w-auto flex-row items-center gap-1.5 border border-app-cardborder bg-app-card p-2`;
|
||||
const filterCapital = filter.charAt(0).toUpperCase() + filter.slice(1);
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
// if the filter value is false or empty, return null i.e "Hidden"
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<View style={tw`flex-row gap-0.5`}>
|
||||
<View style={twStyle(boxStyle, 'rounded-bl-md rounded-tl-md')}>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AnimatePresence } from 'moti';
|
||||
import { AnimatePresence, MotiView } from 'moti';
|
||||
import { MotiPressable } from 'moti/interactions';
|
||||
import {
|
||||
CircleDashed,
|
||||
|
@ -13,7 +13,7 @@ import { Text, View } from 'react-native';
|
|||
import Card from '~/components/layout/Card';
|
||||
import SectionTitle from '~/components/layout/SectionTitle';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { getSearchStore, SearchFilters, useSearchStore } from '~/stores/searchStore';
|
||||
import { SearchFilters, getSearchStore, useSearchStore } from '~/stores/searchStore';
|
||||
|
||||
import Extension from './Extension';
|
||||
import Kind from './Kind';
|
||||
|
@ -51,12 +51,15 @@ const FiltersList = () => {
|
|||
const [selectedOptions, setSelectedOptions] = useState<SearchFilters[]>(
|
||||
Object.keys(searchStore.appliedFilters) as SearchFilters[]
|
||||
);
|
||||
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
|
||||
|
||||
|
||||
// If any filters are applied - we need to update the store
|
||||
// so the UI can reflect the applied filters
|
||||
useEffect(() => {
|
||||
Object.assign(getSearchStore().filters, getSearchStore().appliedFilters);
|
||||
}, []);
|
||||
//if there are selected filters but not applied reset them
|
||||
if (appliedFiltersLength === 0) {
|
||||
getSearchStore().resetFilters();
|
||||
}
|
||||
}, [appliedFiltersLength]);
|
||||
|
||||
const selectedHandler = useCallback(
|
||||
(option: Capitalize<SearchFilters>) => {
|
||||
|
@ -80,13 +83,16 @@ const FiltersList = () => {
|
|||
searchStore.resetFilter(searchFiltersLowercase);
|
||||
}
|
||||
},
|
||||
[selectedOptions, searchStore]
|
||||
);
|
||||
[selectedOptions, searchStore])
|
||||
|
||||
return (
|
||||
<View style={tw`gap-10`}>
|
||||
<SavedSearches />
|
||||
<View>
|
||||
<MotiView
|
||||
from={{ opacity: 0, translateY: 20 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
transition={{ type: 'timing', duration: 300 }}
|
||||
>
|
||||
<SectionTitle
|
||||
style={tw`px-6 pb-3`}
|
||||
title="What are you searching for?"
|
||||
|
@ -139,7 +145,7 @@ const FiltersList = () => {
|
|||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</MotiView>
|
||||
{/* conditionally render the selected options - this approach makes sure the animation is right
|
||||
by not relying on the index position of the object */}
|
||||
<AnimatePresence>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Location, useLibraryQuery } from '@sd/client';
|
||||
import { MotiView } from 'moti';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { LinearTransition } from 'react-native-reanimated';
|
||||
import { Location, useLibraryQuery } from '@sd/client';
|
||||
import { Icon } from '~/components/icons/Icon';
|
||||
import Card from '~/components/layout/Card';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
|
@ -77,6 +77,7 @@ const LocationFilter = memo(({ data }: Props) => {
|
|||
name: data.name as string
|
||||
});
|
||||
}, [data.id, data.name, searchStore]);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { Plus } from 'phosphor-react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Platform, Text, View } from 'react-native';
|
||||
import { ModalRef } from '~/components/layout/Modal';
|
||||
import SaveSearchModal from '~/components/modal/search/SaveSearchModal';
|
||||
import { Button } from '~/components/primitive/Button';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { SearchStackScreenProps } from '~/navigation/SearchStack';
|
||||
|
@ -10,6 +12,8 @@ import { getSearchStore, useSearchStore } from '~/stores/searchStore';
|
|||
const SaveAdd = () => {
|
||||
const searchStore = useSearchStore();
|
||||
const navigation = useNavigation<SearchStackScreenProps<'Search'>['navigation']>();
|
||||
const modalRef = useRef<ModalRef>(null);
|
||||
|
||||
const filtersApplied = Object.keys(searchStore.appliedFilters).length > 0;
|
||||
const buttonDisable = !filtersApplied && searchStore.disableActionButtons;
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
@ -38,6 +42,7 @@ const SaveAdd = () => {
|
|||
opacity: buttonDisable ? 0.5 : 1
|
||||
})}
|
||||
variant="dashed"
|
||||
onPress={() => modalRef.current?.present()}
|
||||
>
|
||||
<Plus weight="bold" size={12} color={tw.color('text-ink-dull')} />
|
||||
<Text style={tw`font-medium text-ink-dull`}>Save search</Text>
|
||||
|
@ -58,6 +63,7 @@ const SaveAdd = () => {
|
|||
{filtersApplied ? 'Update filters' : 'Add filters'}
|
||||
</Text>
|
||||
</Button>
|
||||
<SaveSearchModal ref={modalRef} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,15 +1,27 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import {
|
||||
SavedSearch as ISavedSearch,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
useRspcLibraryContext
|
||||
} from '@sd/client';
|
||||
import { MotiView } from 'moti';
|
||||
import { MotiPressable } from 'moti/interactions';
|
||||
import { FlatList, Text, View } from 'react-native';
|
||||
import { X } from 'phosphor-react-native';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { Icon } from '~/components/icons/Icon';
|
||||
import Card from '~/components/layout/Card';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
import Fade from '~/components/layout/Fade';
|
||||
import SectionTitle from '~/components/layout/SectionTitle';
|
||||
import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper';
|
||||
import DottedDivider from '~/components/primitive/DottedDivider';
|
||||
import { useSavedSearch } from '~/hooks/useSavedSearch';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { getSearchStore } from '~/stores/searchStore';
|
||||
|
||||
const SavedSearches = () => {
|
||||
const { data: savedSearches } = useLibraryQuery(['search.saved.list']);
|
||||
return (
|
||||
<Fade color="black" width={30} height="100%">
|
||||
<MotiView
|
||||
|
@ -22,10 +34,16 @@ const SavedSearches = () => {
|
|||
title="Saved searches"
|
||||
sub="Tap a saved search for searching quickly"
|
||||
/>
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
|
||||
<FlatList
|
||||
data={Array.from({ length: 6 })}
|
||||
renderItem={() => <SavedSearch />}
|
||||
data={savedSearches}
|
||||
ListEmptyComponent={() => {
|
||||
return (
|
||||
<Empty
|
||||
icon="Folder" description="No saved searches" style={tw`w-full`} />
|
||||
);
|
||||
}}
|
||||
renderItem={({ item }) => <SavedSearch search={item} />}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
numColumns={Math.ceil(6 / 2)}
|
||||
scrollEnabled={false}
|
||||
|
@ -41,16 +59,37 @@ const SavedSearches = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const SavedSearch = () => {
|
||||
interface Props {
|
||||
search: ISavedSearch;
|
||||
}
|
||||
|
||||
const SavedSearch = ({ search }: Props) => {
|
||||
const navigation = useNavigation();
|
||||
const dataForSearch = useSavedSearch(search);
|
||||
const rspc = useRspcLibraryContext();
|
||||
const deleteSearch = useLibraryMutation('search.saved.delete', {
|
||||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.saved.list'])
|
||||
});
|
||||
return (
|
||||
<MotiPressable
|
||||
from={{ opacity: 0, translateY: 20 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
transition={{ type: 'timing', duration: 300 }}
|
||||
onPress={() => {
|
||||
getSearchStore().appliedFilters = dataForSearch;
|
||||
navigation.navigate('SearchStack', {
|
||||
screen: 'Search'
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Card style={tw`mr-2 w-auto flex-row gap-2 p-2.5`}>
|
||||
<Card style={tw`mr-2 w-auto flex-row items-center gap-2 p-2.5`}>
|
||||
<Pressable
|
||||
onPress={async () => await deleteSearch.mutateAsync(search.id)}
|
||||
>
|
||||
<X size={14} color={tw.color('text-ink-dull')} />
|
||||
</Pressable>
|
||||
<Icon name="Folder" size={20} />
|
||||
<Text style={tw`text-sm font-medium text-ink`}>Saved search</Text>
|
||||
<Text style={tw`text-sm font-medium text-ink`}>{search.name}</Text>
|
||||
</Card>
|
||||
</MotiPressable>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Tag, useLibraryQuery } from '@sd/client';
|
||||
import { MotiView } from 'moti';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { LinearTransition } from 'react-native-reanimated';
|
||||
import { Tag, useLibraryQuery } from '@sd/client';
|
||||
import Card from '~/components/layout/Card';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
import Fade from '~/components/layout/Fade';
|
||||
|
@ -63,10 +63,7 @@ interface Props {
|
|||
const TagFilter = memo(({ tag }: Props) => {
|
||||
const searchStore = useSearchStore();
|
||||
const isSelected = useMemo(
|
||||
() =>
|
||||
searchStore.filters.tags.some(
|
||||
(filter) => filter.id === tag.id && filter.color === tag.color
|
||||
),
|
||||
() => searchStore.filters.tags.some((filter) => filter.id === tag.id),
|
||||
[searchStore.filters.tags, tag]
|
||||
);
|
||||
const onPress = useCallback(() => {
|
||||
|
@ -74,7 +71,8 @@ const TagFilter = memo(({ tag }: Props) => {
|
|||
id: tag.id,
|
||||
color: tag.color!
|
||||
});
|
||||
}, [searchStore, tag.id, tag.color]);
|
||||
}, [searchStore, tag]);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card
|
||||
|
|
105
apps/mobile/src/hooks/useFiltersSearch.ts
Normal file
105
apps/mobile/src/hooks/useFiltersSearch.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { SearchFilterArgs, useLibraryQuery } from '@sd/client';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Filters, SearchFilters, getSearchStore, useSearchStore } from '~/stores/searchStore';
|
||||
|
||||
/**
|
||||
* This hook merges the selected filters from Filters page in order
|
||||
* to make query calls for saved searches and setups filters for the search
|
||||
* the data structure has been designed to match the desktop app
|
||||
* @param search - search input string value
|
||||
*/
|
||||
|
||||
|
||||
export function useFiltersSearch(search: string) {
|
||||
|
||||
const [name, ext] = useMemo(() => search.split('.'), [search]);
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
const locations = useLibraryQuery(['locations.list'], {
|
||||
keepPreviousData: true,
|
||||
enabled: (name || ext) ? true : false,
|
||||
});
|
||||
|
||||
const filterFactory = (key: SearchFilters, value: Filters[keyof Filters]) => {
|
||||
|
||||
//hidden is the only boolean filter - so we can return it directly
|
||||
//Rest of the filters are arrays, so we map them to the correct format
|
||||
const filterValue = Array.isArray(value) ? value.map((v: any) => {
|
||||
return v.id ? v.id : v;
|
||||
}) : value;
|
||||
|
||||
//switch case for each filter
|
||||
//This makes it easier to add new filters in the future and setup
|
||||
//the correct object of each filter accordingly and easily
|
||||
|
||||
switch (key) {
|
||||
case 'locations':
|
||||
return { filePath: { locations: { in: filterValue } } };
|
||||
case 'name':
|
||||
return Array.isArray(filterValue) && filterValue.map((v: string) => {
|
||||
return { filePath: { [key]: { contains: v } } };
|
||||
})
|
||||
case 'hidden':
|
||||
return { filePath: { hidden: filterValue } };
|
||||
case 'extension':
|
||||
return Array.isArray(filterValue) && filterValue.map((v: string) => {
|
||||
return { filePath: { [key]: { in: [v] } } };
|
||||
})
|
||||
case 'tags':
|
||||
return { object: { tags: { in: filterValue } } };
|
||||
case 'kind':
|
||||
return { object: { kind: { in: filterValue } } };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const mergedFilters = useMemo(() => {
|
||||
|
||||
const filters = [] as SearchFilterArgs[];
|
||||
|
||||
//It's a global search if no locations have been selected
|
||||
if (searchStore.filters.locations.length === 0 || !name || !ext) {
|
||||
const locationIds = locations.data?.map((l) => l.id);
|
||||
if (locationIds) filters.push({ filePath: { locations: { in: locationIds } } });
|
||||
}
|
||||
|
||||
//handle search input
|
||||
if (name) filters.push({ filePath: { name: { contains: name } } });
|
||||
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
|
||||
|
||||
// handle selected filters
|
||||
for (const key in searchStore.filters) {
|
||||
|
||||
const filterKey = key as SearchFilters;
|
||||
//due to an issue with Valtio and Hermes Engine - need to do getSearchStore()
|
||||
//https://github.com/pmndrs/valtio/issues/765
|
||||
const filterValue = getSearchStore().filters[filterKey];
|
||||
|
||||
// no need to add empty filters
|
||||
if (Array.isArray(filterValue)) {
|
||||
const realValues = filterValue.filter((v) => v !== '');
|
||||
if (realValues.length === 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// create the filter object
|
||||
const filter = filterFactory(filterKey, filterValue);
|
||||
|
||||
// add the filter to the mergedFilters
|
||||
filters.push(filter as SearchFilterArgs);
|
||||
|
||||
}
|
||||
|
||||
// makes sure the array is not 2D
|
||||
return filters.flat();
|
||||
|
||||
}, [searchStore.filters, search]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getSearchStore().mergedFilters = mergedFilters;
|
||||
}, [searchStore.filters, search]);
|
||||
};
|
134
apps/mobile/src/hooks/useSavedSearch.ts
Normal file
134
apps/mobile/src/hooks/useSavedSearch.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { SavedSearch, SearchFilterArgs, useLibraryQuery } from '@sd/client';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { kinds } from '~/components/search/filters/Kind';
|
||||
import { Filters, SearchFilters } from '~/stores/searchStore';
|
||||
|
||||
/**
|
||||
* This hook takes in the JSON of a Saved Search
|
||||
* and returns the data of its filters for rendering in the UI
|
||||
*/
|
||||
|
||||
export function useSavedSearch(search: SavedSearch) {
|
||||
const parseFilters = JSON.parse(search.filters as string);
|
||||
|
||||
// returns an array of keys of the filters being used in the Saved Search
|
||||
//i.e locations, tags, kind, etc...
|
||||
const filterKeys: SearchFilters[] = parseFilters.reduce((acc: SearchFilters[], curr: keyof SearchFilterArgs) => {
|
||||
const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object';
|
||||
const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters;
|
||||
if (!acc.includes(key)) {
|
||||
acc.push(key as SearchFilters);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// this util function extracts the data of a filter from the Saved Search
|
||||
const extractDataFromSavedSearch = (key: SearchFilters, filterTag: 'contains' | 'in', type: 'filePath' | 'object') => {
|
||||
// Iterate through each item in the data array
|
||||
for (const item of parseFilters) {
|
||||
// Check if 'filePath' | 'object' exists and contains a the key
|
||||
if (item[type] && key in item[type]) {
|
||||
// Return the data of the filters
|
||||
return item.filePath[key][filterTag];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const locations = useLibraryQuery(['locations.list'], {
|
||||
keepPreviousData: true,
|
||||
enabled: filterKeys.includes('locations'),
|
||||
});
|
||||
const tags = useLibraryQuery(['tags.list'], {
|
||||
keepPreviousData: true,
|
||||
enabled: filterKeys.includes('tags'),
|
||||
});
|
||||
|
||||
// Filters like locations, tags, and kind require data to be rendered as a Filter
|
||||
// We prepare the data in the same format as the "filters" object in the "SearchStore"
|
||||
// it is then 'matched' with the data from the "Saved Search"
|
||||
|
||||
const prepFilters = useCallback(() => {
|
||||
const data = {} as Record<SearchFilters, any>;
|
||||
filterKeys.forEach((key: SearchFilters) => {
|
||||
switch (key) {
|
||||
case 'locations':
|
||||
data.locations = locations.data?.map((location) => {
|
||||
return {
|
||||
id: location.id,
|
||||
name: location.name
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'tags':
|
||||
data.tags = tags.data?.map((tag) => {
|
||||
return {
|
||||
id: tag.id,
|
||||
color: tag.color
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'kind':
|
||||
data.kind = kinds.map((kind) => {
|
||||
return {
|
||||
name: kind.name,
|
||||
id: kind.value,
|
||||
icon: kind.icon
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'name':
|
||||
data.name = extractDataFromSavedSearch(key, 'contains', 'filePath');
|
||||
break;
|
||||
case 'extension':
|
||||
data.extension = extractDataFromSavedSearch(key, 'contains', 'filePath');
|
||||
break;
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}, [locations, tags]);
|
||||
|
||||
const filters: Partial<Filters> = useMemo(() => {
|
||||
return parseFilters.reduce((acc: Record<SearchFilters, {}>, curr: keyof SearchFilterArgs) => {
|
||||
|
||||
const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object';
|
||||
const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters; //locations, tags, kind, etc...
|
||||
|
||||
// this function extracts the data from the result of the "filters" object in the Saved Search
|
||||
// and matches it with the values of the filters
|
||||
const extractData = (key: SearchFilters) => {
|
||||
const values: {
|
||||
contains?: string;
|
||||
in?: number[];
|
||||
} = curr[objectOrFilePath][key];
|
||||
const type = Object.keys(values)[0];
|
||||
|
||||
switch (type) {
|
||||
case 'contains':
|
||||
// some filters have a name property and some are just strings
|
||||
return prepFilters()[key].filter((item: any) => {
|
||||
return item.name ? item.name === values[type] :
|
||||
item
|
||||
});
|
||||
case 'in':
|
||||
return prepFilters()[key].filter((item: any) => values[type]?.includes(item.id));
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
};
|
||||
|
||||
// the data being setup for the filters so it can be rendered
|
||||
if (!acc[key]) {
|
||||
acc[key] = extractData(key);
|
||||
//don't include false values i.e if the "Hidden" filter is false
|
||||
if (acc[key] === false) {
|
||||
delete acc[key];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
}, [parseFilters]);
|
||||
|
||||
return filters;
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import React from 'react';
|
||||
import DynamicHeader from '~/components/header/DynamicHeader';
|
||||
import Header from '~/components/header/Header';
|
||||
import LocationScreen from '~/screens/browse/Location';
|
||||
import FiltersScreen from '~/screens/search/Filters';
|
||||
import SearchScreen from '~/screens/search/Search';
|
||||
|
||||
|
@ -25,6 +27,14 @@ export default function SearchStack() {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{/** This screen is already in BrowseStack - but added here as it offers the UX needed */}
|
||||
<Stack.Screen
|
||||
name="Location"
|
||||
component={LocationScreen}
|
||||
options={({route: optionsRoute}) => ({
|
||||
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="location" />
|
||||
})}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
@ -32,6 +42,7 @@ export default function SearchStack() {
|
|||
export type SearchStackParamList = {
|
||||
Search: undefined;
|
||||
Filters: undefined;
|
||||
Location: { id: number; path: string };
|
||||
};
|
||||
|
||||
export type SearchStackScreenProps<Screen extends keyof SearchStackParamList> =
|
||||
|
|
|
@ -146,7 +146,7 @@ export default function TabNavigator() {
|
|||
listeners={() => ({
|
||||
focus: () => {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -16,7 +16,9 @@ const Stack = createNativeStackNavigator<BrowseStackParamList>();
|
|||
|
||||
export default function BrowseStack() {
|
||||
return (
|
||||
<Stack.Navigator initialRouteName="Browse">
|
||||
<Stack.Navigator
|
||||
initialRouteName="Browse"
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Browse"
|
||||
component={BrowseScreen}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
|
||||
import { useEffect } from 'react';
|
||||
import Explorer from '~/components/explorer/Explorer';
|
||||
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
|
||||
import { getExplorerStore } from '~/stores/explorerStore';
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { Plus } from 'phosphor-react-native';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { FlatList, Pressable, View } from 'react-native';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
import { ModalRef } from '~/components/layout/Modal';
|
||||
import ScreenContainer from '~/components/layout/ScreenContainer';
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { Plus } from 'phosphor-react-native';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
import { ModalRef } from '~/components/layout/Modal';
|
||||
import ScreenContainer from '~/components/layout/ScreenContainer';
|
||||
|
@ -24,15 +24,15 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
|||
|
||||
const { search } = useSearchStore();
|
||||
const tags = useLibraryQuery(['tags.list']);
|
||||
const tagData = tags.data || [];
|
||||
const tagsData = tags.data;
|
||||
const [debouncedSearch] = useDebounce(search, 200);
|
||||
|
||||
const filteredTags = useMemo(
|
||||
() =>
|
||||
tagData?.filter((location) =>
|
||||
tagsData?.filter((location) =>
|
||||
location.name?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
) ?? [],
|
||||
[debouncedSearch, tagData]
|
||||
[debouncedSearch, tagsData]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -76,7 +76,7 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
|||
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
|
||||
contentContainerStyle={twStyle(
|
||||
`py-6`,
|
||||
tagData.length === 0 && 'h-full items-center justify-center'
|
||||
tagsData?.length === 0 && 'h-full items-center justify-center'
|
||||
)}
|
||||
/>
|
||||
<CreateTagModal ref={modalRef} />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useLibraryQuery } from '@sd/client';
|
||||
import { useMemo } from 'react';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { IconName } from '~/components/icons/Icon';
|
||||
import ScreenContainer from '~/components/layout/ScreenContainer';
|
||||
import CategoryItem from '~/components/overview/CategoryItem';
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { ArrowLeft, DotsThreeOutline, FunnelSimple, MagnifyingGlass } from 'phosphor-react-native';
|
||||
import { Suspense, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { usePathsExplorerQuery } from '@sd/client';
|
||||
import { ArrowLeft, DotsThreeOutline, FunnelSimple } from 'phosphor-react-native';
|
||||
import { Suspense, useDeferredValue, useState } from 'react';
|
||||
import { ActivityIndicator, Platform, Pressable, TextInput, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { SearchFilterArgs, useObjectsExplorerQuery } from '@sd/client';
|
||||
import Explorer from '~/components/explorer/Explorer';
|
||||
import Empty from '~/components/layout/Empty';
|
||||
import FiltersBar from '~/components/search/filters/FiltersBar';
|
||||
import { useFiltersSearch } from '~/hooks/useFiltersSearch';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { SearchStackScreenProps } from '~/navigation/SearchStack';
|
||||
import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore';
|
||||
|
@ -12,40 +15,32 @@ import { useSearchStore } from '~/stores/searchStore';
|
|||
|
||||
const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
|
||||
const headerHeight = useSafeAreaInsets().top;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const searchStore = useSearchStore();
|
||||
const explorerStore = useExplorerStore();
|
||||
const appliedFiltersLength = useMemo(
|
||||
() => Object.keys(searchStore.appliedFilters).length,
|
||||
[searchStore.appliedFilters]
|
||||
);
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
const [search, setSearch] = useState('');
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const [name, ext] = deferredSearch.split('.');
|
||||
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
||||
const filters: SearchFilterArgs[] = [];
|
||||
|
||||
if (name) filters.push({ filePath: { name: { contains: name } } });
|
||||
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
|
||||
|
||||
return filters;
|
||||
}, [deferredSearch]);
|
||||
|
||||
const objects = useObjectsExplorerQuery({
|
||||
const objects = usePathsExplorerQuery({
|
||||
arg: {
|
||||
take: 30,
|
||||
filters
|
||||
filters: searchStore.mergedFilters,
|
||||
},
|
||||
order: null,
|
||||
enabled: isFocused && searchStore.mergedFilters.length > 1, // only fetch when screen is focused & filters are applied
|
||||
suspense: true,
|
||||
enabled: !!deferredSearch,
|
||||
order: null,
|
||||
onSuccess: () => getExplorerStore().resetNewThumbnails()
|
||||
});
|
||||
|
||||
// Check if there are no objects or no search
|
||||
const noObjects = objects.items?.length === 0 || !objects.items;
|
||||
const noSearch = deferredSearch.length === 0 && appliedFiltersLength === 0;
|
||||
|
||||
useFiltersSearch(deferredSearch);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={twStyle('flex-1 bg-app-header', {
|
||||
|
@ -71,17 +66,6 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
|
|||
style={tw`h-10 w-4/5 flex-wrap rounded-md border border-app-inputborder bg-app-input`}
|
||||
>
|
||||
<View style={tw`flex h-full flex-row items-center px-3`}>
|
||||
<View style={tw`mr-3`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size={'small'} color={'white'} />
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
size={20}
|
||||
weight="bold"
|
||||
color={tw.color('ink-dull')}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={(t) => setSearch(t)}
|
||||
|
@ -119,12 +103,26 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
|
|||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
{appliedFiltersLength > 0 && <FiltersBar />}
|
||||
{appliedFiltersLength > 0 && <FiltersBar/>}
|
||||
</View>
|
||||
{/* Content */}
|
||||
<View style={tw`flex-1`}>
|
||||
<Suspense fallback={<ActivityIndicator />}>
|
||||
<Explorer {...objects} tabHeight={false} />
|
||||
<Explorer
|
||||
{...objects}
|
||||
isEmpty={noObjects}
|
||||
emptyComponent={
|
||||
<Empty
|
||||
icon={noSearch ? 'Search' : 'FolderNoSpace'}
|
||||
style={twStyle('flex-1 items-center justify-center border-0', {
|
||||
marginBottom: headerHeight
|
||||
})}
|
||||
textSize="text-md"
|
||||
iconSize={100}
|
||||
description={noSearch ? 'Add filters or type to search for files' : 'No files found'}
|
||||
/>
|
||||
}
|
||||
tabHeight={false} />
|
||||
</Suspense>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { SearchFilterArgs } from '@sd/client';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
import { IconName } from '~/components/icons/Icon';
|
||||
|
||||
|
@ -19,29 +20,20 @@ export interface KindItem {
|
|||
icon: IconName;
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
locations: FilterItem[];
|
||||
tags: TagItem[];
|
||||
name: string[];
|
||||
extension: string[];
|
||||
hidden: boolean;
|
||||
kind: KindItem[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
search: string;
|
||||
filters: {
|
||||
locations: FilterItem[];
|
||||
tags: TagItem[];
|
||||
name: string[];
|
||||
extension: string[];
|
||||
hidden: boolean;
|
||||
kind: KindItem[];
|
||||
};
|
||||
appliedFilters: Partial<
|
||||
Record<
|
||||
SearchFilters,
|
||||
{
|
||||
locations: FilterItem[];
|
||||
tags: TagItem[];
|
||||
name: string[];
|
||||
extension: string[];
|
||||
hidden: boolean;
|
||||
kind: KindItem[];
|
||||
}
|
||||
>
|
||||
>;
|
||||
filters: Filters;
|
||||
appliedFilters: Partial<Filters>;
|
||||
mergedFilters: SearchFilterArgs[],
|
||||
disableActionButtons: boolean;
|
||||
}
|
||||
|
||||
|
@ -56,6 +48,7 @@ const initialState: State = {
|
|||
kind: []
|
||||
},
|
||||
appliedFilters: {},
|
||||
mergedFilters: [],
|
||||
disableActionButtons: true
|
||||
};
|
||||
|
||||
|
@ -83,11 +76,13 @@ const searchStore = proxy<
|
|||
State & {
|
||||
updateFilters: <K extends keyof State['filters']>(
|
||||
filter: K,
|
||||
value: State['filters'][K] extends Array<infer U> ? U : State['filters'][K]
|
||||
value: State['filters'][K] extends Array<infer U> ? U : State['filters'][K],
|
||||
apply?: boolean
|
||||
) => void;
|
||||
applyFilters: () => void;
|
||||
setSearch: (search: string) => void;
|
||||
resetFilter: <K extends keyof State['filters']>(filter: K, apply?: boolean) => void;
|
||||
resetFilters: () => void;
|
||||
setInput: (index: number, value: string, key: 'name' | 'extension') => void;
|
||||
addInput: (key: 'name' | 'extension') => void;
|
||||
removeInput: (index: number, key: 'name' | 'extension') => void;
|
||||
|
@ -95,7 +90,7 @@ const searchStore = proxy<
|
|||
>({
|
||||
...initialState,
|
||||
//for updating the filters upon value selection
|
||||
updateFilters: (filter, value) => {
|
||||
updateFilters: (filter, value, apply = false) => {
|
||||
if (filter === 'hidden') {
|
||||
// Directly assign boolean values without an array operation
|
||||
searchStore.filters['hidden'] = value as boolean;
|
||||
|
@ -113,6 +108,9 @@ const searchStore = proxy<
|
|||
searchStore.filters[filter] = updatedFilter;
|
||||
}
|
||||
}
|
||||
//instead of a useEffect or subscription - we can call applyFilters directly
|
||||
// useful when you want to apply the filters from another screen
|
||||
if (apply) searchStore.applyFilters();
|
||||
},
|
||||
//for clicking add filters and applying the selection
|
||||
applyFilters: () => {
|
||||
|
@ -120,8 +118,9 @@ const searchStore = proxy<
|
|||
searchStore.appliedFilters = Object.entries(searchStore.filters).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0 && value[0] !== '') {
|
||||
acc[key as SearchFilters] = value.filter((v) => v !== ''); // Remove empty values i.e empty inputs
|
||||
const realValues = value.filter((v) => v !== '');
|
||||
if (realValues.length > 0) {
|
||||
acc[key as SearchFilters] = realValues;
|
||||
}
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Only apply the hidden filter if it's true
|
||||
|
@ -144,7 +143,9 @@ const searchStore = proxy<
|
|||
//instead of a useEffect or subscription - we can call applyFilters directly
|
||||
if (apply) searchStore.applyFilters();
|
||||
},
|
||||
|
||||
resetFilters: () => {
|
||||
searchStore.filters = { ...initialState.filters };
|
||||
},
|
||||
setInput: (index, value, key) => {
|
||||
const newValues = [...searchStore.filters[key]];
|
||||
newValues[index] = value;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "sd-core"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
description = "Virtual distributed filesystem engine that powers Spacedrive."
|
||||
authors = ["Spacedrive Technology Inc."]
|
||||
rust-version = "1.75.0"
|
||||
|
@ -31,15 +31,15 @@ sd-actors = { version = "0.1.0", path = "../crates/actors" }
|
|||
sd-ai = { path = "../crates/ai", optional = true }
|
||||
sd-cloud-api = { version = "0.1.0", path = "../crates/cloud-api" }
|
||||
sd-crypto = { path = "../crates/crypto", features = [
|
||||
"sys",
|
||||
"tokio",
|
||||
"sys",
|
||||
"tokio",
|
||||
], optional = true }
|
||||
sd-ffmpeg = { path = "../crates/ffmpeg", optional = true }
|
||||
sd-file-ext = { path = "../crates/file-ext" }
|
||||
sd-images = { path = "../crates/images", features = [
|
||||
"rspc",
|
||||
"serde",
|
||||
"specta",
|
||||
"rspc",
|
||||
"serde",
|
||||
"specta",
|
||||
] }
|
||||
sd-media-metadata = { path = "../crates/media-metadata" }
|
||||
sd-p2p = { path = "../crates/p2p", features = ["specta"] }
|
||||
|
@ -70,12 +70,12 @@ reqwest = { workspace = true, features = ["json", "native-tls-vendored","stream"
|
|||
rmp-serde = { workspace = true }
|
||||
rmpv = { workspace = true }
|
||||
rspc = { workspace = true, features = [
|
||||
"axum",
|
||||
"uuid",
|
||||
"chrono",
|
||||
"tracing",
|
||||
"alpha",
|
||||
"unstable",
|
||||
"axum",
|
||||
"uuid",
|
||||
"chrono",
|
||||
"tracing",
|
||||
"alpha",
|
||||
"unstable",
|
||||
] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
@ -86,12 +86,12 @@ strum_macros = { workspace = true }
|
|||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"sync",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"macros",
|
||||
"time",
|
||||
"process",
|
||||
"sync",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"macros",
|
||||
"time",
|
||||
"process",
|
||||
] }
|
||||
tokio-stream = { workspace = true, features = ["fs"] }
|
||||
tokio-util = { workspace = true, features = ["io"] }
|
||||
|
@ -121,7 +121,7 @@ int-enum = "0.5.0"
|
|||
libc = "0.2.153"
|
||||
mini-moka = "0.10.2"
|
||||
notify = { git = "https://github.com/notify-rs/notify.git", rev = "c3929ed114fbb0bc7457a9a498260461596b00ca", default-features = false, features = [
|
||||
"macos_fsevent",
|
||||
"macos_fsevent",
|
||||
] }
|
||||
rmp = "0.8.12"
|
||||
serde-hashkey = "0.4.5"
|
||||
|
@ -153,10 +153,10 @@ trash = "4.1.0"
|
|||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
icrate = { version = "0.1.0", features = [
|
||||
"Foundation",
|
||||
"Foundation_NSFileManager",
|
||||
"Foundation_NSString",
|
||||
"Foundation_NSNumber",
|
||||
"Foundation",
|
||||
"Foundation_NSFileManager",
|
||||
"Foundation_NSString",
|
||||
"Foundation_NSNumber",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
@ -27,7 +27,7 @@ index: 11
|
|||
|
||||
- Available at `/api/releases/desktop/[version]/[target]/[arch]`
|
||||
- Same version semantics as Desktop Update API
|
||||
- Looks for assets starting with `Spacedrive-{Target}-{Arch}` to allow for extensions like `.dmg`, `.AppImage` and `.msi`
|
||||
- Looks for assets starting with `Spacedrive-{Target}-{Arch}` to allow for extensions like `.dmg`, `.deb` and `.msi`
|
||||
- Returns a redirect as it's intended to be invoked via `<a>` elements
|
||||
|
||||
## Publishing a Release
|
||||
|
|
|
@ -108,5 +108,3 @@ C:\Program Files\Spacedrive\Spacedrive.exe
|
|||
```bash
|
||||
/opt/Spacedrive/Spacedrive
|
||||
```
|
||||
|
||||
- Alternatively you may launch the AppImage from a terminal to view the logs.
|
||||
|
|
|
@ -9,7 +9,7 @@ import { useDebugState } from '@sd/client';
|
|||
import { Button, Dialogs } from '@sd/ui';
|
||||
|
||||
import { showAlertDialog } from './components';
|
||||
import { useOperatingSystem, useTheme } from './hooks';
|
||||
import { useLocale, useOperatingSystem, useTheme } from './hooks';
|
||||
import { usePlatform } from './util/Platform';
|
||||
|
||||
const sentryBrowserLazy = import('@sentry/browser');
|
||||
|
@ -75,6 +75,8 @@ export function ErrorPage({
|
|||
localStorage.getItem(RENDERING_ERROR_LOCAL_STORAGE_KEY)
|
||||
);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
// If the user is on a page and the user presses "Reset" on the error boundary, it may crash in rendering causing the user to get stuck on the error page.
|
||||
// If it crashes again, we redirect them instead of infinitely crashing.
|
||||
useEffect(() => {
|
||||
|
@ -91,9 +93,9 @@ export function ErrorPage({
|
|||
|
||||
const resetHandler = () => {
|
||||
showAlertDialog({
|
||||
title: 'Reset',
|
||||
value: 'Are you sure you want to reset Spacedrive? Your database will be deleted.',
|
||||
label: 'Confirm',
|
||||
title: t('reset'),
|
||||
value: t('reset_confirmation'),
|
||||
label: t('confirm'),
|
||||
cancelBtn: true,
|
||||
onSubmit: () => {
|
||||
localStorage.clear();
|
||||
|
@ -116,8 +118,8 @@ export function ErrorPage({
|
|||
}
|
||||
>
|
||||
<Dialogs />
|
||||
<p className="m-3 text-sm font-bold text-ink-faint">APP CRASHED</p>
|
||||
<h1 className="text-2xl font-bold text-ink">We're past the event horizon...</h1>
|
||||
<p className="m-3 text-sm font-bold text-ink-faint">{t('app_crashed')}</p>
|
||||
<h1 className="text-2xl font-bold text-ink">{t('app_crashed_description')}</h1>
|
||||
<pre className="m-2 max-w-[650px] whitespace-normal text-center text-ink">
|
||||
{message}
|
||||
</pre>
|
||||
|
@ -125,7 +127,7 @@ export function ErrorPage({
|
|||
<div className="flex flex-row space-x-2 text-ink">
|
||||
{reloadBtn && (
|
||||
<Button variant="accent" className="mt-2" onClick={reloadBtn}>
|
||||
Reload
|
||||
{t('reload')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
@ -139,11 +141,11 @@ export function ErrorPage({
|
|||
)
|
||||
}
|
||||
>
|
||||
Send report
|
||||
{t('send_report')}
|
||||
</Button>
|
||||
{platform.openLogsDir && (
|
||||
<Button variant="gray" className="mt-2" onClick={platform.openLogsDir}>
|
||||
Open Logs
|
||||
{t('open_logs')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -152,19 +154,15 @@ export function ErrorPage({
|
|||
message.startsWith('failed to initialize library manager')) && (
|
||||
<div className="flex flex-col items-center pt-12">
|
||||
<p className="text-md max-w-[650px] text-center">
|
||||
We detected you may have created your library with an older version of
|
||||
Spacedrive. Please reset it to continue using the app!
|
||||
</p>
|
||||
<p className="mt-3 font-bold">
|
||||
{' '}
|
||||
YOU WILL LOSE ANY EXISTING SPACEDRIVE DATA!
|
||||
{t('reset_to_continue')}
|
||||
</p>
|
||||
<p className="mt-3 font-bold"> {t('reset_warning')}</p>
|
||||
<Button
|
||||
variant="colored"
|
||||
onClick={resetHandler}
|
||||
className="mt-4 max-w-xs border-transparent bg-red-500"
|
||||
>
|
||||
Reset & Quit App
|
||||
{t('reset_and_quit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -250,7 +250,7 @@ export const ParentFolderActions = new ConditionalItem({
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_rescan_location'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -35,7 +35,7 @@ export const RemoveFromRecents = new ConditionalItem({
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_remove_file_from_recents'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -79,6 +79,7 @@ const Items = ({
|
|||
|
||||
const ids = selectedFilePaths.map((obj) => obj.id);
|
||||
const paths = selectedEphemeralPaths.map((obj) => obj.path);
|
||||
const { t } = useLocale();
|
||||
|
||||
const items = useQuery<unknown>(
|
||||
['openWith', ids, paths],
|
||||
|
@ -109,7 +110,7 @@ const Items = ({
|
|||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Failed to open file, with: ${data.url}`);
|
||||
toast.error(t('failed_to_open_file_with', { data: data.url }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -117,7 +118,7 @@ const Items = ({
|
|||
</Menu.Item>
|
||||
))
|
||||
) : (
|
||||
<p className="w-full text-center text-sm text-gray-400"> No apps available </p>
|
||||
<p className="w-full text-center text-sm text-gray-400">{t('no_apps_available')}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -27,7 +27,7 @@ export const CopyAsPathBase = (
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_copy_file_path'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -50,7 +50,7 @@ const notices = {
|
|||
},
|
||||
media: {
|
||||
key: 'mediaView',
|
||||
title: 'Media View',
|
||||
title: i18n.t('media_view'),
|
||||
description: i18n.t('media_view_notice_description'),
|
||||
icon: <MediaViewIcon />
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useLibraryMutation, useZodForm } from '@sd/client';
|
||||
import { CheckBox, Dialog, Tooltip, useDialog, UseDialogProps } from '@sd/ui';
|
||||
import i18n from '~/app/I18n';
|
||||
import { Icon } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
|
@ -18,11 +19,11 @@ interface Props extends UseDialogProps {
|
|||
|
||||
function getWording(dirCount: number, fileCount: number) {
|
||||
let type = 'file';
|
||||
let prefix = 'a';
|
||||
let prefix = i18n.t('prefix_a');
|
||||
|
||||
if (dirCount == 1 && fileCount == 0) {
|
||||
type = 'directory';
|
||||
prefix = 'a';
|
||||
type = i18n.t('directory');
|
||||
prefix = i18n.t('prefix_a');
|
||||
}
|
||||
|
||||
if (dirCount > 1 && fileCount == 0) {
|
||||
|
@ -40,7 +41,9 @@ function getWording(dirCount: number, fileCount: number) {
|
|||
prefix = (fileCount + dirCount).toString();
|
||||
}
|
||||
|
||||
return { type, prefix };
|
||||
const translatedType = i18n.t(`${type}`);
|
||||
|
||||
return { type, prefix, translatedType };
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
|
@ -53,11 +56,11 @@ export default (props: Props) => {
|
|||
const form = useZodForm();
|
||||
const { dirCount = 0, fileCount = 0, indexedArgs, ephemeralArgs } = props;
|
||||
|
||||
const { type, prefix } = getWording(dirCount, fileCount);
|
||||
const { type, prefix, translatedType } = getWording(dirCount, fileCount);
|
||||
|
||||
const icon = type === 'file' || type === 'files' ? 'Document' : 'Folder';
|
||||
|
||||
const description = t('delete_warning', { type });
|
||||
const description = t('delete_warning', { type: translatedType });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -102,11 +105,12 @@ export default (props: Props) => {
|
|||
})}
|
||||
icon={<Icon theme="light" name={icon} size={28} />}
|
||||
dialog={useDialog(props)}
|
||||
title={t('delete_dialog_title', { prefix, type })}
|
||||
title={t('delete_dialog_title', { prefix, type: translatedType })}
|
||||
description={description}
|
||||
loading={deleteFile.isLoading}
|
||||
ctaLabel={t('delete_forever')}
|
||||
ctaSecondLabel={t('move_to_trash')}
|
||||
closeLabel={t('close')}
|
||||
ctaDanger
|
||||
className="w-[200px]"
|
||||
>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Object as SDObject, useLibraryMutation } from '@sd/client';
|
||||
import { Divider, TextArea } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { MetaContainer, MetaTitle } from '../Inspector';
|
||||
|
||||
|
@ -27,12 +28,13 @@ export default function Note(props: Props) {
|
|||
useEffect(() => () => flush.current?.(), []);
|
||||
|
||||
const [cachedNote, setCachedNote] = useState(props.data.note);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<MetaContainer>
|
||||
<MetaTitle>Note</MetaTitle>
|
||||
<MetaTitle>{t('note')}</MetaTitle>
|
||||
<TextArea
|
||||
className="mb-1 mt-2 !py-2 text-xs leading-snug"
|
||||
value={cachedNote ?? ''}
|
||||
|
|
|
@ -54,7 +54,7 @@ import { FileThumb } from '../FilePath/Thumb';
|
|||
import { useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { explorerStore } from '../store';
|
||||
import { useExplorerItemData } from '../useExplorerItemData';
|
||||
import { uniqueId } from '../util';
|
||||
import { translateKindName, uniqueId } from '../util';
|
||||
import { RenamableItemText } from '../View/RenamableItemText';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
import MediaData from './MediaData';
|
||||
|
@ -100,6 +100,7 @@ export const Inspector = forwardRef<HTMLDivElement, Props>(
|
|||
explorerStore.showMoreInfo = false;
|
||||
}, [pathname]);
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div ref={ref} style={{ width: INSPECTOR_WIDTH, ...style }} {...props}>
|
||||
<Sticky
|
||||
|
@ -120,7 +121,7 @@ export const Inspector = forwardRef<HTMLDivElement, Props>(
|
|||
<div className="flex select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">
|
||||
{!isNonEmpty(selectedItems) ? (
|
||||
<div className="flex h-[390px] items-center justify-center text-sm text-ink-dull">
|
||||
Nothing selected
|
||||
{t('nothing_selected')}
|
||||
</div>
|
||||
) : selectedItems.length === 1 ? (
|
||||
<SingleItemMetadata item={selectedItems[0]} />
|
||||
|
@ -342,7 +343,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
|||
onClick={() => {
|
||||
if (fullPath) {
|
||||
navigator.clipboard.writeText(fullPath);
|
||||
toast.info('Copied path to clipboard');
|
||||
toast.info(t('path_copied_to_clipboard_title'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -367,7 +368,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
|||
{mediaData.data && <MediaData data={mediaData.data} />}
|
||||
|
||||
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
|
||||
<InfoPill>{isDir ? 'Folder' : kind}</InfoPill>
|
||||
<InfoPill>{isDir ? t('folder') : translateKindName(kind)}</InfoPill>
|
||||
|
||||
{extension && <InfoPill>{extension}</InfoPill>}
|
||||
|
||||
|
@ -560,7 +561,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
|||
|
||||
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
|
||||
{[...metadata.kinds].map(([kind, items]) => (
|
||||
<InfoPill key={kind}>{`${kind} (${items.length})`}</InfoPill>
|
||||
<InfoPill key={kind}>{`${translateKindName(kind)} (${items.length})`}</InfoPill>
|
||||
))}
|
||||
|
||||
{/* {labels.data?.map((label) => {
|
||||
|
|
|
@ -69,7 +69,7 @@ export default () => {
|
|||
>
|
||||
{SortOrderSchema.options.map((o) => (
|
||||
<SelectOption key={o.value} value={o.value}>
|
||||
{o.value}
|
||||
{o.description}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
@ -38,7 +38,7 @@ export default (props: PropsWithChildren) => {
|
|||
const rescanLocation = useLibraryMutation('locations.subPathRescan');
|
||||
const createFolder = useLibraryMutation(['files.createFolder'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: t('create_folder_error'), body: `Error: ${e}.` });
|
||||
toast.error({ title: t('create_folder_error'), body: t('error_message', { error: e }) });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
|
@ -52,7 +52,7 @@ export default (props: PropsWithChildren) => {
|
|||
});
|
||||
const createFile = useLibraryMutation(['files.createFile'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: t('create_file_error'), body: `${e}.` });
|
||||
toast.error({ title: t('create_file_error'), body: t('error_message', { error: e }) });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (file) => {
|
||||
|
@ -66,7 +66,7 @@ export default (props: PropsWithChildren) => {
|
|||
});
|
||||
const createEphemeralFolder = useLibraryMutation(['ephemeralFiles.createFolder'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: t('create_folder_error'), body: `Error: ${e}.` });
|
||||
toast.error({ title: t('create_folder_error'), body: t('error_message', { error: e }) });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
|
@ -80,7 +80,7 @@ export default (props: PropsWithChildren) => {
|
|||
});
|
||||
const createEphemeralFile = useLibraryMutation(['ephemeralFiles.createFile'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: t('create_file_error'), body: `${e}.` });
|
||||
toast.error({ title: t('create_file_error'), body: t('error_message', { error: e }) });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (file) => {
|
||||
|
@ -220,7 +220,7 @@ export default (props: PropsWithChildren) => {
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_reindex_location'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -239,7 +239,7 @@ export default (props: PropsWithChildren) => {
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_generate_thumbnails'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -258,7 +258,7 @@ export default (props: PropsWithChildren) => {
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_generate_labels'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -276,7 +276,7 @@ export default (props: PropsWithChildren) => {
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t('failed_to_generate_checksum'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -233,8 +233,8 @@ export const QuickPreview = () => {
|
|||
}
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
title: 'Failed to open file',
|
||||
body: `Couldn't open file, due to an error: ${error}`
|
||||
title: t('failed_to_open_file_title'),
|
||||
body: t('failed_to_open_file_body', { error: error })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -254,7 +254,7 @@ export const QuickPreview = () => {
|
|||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay
|
||||
className={clsx(
|
||||
'absolute inset-0 z-50',
|
||||
'absolute inset-0 z-[100]',
|
||||
'radix-state-open:animate-in radix-state-open:fade-in-0',
|
||||
isDark ? 'bg-black/80' : 'bg-black/60'
|
||||
)}
|
||||
|
@ -262,7 +262,7 @@ export const QuickPreview = () => {
|
|||
/>
|
||||
|
||||
<Dialog.Content
|
||||
className="fixed inset-[5%] z-50 outline-none radix-state-open:animate-in radix-state-open:fade-in-0 radix-state-open:zoom-in-95"
|
||||
className="fixed inset-[5%] z-[100] outline-none radix-state-open:animate-in radix-state-open:fade-in-0 radix-state-open:zoom-in-95"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => isRenaming && e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
|
@ -408,8 +408,11 @@ export const QuickPreview = () => {
|
|||
setNewName(newName);
|
||||
} catch (e) {
|
||||
toast.error({
|
||||
title: `Could not rename ${itemData.fullName} to ${newName}`,
|
||||
body: `Error: ${e}.`
|
||||
title: t('failed_to_rename_file', {
|
||||
oldName: itemData.fullName,
|
||||
newName
|
||||
}),
|
||||
body: t('error_message', { error: e })
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -136,7 +136,7 @@ export const useExplorerTopBarOptions = () => {
|
|||
// ),
|
||||
// // TODO: Assign tag mode is not yet implemented!
|
||||
// // onClick: () => (explorerStore.tagAssignMode = !explorerStore.tagAssignMode),
|
||||
// onClick: () => toast.info('Coming soon!'),
|
||||
// onClick: () => toast.info(t('coming_soon)),
|
||||
// topBarActive: tagAssignMode,
|
||||
// individual: true,
|
||||
// showAtResolution: 'xl:flex'
|
||||
|
|
|
@ -24,7 +24,7 @@ import { useExplorerContext } from '../../Context';
|
|||
import { FileThumb } from '../../FilePath/Thumb';
|
||||
import { InfoPill } from '../../Inspector';
|
||||
import { CutCopyState, explorerStore, isCut } from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { translateKindName, uniqueId } from '../../util';
|
||||
import { RenamableItemText } from '../RenamableItemText';
|
||||
|
||||
export const LIST_VIEW_ICON_SIZES = {
|
||||
|
@ -84,7 +84,7 @@ const KindCell = ({ kind }: { kind: string }) => {
|
|||
className="bg-app-button/50"
|
||||
style={{ fontSize: LIST_VIEW_TEXT_SIZES[explorerSettings.listViewTextSize] }}
|
||||
>
|
||||
{kind}
|
||||
{translateKindName(kind)}
|
||||
</InfoPill>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
type ExplorerItem
|
||||
} from '@sd/client';
|
||||
import { toast } from '@sd/ui';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import { useIsDark, useLocale } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { RenameTextBox, RenameTextBoxProps } from '../FilePath/RenameTextBox';
|
||||
|
@ -76,6 +76,8 @@ export const RenamableItemText = ({
|
|||
ref.current.innerText = itemData.fullName;
|
||||
}, [itemData.fullName]);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (newName: string) => {
|
||||
try {
|
||||
|
@ -143,12 +145,15 @@ export const RenamableItemText = ({
|
|||
} catch (e) {
|
||||
reset();
|
||||
toast.error({
|
||||
title: `Could not rename ${itemData.fullName} to ${newName}`,
|
||||
body: `Error: ${e}.`
|
||||
title: t('failed_to_rename_file', {
|
||||
oldName: itemData.fullName,
|
||||
newName
|
||||
}),
|
||||
body: t('error_message', { error: e })
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemData.fullName, item, renameEphemeralFile, renameFile, renameLocation, reset]
|
||||
[itemData.fullName, item, renameEphemeralFile, renameFile, renameLocation, reset, t]
|
||||
);
|
||||
|
||||
const disabled =
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
type NonIndexedPathItem
|
||||
} from '@sd/client';
|
||||
import { ContextMenu, toast } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
|
@ -33,6 +34,8 @@ export const useViewItemDoubleClick = () => {
|
|||
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const doubleClick = useCallback(
|
||||
async (item?: ExplorerItem) => {
|
||||
const selectedItems = [...explorer.selectedItems];
|
||||
|
@ -102,7 +105,10 @@ export const useViewItemDoubleClick = () => {
|
|||
items.paths.map(({ id }) => id)
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error({ title: 'Failed to open file', body: `Error: ${error}.` });
|
||||
toast.error({
|
||||
title: t('failed_to_open_file_title'),
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
} else if (item && explorer.settingsStore.openOnDoubleClick === 'quickPreview') {
|
||||
if (item.type !== 'Location' && !(isPath(item) && item.item.is_dir)) {
|
||||
|
@ -157,7 +163,10 @@ export const useViewItemDoubleClick = () => {
|
|||
try {
|
||||
await openEphemeralFiles(items.non_indexed.map(({ path }) => path));
|
||||
} catch (error) {
|
||||
toast.error({ title: 'Failed to open file', body: `Error: ${error}.` });
|
||||
toast.error({
|
||||
title: t('failed_to_open_file_title'),
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
} else if (item && explorer.settingsStore.openOnDoubleClick === 'quickPreview') {
|
||||
if (item.type !== 'Location' && !(isPath(item) && item.item.is_dir)) {
|
||||
|
|
|
@ -151,7 +151,7 @@ export const useExplorerCopyPaste = () => {
|
|||
} catch (error) {
|
||||
toast.error({
|
||||
title: t(type === 'Copy' ? 'failed_to_copy_file' : 'failed_to_cut_file'),
|
||||
body: `Error: ${error}.`
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
type ExplorerSettings,
|
||||
type Ordering
|
||||
} from '@sd/client';
|
||||
import i18n from '~/app/I18n';
|
||||
|
||||
import {
|
||||
DEFAULT_LIST_VIEW_ICON_SIZE,
|
||||
|
@ -150,24 +151,24 @@ export function isCut(item: ExplorerItem, cutCopyState: CutCopyState) {
|
|||
}
|
||||
|
||||
export const filePathOrderingKeysSchema = z.union([
|
||||
z.literal('name').describe('Name'),
|
||||
z.literal('sizeInBytes').describe('Size'),
|
||||
z.literal('dateModified').describe('Date Modified'),
|
||||
z.literal('dateIndexed').describe('Date Indexed'),
|
||||
z.literal('dateCreated').describe('Date Created'),
|
||||
z.literal('object.dateAccessed').describe('Date Accessed'),
|
||||
z.literal('object.mediaData.epochTime').describe('Date Taken')
|
||||
z.literal('name').describe(i18n.t('name')),
|
||||
z.literal('sizeInBytes').describe(i18n.t('size')),
|
||||
z.literal('dateModified').describe(i18n.t('date_modified')),
|
||||
z.literal('dateIndexed').describe(i18n.t('date_indexed')),
|
||||
z.literal('dateCreated').describe(i18n.t('date_created')),
|
||||
z.literal('object.dateAccessed').describe(i18n.t('date_accessed')),
|
||||
z.literal('object.mediaData.epochTime').describe(i18n.t('date_taken'))
|
||||
]);
|
||||
|
||||
export const objectOrderingKeysSchema = z.union([
|
||||
z.literal('dateAccessed').describe('Date Accessed'),
|
||||
z.literal('kind').describe('Kind'),
|
||||
z.literal('mediaData.epochTime').describe('Date Taken')
|
||||
z.literal('dateAccessed').describe(i18n.t('date_accessed')),
|
||||
z.literal('kind').describe(i18n.t('kind')),
|
||||
z.literal('mediaData.epochTime').describe(i18n.t('date_taken'))
|
||||
]);
|
||||
|
||||
export const nonIndexedPathOrderingSchema = z.union([
|
||||
z.literal('name').describe('Name'),
|
||||
z.literal('sizeInBytes').describe('Size'),
|
||||
z.literal('dateCreated').describe('Date Created'),
|
||||
z.literal('dateModified').describe('Date Modified')
|
||||
z.literal('name').describe(i18n.t('name')),
|
||||
z.literal('sizeInBytes').describe(i18n.t('size')),
|
||||
z.literal('dateCreated').describe(i18n.t('date_created')),
|
||||
z.literal('dateModified').describe(i18n.t('date_modified'))
|
||||
]);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import dayjs from 'dayjs';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
import i18n from '~/app/I18n';
|
||||
import { ExplorerParamsSchema } from '~/app/route-schemas';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
|
||||
|
@ -36,6 +37,7 @@ export function getItemData(index: number, items: ExplorerItem[]) {
|
|||
}
|
||||
|
||||
const dayjsLocales: Record<string, any> = {
|
||||
ar: () => import('dayjs/locale/ar.js'),
|
||||
en: () => import('dayjs/locale/en.js'),
|
||||
de: () => import('dayjs/locale/de.js'),
|
||||
es: () => import('dayjs/locale/es.js'),
|
||||
|
@ -71,65 +73,66 @@ export function loadDayjsLocale(language: string) {
|
|||
// Generate list of localized formats available in the app
|
||||
export function generateLocaleDateFormats(language: string) {
|
||||
language = language.replace('_', '-');
|
||||
const defaultDate = '01/01/2024 23:19';
|
||||
const DATE_FORMATS = [
|
||||
{
|
||||
value: 'L',
|
||||
label: dayjs().locale(language).format('L')
|
||||
label: dayjs(defaultDate).locale(language).format('L')
|
||||
},
|
||||
{
|
||||
value: 'L LT',
|
||||
label: dayjs().locale(language).format('L LT')
|
||||
value: 'L, LT',
|
||||
label: dayjs(defaultDate).locale(language).format('L, LT')
|
||||
},
|
||||
{
|
||||
value: 'll',
|
||||
label: dayjs(defaultDate).locale(language).format('ll')
|
||||
},
|
||||
// {
|
||||
// value: 'll',
|
||||
// label: dayjs().locale(language).format('ll')
|
||||
// },
|
||||
{
|
||||
value: 'LL',
|
||||
label: dayjs().locale(language).format('LL')
|
||||
label: dayjs(defaultDate).locale(language).format('LL')
|
||||
},
|
||||
{
|
||||
value: 'lll',
|
||||
label: dayjs(defaultDate).locale(language).format('lll')
|
||||
},
|
||||
// {
|
||||
// value: 'lll',
|
||||
// label: dayjs().locale(language).format('lll')
|
||||
// },
|
||||
{
|
||||
value: 'LLL',
|
||||
label: dayjs().locale(language).format('LLL')
|
||||
label: dayjs(defaultDate).locale(language).format('LLL')
|
||||
},
|
||||
{
|
||||
value: 'llll',
|
||||
label: dayjs().locale(language).format('llll')
|
||||
label: dayjs(defaultDate).locale(language).format('llll')
|
||||
}
|
||||
];
|
||||
if (language === 'en') {
|
||||
const additionalFormats = [
|
||||
{
|
||||
value: 'DD/MM/YYYY',
|
||||
label: dayjs().locale('en').format('DD/MM/YYYY')
|
||||
label: dayjs(defaultDate).locale('en').format('DD/MM/YYYY')
|
||||
},
|
||||
{
|
||||
value: 'DD/MM/YYYY HH:mm',
|
||||
label: dayjs().locale('en').format('DD/MM/YYYY HH:mm')
|
||||
},
|
||||
// {
|
||||
// value: 'D MMM YYYY',
|
||||
// label: dayjs().locale('en').format('D MMM YYYY')
|
||||
// },
|
||||
{
|
||||
value: 'D MMMM YYYY',
|
||||
label: dayjs().locale('en').format('D MMMM YYYY')
|
||||
},
|
||||
// {
|
||||
// value: 'D MMM YYYY HH:mm',
|
||||
// label: dayjs().locale('en').format('D MMM YYYY HH:mm')
|
||||
// },
|
||||
{
|
||||
value: 'D MMMM YYYY HH:mm',
|
||||
label: dayjs().locale('en').format('D MMMM YYYY HH:mm')
|
||||
label: dayjs(defaultDate).locale('en').format('DD/MM/YYYY HH:mm')
|
||||
},
|
||||
{
|
||||
value: 'ddd, D MMM YYYY HH:mm',
|
||||
label: dayjs().locale('en').format('ddd, D MMMM YYYY HH:mm')
|
||||
value: 'D MMM, YYYY',
|
||||
label: dayjs(defaultDate).locale('en').format('D MMM, YYYY')
|
||||
},
|
||||
{
|
||||
value: 'D MMMM, YYYY',
|
||||
label: dayjs(defaultDate).locale('en').format('D MMMM, YYYY')
|
||||
},
|
||||
{
|
||||
value: 'D MMM, YYYY HH:mm',
|
||||
label: dayjs(defaultDate).locale('en').format('D MMM, YYYY HH:mm')
|
||||
},
|
||||
{
|
||||
value: 'D MMMM, YYYY HH:mm',
|
||||
label: dayjs(defaultDate).locale('en').format('D MMMM, YYYY HH:mm')
|
||||
},
|
||||
{
|
||||
value: 'ddd, D MMM, YYYY HH:mm',
|
||||
label: dayjs(defaultDate).locale('en').format('ddd, D MMMM, YYYY HH:mm')
|
||||
}
|
||||
];
|
||||
return DATE_FORMATS.concat(additionalFormats);
|
||||
|
@ -137,3 +140,48 @@ export function generateLocaleDateFormats(language: string) {
|
|||
return DATE_FORMATS;
|
||||
}
|
||||
}
|
||||
|
||||
const kinds: Record<string, string> = {
|
||||
Unknown: `${i18n.t('unknown')}`,
|
||||
Document: `${i18n.t('document')}`,
|
||||
Folder: `${i18n.t('folder')}`,
|
||||
Text: `${i18n.t('text')}`,
|
||||
Package: `${i18n.t('package')}`,
|
||||
Image: `${i18n.t('image')}`,
|
||||
Audio: `${i18n.t('audio')}`,
|
||||
Video: `${i18n.t('video')}`,
|
||||
Archive: `${i18n.t('archive')}`,
|
||||
Executable: `${i18n.t('executable')}`,
|
||||
Alias: `${i18n.t('alias')}`,
|
||||
Encrypted: `${i18n.t('encrypted')}`,
|
||||
Key: `${i18n.t('key')}`,
|
||||
Link: `${i18n.t('link')}`,
|
||||
WebPageArchive: `${i18n.t('web_page_archive')}`,
|
||||
Widget: `${i18n.t('widget')}`,
|
||||
Album: `${i18n.t('album')}`,
|
||||
Collection: `${i18n.t('collection')}`,
|
||||
Font: `${i18n.t('font')}`,
|
||||
Mesh: `${i18n.t('mesh')}`,
|
||||
Code: `${i18n.t('code')}`,
|
||||
Database: `${i18n.t('database')}`,
|
||||
Book: `${i18n.t('book')}`,
|
||||
Config: `${i18n.t('widget')}`,
|
||||
Dotfile: `${i18n.t('dotfile')}`,
|
||||
Screenshot: `${i18n.t('screenshot')}`,
|
||||
Label: `${i18n.t('label')}`
|
||||
};
|
||||
|
||||
export function translateKindName(kindName: string): string {
|
||||
if (kinds[kindName]) {
|
||||
try {
|
||||
const kind = kinds[kindName] as string;
|
||||
return kind;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${kindName} translation:`, error);
|
||||
return kindName;
|
||||
}
|
||||
} else {
|
||||
console.warn(`Translation for ${kindName} not available, falling back to passed value.`);
|
||||
return kindName;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { CheckSquare } from '@phosphor-icons/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { SetStateAction } from 'react';
|
||||
import { SetStateAction, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
auth,
|
||||
|
@ -31,7 +31,7 @@ import {
|
|||
useExplorerOperatingSystem
|
||||
} from '../../Explorer/useExplorerOperatingSystem';
|
||||
import Setting from '../../settings/Setting';
|
||||
import { useSidebarContext } from './SidebarLayout/Context';
|
||||
import { SidebarContext, useSidebarContext } from './SidebarLayout/Context';
|
||||
|
||||
export default () => {
|
||||
const buildInfo = useBridgeQuery(['buildInfo']);
|
||||
|
@ -41,14 +41,14 @@ export default () => {
|
|||
const platform = usePlatform();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sidebar = useSidebarContext();
|
||||
const sidebar = useContext(SidebarContext);
|
||||
|
||||
const popover = usePopover();
|
||||
|
||||
function handleOpenChange(action: SetStateAction<boolean>) {
|
||||
const open = typeof action === 'boolean' ? action : !popover.open;
|
||||
popover.setOpen(open);
|
||||
sidebar.onLockedChange(open);
|
||||
sidebar?.onLockedChange(open);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -110,7 +110,7 @@ export function FeedbackPopover() {
|
|||
</div>
|
||||
{emojiError && (
|
||||
<p className="pt-1 text-xs text-red-500">
|
||||
Please select an emoji
|
||||
{t('please_select_emoji')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -28,7 +28,11 @@ export default () => {
|
|||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{libraries.isLoading ? 'Loading...' : library ? library.config.name : ' '}
|
||||
{libraries.isLoading
|
||||
? `${t('loading')}...`
|
||||
: library
|
||||
? library.config.name
|
||||
: ' '}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Children, PropsWithChildren, useState } from 'react';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
export const SEE_MORE_COUNT = 5;
|
||||
|
||||
|
@ -11,6 +12,7 @@ export function SeeMore({ children, limit = SEE_MORE_COUNT }: Props) {
|
|||
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
{childrenArray.map((child, index) => (seeMore || index < limit ? child : null))}
|
||||
|
@ -19,7 +21,7 @@ export function SeeMore({ children, limit = SEE_MORE_COUNT }: Props) {
|
|||
onClick={() => setSeeMore(!seeMore)}
|
||||
className="mb-1 ml-2 mt-0.5 cursor-pointer text-center text-tiny font-semibold text-ink-faint/50 transition hover:text-accent"
|
||||
>
|
||||
See {seeMore ? 'less' : 'more'}
|
||||
{seeMore ? `${t('see_less')}` : `${t('see_more')}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -9,6 +9,8 @@ import { openDirectoryPickerDialog } from '~/app/$libraryId/settings/library/loc
|
|||
import { useLocale } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { useSidebarContext } from '../../SidebarLayout/Context';
|
||||
|
||||
export const ContextMenu = ({
|
||||
children,
|
||||
locationId
|
||||
|
@ -17,10 +19,16 @@ export const ContextMenu = ({
|
|||
const platform = usePlatform();
|
||||
const libraryId = useLibraryContext().library.uuid;
|
||||
|
||||
const sidebar = useSidebarContext();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<CM.Root trigger={children}>
|
||||
<CM.Root
|
||||
trigger={children}
|
||||
onOpenChange={(open) => sidebar.onLockedChange(open)}
|
||||
className="z-[100]"
|
||||
>
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
@ -35,7 +43,7 @@ export const ContextMenu = ({
|
|||
));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
toast.error(t('error_message', { error }));
|
||||
}
|
||||
}}
|
||||
icon={Plus}
|
||||
|
|
|
@ -7,13 +7,21 @@ import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
|||
import DeleteDialog from '~/app/$libraryId/settings/library/tags/DeleteDialog';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { useSidebarContext } from '../../SidebarLayout/Context';
|
||||
|
||||
export const ContextMenu = ({ children, tagId }: PropsWithChildren<{ tagId: number }>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sidebar = useSidebarContext();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<CM.Root trigger={children}>
|
||||
<CM.Root
|
||||
trigger={children}
|
||||
onOpenChange={(open) => sidebar.onLockedChange(open)}
|
||||
className="z-[100]"
|
||||
>
|
||||
<CM.Item
|
||||
icon={Plus}
|
||||
label={t('new_tag')}
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function ToolsSection() {
|
|||
className={`max-w relative flex w-full grow flex-row items-center gap-0.5 truncate rounded border border-transparent ${os === 'macOS' ? 'bg-opacity-90' : ''} px-2 py-1 text-sm font-medium text-sidebar-inkDull outline-none ring-0 ring-inset ring-transparent ring-offset-0 focus:ring-1 focus:ring-accent focus:ring-offset-0`}
|
||||
onClick={() => {
|
||||
platform.openTrashInOsExplorer?.();
|
||||
toast.info('Opening Trash');
|
||||
toast.info(t('opening_trash'));
|
||||
}}
|
||||
>
|
||||
<Trash size={18} className="mr-1" />
|
||||
|
|
|
@ -159,6 +159,8 @@ function Node({
|
|||
onDrop: (files) => onDropped(id, files)
|
||||
});
|
||||
|
||||
const {t} = useLocale()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
@ -170,7 +172,7 @@ function Node({
|
|||
)}
|
||||
onClick={() => {
|
||||
if (!platform.openFilePickerDialog) {
|
||||
toast.warning('File picker not supported on this platform');
|
||||
toast.warning(t('file_picker_not_supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { P2PEvent, useBridgeMutation, useSpacedropProgress } from '@sd/client';
|
||||
import { Input, ProgressBar, toast, ToastId } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
const placeholder = '/Users/oscar/Desktop/demo.txt';
|
||||
|
@ -9,17 +10,16 @@ export function useIncomingSpacedropToast() {
|
|||
const platform = usePlatform();
|
||||
const acceptSpacedrop = useBridgeMutation('p2p.acceptSpacedrop');
|
||||
const filePathInput = useRef<HTMLInputElement>(null);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropRequest' }>) =>
|
||||
toast.info(
|
||||
{
|
||||
title: 'Incoming Spacedrop',
|
||||
title: t('incoming_spacedrop'),
|
||||
// TODO: Make this pretty
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
File '{data.files[0]}' from '{data.peer_name}'
|
||||
</p>
|
||||
<p>{t('file_from', { file: data.files[0], name: data.peer_name })}</p>
|
||||
{/* TODO: This will be removed in the future for now it's just a hack */}
|
||||
{platform.saveFilePickerDialog ? null : (
|
||||
<Input
|
||||
|
@ -40,14 +40,14 @@ export function useIncomingSpacedropToast() {
|
|||
event !== 'on-action' && acceptSpacedrop.mutate([data.id, null]);
|
||||
},
|
||||
action: {
|
||||
label: 'Accept',
|
||||
label: t('accept'),
|
||||
async onClick() {
|
||||
let destinationFilePath = filePathInput.current?.value ?? placeholder;
|
||||
|
||||
if (data.files.length != 1) {
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
const result = await platform.openDirectoryPickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
title: t('save_spacedrop'),
|
||||
multiple: false
|
||||
});
|
||||
if (!result) {
|
||||
|
@ -58,7 +58,7 @@ export function useIncomingSpacedropToast() {
|
|||
} else {
|
||||
if (platform.saveFilePickerDialog) {
|
||||
const result = await platform.saveFilePickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
title: t('save_spacedrop'),
|
||||
defaultPath: data.files?.[0]
|
||||
});
|
||||
if (!result) {
|
||||
|
@ -72,7 +72,7 @@ export function useIncomingSpacedropToast() {
|
|||
await acceptSpacedrop.mutateAsync([data.id, destinationFilePath]);
|
||||
}
|
||||
},
|
||||
cancel: 'Reject'
|
||||
cancel: t('reject')
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -95,6 +95,7 @@ export function SpacedropProgress({ toastId, dropId }: { toastId: ToastId; dropI
|
|||
|
||||
export function useSpacedropProgressToast() {
|
||||
const cancelSpacedrop = useBridgeMutation(['p2p.cancelSpacedrop']);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropProgress' }>) => {
|
||||
toast.info(
|
||||
|
@ -106,7 +107,7 @@ export function useSpacedropProgressToast() {
|
|||
id: data.id,
|
||||
duration: Infinity,
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
label: t('cancel'),
|
||||
onClick() {
|
||||
cancelSpacedrop.mutate(data.id);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ export function Component() {
|
|||
orderingKeys: objectOrderingKeysSchema
|
||||
});
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'objects' });
|
||||
|
||||
const defaultFilter = { object: { favorite: true } };
|
||||
|
||||
|
@ -52,7 +52,7 @@ export function Component() {
|
|||
<ExplorerContextProvider explorer={explorer}>
|
||||
<SearchContextProvider search={search}>
|
||||
<TopBarPortal
|
||||
center={<SearchBar defaultFilters={[defaultFilter]} />}
|
||||
center={<SearchBar defaultFilters={[defaultFilter]} defaultTarget="objects" />}
|
||||
left={
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{t('favorites')}</span>
|
||||
|
|
|
@ -30,7 +30,7 @@ export function Component() {
|
|||
orderingKeys: objectOrderingKeysSchema
|
||||
});
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'objects' });
|
||||
|
||||
const explorer = useExplorer({
|
||||
items: labels.data || null,
|
||||
|
|
|
@ -71,7 +71,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) =
|
|||
[location.id]
|
||||
);
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'paths' });
|
||||
|
||||
const searchFiltersAreDefault = useMemo(
|
||||
() => JSON.stringify(defaultFilters) !== JSON.stringify(search.filters),
|
||||
|
|
|
@ -4,6 +4,9 @@ import { useRef } from 'react';
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { formatNumber, SearchFilterArgs, useLibraryQuery } from '@sd/client';
|
||||
import { Icon } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { translateKindName } from '../Explorer/util';
|
||||
|
||||
export default () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
@ -38,7 +41,7 @@ export default () => {
|
|||
>
|
||||
<KindItem
|
||||
kind={kind}
|
||||
name={name}
|
||||
name={translateKindName(name)}
|
||||
icon={icon}
|
||||
items={count}
|
||||
onClick={() => {}}
|
||||
|
@ -61,6 +64,7 @@ interface KindItemProps {
|
|||
}
|
||||
|
||||
const KindItem = ({ kind, name, icon, items, selected, onClick, disabled }: KindItemProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
|
@ -85,7 +89,8 @@ const KindItem = ({ kind, name, icon, items, selected, onClick, disabled }: Kind
|
|||
<h2 className="text-sm font-medium">{name}</h2>
|
||||
{items !== undefined && (
|
||||
<p className="text-xs text-ink-faint">
|
||||
{formatNumber(items)} Item{(items > 1 || items === 0) && 's'}
|
||||
{formatNumber(items)}{' '}
|
||||
{items > 1 || items === 0 ? `${t('items')}` : `${t('item')}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { byteSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { useCounter } from '~/hooks';
|
||||
import { useCounter, useLocale } from '~/hooks';
|
||||
|
||||
interface StatItemProps {
|
||||
title: string;
|
||||
|
@ -12,25 +12,6 @@ interface StatItemProps {
|
|||
info?: string;
|
||||
}
|
||||
|
||||
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity: 'Total capacity',
|
||||
preview_media_bytes: 'Preview media',
|
||||
library_db_size: 'Index size',
|
||||
total_bytes_free: 'Free space',
|
||||
total_bytes_used: 'Total used space'
|
||||
};
|
||||
|
||||
const StatDescriptions: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity:
|
||||
'The total capacity of all nodes connected to the library. May show incorrect values during alpha.',
|
||||
preview_media_bytes: 'The total size of all preview media files, such as thumbnails.',
|
||||
library_db_size: 'The size of the library database.',
|
||||
total_bytes_free: 'Free space available on all nodes connected to the library.',
|
||||
total_bytes_used: 'Total space used on all nodes connected to the library.'
|
||||
};
|
||||
|
||||
const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames;
|
||||
|
||||
let mounted = false;
|
||||
|
||||
const StatItem = (props: StatItemProps) => {
|
||||
|
@ -92,6 +73,27 @@ const LibraryStats = () => {
|
|||
if (!stats.isLoading) mounted = true;
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity: t('total_bytes_capacity'),
|
||||
preview_media_bytes: t('preview_media_bytes'),
|
||||
library_db_size: t('library_db_size'),
|
||||
total_bytes_free: t('total_bytes_free'),
|
||||
total_bytes_used: t('total_bytes_used')
|
||||
};
|
||||
|
||||
const StatDescriptions: Partial<Record<keyof Statistics, string>> = {
|
||||
total_bytes_capacity: t('total_bytes_capacity_description'),
|
||||
preview_media_bytes: t('preview_media_bytes_description'),
|
||||
library_db_size: t('library_db_size_description'),
|
||||
total_bytes_free: t('total_bytes_free_description'),
|
||||
total_bytes_used: t('total_bytes_used_description')
|
||||
};
|
||||
|
||||
const displayableStatItems = Object.keys(
|
||||
StatItemNames
|
||||
) as unknown as keyof typeof StatItemNames;
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
<div className="flex gap-3 overflow-hidden">
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// import { X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { Icon, IconName } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
type NewCardProps =
|
||||
| {
|
||||
|
@ -30,6 +31,7 @@ export default function NewCard({
|
|||
button,
|
||||
className
|
||||
}: NewCardProps) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
@ -61,7 +63,7 @@ export default function NewCard({
|
|||
disabled={!buttonText}
|
||||
className="text-sm font-medium text-ink-dull"
|
||||
>
|
||||
{buttonText ? buttonText : 'Coming Soon'}
|
||||
{buttonText ? buttonText : t('coming_soon')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import { byteSize } from '@sd/client';
|
||||
import { Button, Card, CircularProgress, tw } from '@sd/ui';
|
||||
import { Icon } from '~/components';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import { useIsDark, useLocale } from '~/hooks';
|
||||
|
||||
type StatCardProps = {
|
||||
name: string;
|
||||
|
@ -40,6 +40,8 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => {
|
|||
return Math.floor((usedSpaceSpace.value / totalSpace.value) * 100);
|
||||
}, [mounted, totalSpace, usedSpaceSpace]);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
|
||||
<div className="flex flex-row items-center gap-5 p-4 px-6">
|
||||
|
@ -69,13 +71,13 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => {
|
|||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="mt-1 truncate text-tiny text-ink-faint">
|
||||
{freeSpace.value}
|
||||
{freeSpace.unit} free of {totalSpace.value}
|
||||
{freeSpace.unit} {t('free_of')} {totalSpace.value}
|
||||
{totalSpace.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
|
||||
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
|
||||
<Pill className="uppercase">{connectionType || t('local')}</Pill>
|
||||
<div className="grow" />
|
||||
{/* <Button size="icon" variant="outline">
|
||||
<Ellipsis className="w-3 h-3 opacity-50" />
|
||||
|
|
|
@ -24,7 +24,7 @@ export const Component = () => {
|
|||
|
||||
const { data: node } = useBridgeQuery(['nodeState']);
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'paths' });
|
||||
|
||||
const stats = useLibraryQuery(['library.statistics']);
|
||||
|
||||
|
@ -77,7 +77,7 @@ export const Component = () => {
|
|||
<OverviewSection>
|
||||
<FileKindStatistics />
|
||||
</OverviewSection>
|
||||
<OverviewSection count={1} title="Devices">
|
||||
<OverviewSection count={1} title={t('devices')}>
|
||||
{node && (
|
||||
<StatisticItem
|
||||
name={node.name}
|
||||
|
@ -130,9 +130,9 @@ export const Component = () => {
|
|||
/> */}
|
||||
<NewCard
|
||||
icons={['Laptop', 'Server', 'SilverBox', 'Tablet']}
|
||||
text="Spacedrive works best on all your devices."
|
||||
text={t('connect_device_description')}
|
||||
className="h-auto"
|
||||
// buttonText="Connect a device"
|
||||
// buttonText={t('connect_device')}
|
||||
/>
|
||||
{/**/}
|
||||
</OverviewSection>
|
||||
|
@ -152,13 +152,13 @@ export const Component = () => {
|
|||
{!locations?.length && (
|
||||
<NewCard
|
||||
icons={['HDD', 'Folder', 'Globe', 'SD']}
|
||||
text="Connect a local path, volume or network location to Spacedrive."
|
||||
text={t('add_location_overview_description')}
|
||||
button={() => <AddLocationButton variant="outline" />}
|
||||
/>
|
||||
)}
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection count={0} title="Cloud Drives">
|
||||
<OverviewSection count={0} title={t('cloud_drives')}>
|
||||
{/* <StatisticItem
|
||||
name="James Pine"
|
||||
icon="DriveDropbox"
|
||||
|
@ -184,8 +184,8 @@ export const Component = () => {
|
|||
'DriveOneDrive'
|
||||
// 'DriveBox'
|
||||
]}
|
||||
text="Connect your cloud accounts to Spacedrive."
|
||||
// buttonText="Connect a cloud"
|
||||
text={t('connect_cloud_description')}
|
||||
// buttonText={t('connect_cloud)}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ export function Component() {
|
|||
orderingKeys: objectOrderingKeysSchema
|
||||
});
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'paths' });
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -52,7 +52,7 @@ export function Component() {
|
|||
<ExplorerContextProvider explorer={explorer}>
|
||||
<SearchContextProvider search={search}>
|
||||
<TopBarPortal
|
||||
center={<SearchBar defaultFilters={[defaultFilters]} />}
|
||||
center={<SearchBar defaultFilters={[defaultFilters]} defaultTarget="paths" />}
|
||||
left={
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{t('recents')}</span>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { MagnifyingGlass, X } from '@phosphor-icons/react';
|
|||
import { forwardRef } from 'react';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { useSearchContext } from '.';
|
||||
import HorizontalScroll from '../overview/Layout/HorizontalScroll';
|
||||
|
@ -75,6 +76,7 @@ export const AppliedFilters = () => {
|
|||
|
||||
export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: () => void }) {
|
||||
const searchStore = useSearchStore();
|
||||
const { t } = useLocale();
|
||||
|
||||
const filter = filterRegistry.find((f) => f.extract(arg));
|
||||
if (!filter) return;
|
||||
|
@ -84,53 +86,64 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?:
|
|||
searchStore.filterOptions
|
||||
);
|
||||
|
||||
function isFilterDescriptionDisplayed() {
|
||||
if (filter?.translationKey === 'hidden' || filter?.translationKey === 'favorite') {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterContainer>
|
||||
<StaticSection>
|
||||
<RenderIcon className="size-4" icon={filter.icon} />
|
||||
<FilterText>{filter.name}</FilterText>
|
||||
</StaticSection>
|
||||
<InteractiveSection className="border-l">
|
||||
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
|
||||
{isFilterDescriptionDisplayed() && (
|
||||
<>
|
||||
<InteractiveSection className="border-l">
|
||||
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
|
||||
<div key={value}>{displayName}</div>
|
||||
))} */}
|
||||
{(filter.conditions as any)[filter.getCondition(filter.extract(arg) as any) as any]}
|
||||
</InteractiveSection>
|
||||
{
|
||||
(filter.conditions as any)[
|
||||
filter.getCondition(filter.extract(arg) as any) as any
|
||||
]
|
||||
}
|
||||
</InteractiveSection>
|
||||
|
||||
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
|
||||
{activeOptions && (
|
||||
<>
|
||||
{activeOptions.length === 1 ? (
|
||||
<RenderIcon className="size-4" icon={activeOptions[0]!.icon} />
|
||||
) : (
|
||||
<div className="relative flex gap-0.5 self-center">
|
||||
{activeOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
zIndex: activeOptions.length - index
|
||||
}}
|
||||
>
|
||||
<RenderIcon className="size-4" icon={option.icon} />
|
||||
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
|
||||
{activeOptions && (
|
||||
<>
|
||||
{activeOptions.length === 1 ? (
|
||||
<RenderIcon className="size-4" icon={activeOptions[0]!.icon} />
|
||||
) : (
|
||||
<div className="relative flex gap-0.5 self-center">
|
||||
{activeOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
zIndex: activeOptions.length - index
|
||||
}}
|
||||
>
|
||||
<RenderIcon className="size-4" icon={option.icon} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeOptions.length > 1
|
||||
? `${activeOptions.length} ${t(`${filter.translationKey}`, { count: activeOptions.length })}`
|
||||
: activeOptions[0]?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeOptions.length > 1
|
||||
? `${activeOptions.length} ${pluralize(filter.name)}`
|
||||
: activeOptions[0]?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</InteractiveSection>
|
||||
</InteractiveSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDelete && <CloseTab onClick={onDelete} />}
|
||||
</FilterContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralize(word?: string) {
|
||||
if (word?.endsWith('s')) return word;
|
||||
return `${word}s`;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
CircleDashed,
|
||||
Cube,
|
||||
Folder,
|
||||
Heart,
|
||||
Icon,
|
||||
SelectionSlash,
|
||||
Tag,
|
||||
|
@ -10,9 +11,12 @@ import {
|
|||
import { useState } from 'react';
|
||||
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
|
||||
import { Button, Input } from '@sd/ui';
|
||||
import i18n from '~/app/I18n';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import { SearchOptionItem, SearchOptionSubMenu } from '.';
|
||||
import { translateKindName } from '../Explorer/util';
|
||||
import { AllKeys, FilterOption, getKey } from './store';
|
||||
import { UseSearch } from './useSearch';
|
||||
import { FilterTypeCondition, filterTypeCondition } from './util';
|
||||
|
@ -23,6 +27,7 @@ export interface SearchFilter<
|
|||
name: string;
|
||||
icon: Icon;
|
||||
conditions: TConditions;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
export interface SearchFilterCRUD<
|
||||
|
@ -148,6 +153,8 @@ const FilterOptionText = ({
|
|||
value
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<SearchOptionSubMenu className="!p-1.5" name={filter.name} icon={filter.icon}>
|
||||
<form
|
||||
|
@ -172,7 +179,7 @@ const FilterOptionText = ({
|
|||
className="w-full"
|
||||
type="submit"
|
||||
>
|
||||
Apply
|
||||
{t('apply')}
|
||||
</Button>
|
||||
</form>
|
||||
</SearchOptionSubMenu>
|
||||
|
@ -407,7 +414,8 @@ function createBooleanFilter(
|
|||
|
||||
export const filterRegistry = [
|
||||
createInOrNotInFilter({
|
||||
name: 'Location',
|
||||
name: i18n.t('location'),
|
||||
translationKey: 'location',
|
||||
icon: Folder, // Phosphor folder icon
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
|
||||
|
@ -442,7 +450,8 @@ export const filterRegistry = [
|
|||
)
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Tags',
|
||||
name: i18n.t('tags'),
|
||||
translationKey: 'tag',
|
||||
icon: CircleDashed,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
|
||||
|
@ -478,7 +487,7 @@ export const filterRegistry = [
|
|||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<SDIcon name="Tags" size={32} />
|
||||
<p className="w-4/5 text-center text-xs text-ink-dull">
|
||||
You have not created any tags
|
||||
{i18n.t('no_tags')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -490,7 +499,8 @@ export const filterRegistry = [
|
|||
}
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Kind',
|
||||
name: i18n.t('kind'),
|
||||
translationKey: 'kind',
|
||||
icon: Cube,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
|
||||
|
@ -514,9 +524,9 @@ export const filterRegistry = [
|
|||
Object.keys(ObjectKind)
|
||||
.filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined)
|
||||
.map((key) => {
|
||||
const kind = ObjectKind[Number(key)];
|
||||
const kind = ObjectKind[Number(key)] as string;
|
||||
return {
|
||||
name: kind as string,
|
||||
name: translateKindName(kind),
|
||||
value: Number(key),
|
||||
icon: kind + '20'
|
||||
};
|
||||
|
@ -526,7 +536,8 @@ export const filterRegistry = [
|
|||
)
|
||||
}),
|
||||
createTextMatchFilter({
|
||||
name: 'Name',
|
||||
name: i18n.t('name'),
|
||||
translationKey: 'name',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
|
||||
|
@ -536,7 +547,8 @@ export const filterRegistry = [
|
|||
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
}),
|
||||
createInOrNotInFilter({
|
||||
name: 'Extension',
|
||||
name: i18n.t('extension'),
|
||||
translationKey: 'extension',
|
||||
icon: Textbox,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
|
||||
|
@ -553,7 +565,8 @@ export const filterRegistry = [
|
|||
Render: ({ filter, search }) => <FilterOptionText filter={filter} search={search} />
|
||||
}),
|
||||
createBooleanFilter({
|
||||
name: 'Hidden',
|
||||
name: i18n.t('hidden'),
|
||||
translationKey: 'hidden',
|
||||
icon: SelectionSlash,
|
||||
extract: (arg) => {
|
||||
if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
|
||||
|
@ -569,9 +582,28 @@ export const filterRegistry = [
|
|||
];
|
||||
},
|
||||
Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
}),
|
||||
createBooleanFilter({
|
||||
name: i18n.t('favorite'),
|
||||
translationKey: 'favorite',
|
||||
icon: Heart,
|
||||
extract: (arg) => {
|
||||
if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite;
|
||||
},
|
||||
create: (favorite) => ({ object: { favorite } }),
|
||||
useOptions: () => {
|
||||
return [
|
||||
{
|
||||
name: 'Favorite',
|
||||
value: true,
|
||||
icon: 'Heart' // Spacedrive folder icon
|
||||
}
|
||||
];
|
||||
},
|
||||
Render: ({ filter, search }) => <FilterOptionBoolean filter={filter} search={search} />
|
||||
})
|
||||
// createInOrNotInFilter({
|
||||
// name: 'Label',
|
||||
// name: i18n.t('label'),
|
||||
// icon: Tag,
|
||||
// extract: (arg) => {
|
||||
// if ('object' in arg && 'labels' in arg.object) return arg.object.labels;
|
||||
|
@ -606,7 +638,7 @@ export const filterRegistry = [
|
|||
// idk how to handle this rn since include_descendants is part of 'path' now
|
||||
//
|
||||
// createFilter({
|
||||
// name: 'WithDescendants',
|
||||
// name: i18n.t('with_descendants'),
|
||||
// icon: SelectionSlash,
|
||||
// conditions: filterTypeCondition.trueOrFalse,
|
||||
// setCondition: (args, condition: 'true' | 'false') => {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
import { Input, ModifierKeys, Shortcut } from '@sd/ui';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useLocale, useOperatingSystem } from '~/hooks';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
import { useSearchContext } from './context';
|
||||
|
@ -22,6 +22,7 @@ export default ({ redirectToSearch, defaultFilters, defaultTarget }: Props) => {
|
|||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const searchStore = useSearchStore();
|
||||
const locationState: { focusSearch?: boolean } = useLocation().state;
|
||||
|
||||
const os = useOperatingSystem(true);
|
||||
const keybind = keybindForOs(os);
|
||||
|
@ -69,12 +70,19 @@ export default ({ redirectToSearch, defaultFilters, defaultTarget }: Props) => {
|
|||
const updateDebounce = useDebouncedCallback((value: string) => {
|
||||
search.setSearch?.(value);
|
||||
if (redirectToSearch) {
|
||||
navigate({
|
||||
pathname: '../search',
|
||||
search: createSearchParams({
|
||||
search: value
|
||||
}).toString()
|
||||
});
|
||||
navigate(
|
||||
{
|
||||
pathname: '../search',
|
||||
search: createSearchParams({
|
||||
search: value
|
||||
}).toString()
|
||||
},
|
||||
{
|
||||
state: {
|
||||
focusSearch: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
|
@ -89,16 +97,19 @@ export default ({ redirectToSearch, defaultFilters, defaultTarget }: Props) => {
|
|||
search.setTarget?.(undefined);
|
||||
}
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={searchRef}
|
||||
placeholder="Search"
|
||||
placeholder={t('search')}
|
||||
className="mx-2 w-48 transition-all duration-200 focus-within:w-60"
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
updateValue(e.target.value);
|
||||
}}
|
||||
autoFocus={locationState?.focusSearch || false}
|
||||
onBlur={() => {
|
||||
if (search.rawSearch === '' && !searchStore.interactingWithSearchOptions) {
|
||||
clearValue();
|
||||
|
|
|
@ -13,9 +13,9 @@ import {
|
|||
tw,
|
||||
usePopover
|
||||
} from '@sd/ui';
|
||||
import { useIsDark, useKeybind } from '~/hooks';
|
||||
import { useIsDark, useKeybind, useLocale } from '~/hooks';
|
||||
|
||||
import { AppliedFilters, FilterContainer, InteractiveSection } from './AppliedFilters';
|
||||
import { AppliedFilters, InteractiveSection } from './AppliedFilters';
|
||||
import { useSearchContext } from './context';
|
||||
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters';
|
||||
import {
|
||||
|
@ -96,6 +96,8 @@ export const SearchOptions = ({
|
|||
|
||||
const showSearchTargets = useFeatureFlag('searchTargetSwitcher');
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
|
@ -118,7 +120,7 @@ export const SearchOptions = ({
|
|||
search.target === 'paths' ? 'bg-app-box' : 'hover:bg-app-box/50'
|
||||
)}
|
||||
>
|
||||
Paths
|
||||
{t('paths')}
|
||||
</InteractiveSection>
|
||||
<InteractiveSection
|
||||
onClick={() => search.setTarget?.('objects')}
|
||||
|
@ -126,7 +128,7 @@ export const SearchOptions = ({
|
|||
search.target === 'objects' ? 'bg-app-box' : 'hover:bg-app-box/50'
|
||||
)}
|
||||
>
|
||||
Objects
|
||||
{t('objects')}
|
||||
</InteractiveSection>
|
||||
</OptionContainer>
|
||||
)}
|
||||
|
@ -221,6 +223,8 @@ function AddFilterButton() {
|
|||
[searchQuery]
|
||||
);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
{registerFilters}
|
||||
|
@ -234,7 +238,7 @@ function AddFilterButton() {
|
|||
trigger={
|
||||
<Button className="flex flex-row gap-1" size="xs" variant="dotted">
|
||||
<FunnelSimple />
|
||||
Add Filter
|
||||
{t('add_filter')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
@ -245,7 +249,7 @@ function AddFilterButton() {
|
|||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
variant="transparent"
|
||||
placeholder="Filter..."
|
||||
placeholder={`${t('filter')}...`}
|
||||
/>
|
||||
<Separator />
|
||||
{searchQuery === '' ? (
|
||||
|
@ -274,6 +278,8 @@ function SaveSearchButton() {
|
|||
|
||||
const saveSearch = useLibraryMutation('search.saved.create');
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
popover={popover}
|
||||
|
@ -281,7 +287,7 @@ function SaveSearchButton() {
|
|||
trigger={
|
||||
<Button className="flex shrink-0 flex-row" size="xs" variant="dotted">
|
||||
<Plus weight="bold" className="mr-1" />
|
||||
Save Search
|
||||
{t('save_search')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
@ -310,7 +316,7 @@ function SaveSearchButton() {
|
|||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
variant="default"
|
||||
placeholder="Name"
|
||||
placeholder={t('name')}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
<Button
|
||||
|
@ -319,7 +325,7 @@ function SaveSearchButton() {
|
|||
className="ml-2"
|
||||
variant="accent"
|
||||
>
|
||||
Save
|
||||
{t('save')}
|
||||
</Button>
|
||||
</form>
|
||||
</Popover>
|
||||
|
|
|
@ -31,7 +31,7 @@ export function Component() {
|
|||
|
||||
const { t } = useLocale();
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'paths' });
|
||||
|
||||
const items = useSearchExplorerQuery({
|
||||
search,
|
||||
|
|
|
@ -68,7 +68,8 @@ export const useRegisterSearchFilterOptions = (
|
|||
|
||||
export function argsToOptions(args: SearchFilterArgs[], options: Map<string, FilterOption[]>) {
|
||||
return args.flatMap((fixedArg) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(fixedArg))!;
|
||||
const filter = filterRegistry.find((f) => f.extract(fixedArg));
|
||||
if (!filter) return [];
|
||||
|
||||
return filter
|
||||
.argsToOptions(filter.extract(fixedArg) as any, options)
|
||||
|
|
|
@ -22,7 +22,7 @@ export interface UseSearchProps<TSource extends UseSearchSource> {
|
|||
source: TSource;
|
||||
}
|
||||
|
||||
export function useSearchParamsSource() {
|
||||
export function useSearchParamsSource(props: { defaultTarget: SearchTarget }) {
|
||||
const [searchParams, setSearchParams] = useRawSearchParams();
|
||||
|
||||
const filtersSearchParam = searchParams.get('filters');
|
||||
|
@ -60,7 +60,7 @@ export function useSearchParamsSource() {
|
|||
);
|
||||
}
|
||||
|
||||
const target = (searchParams.get('target') ?? 'paths') as SearchTarget;
|
||||
const target = (searchParams.get('target') as SearchTarget | null) ?? props.defaultTarget;
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
@ -201,9 +201,9 @@ export function useSearch<TSource extends UseSearchSource>(props: UseSearchProps
|
|||
};
|
||||
}
|
||||
|
||||
export function useSearchFromSearchParams() {
|
||||
export function useSearchFromSearchParams(props: { defaultTarget: SearchTarget }) {
|
||||
return useSearch({
|
||||
source: useSearchParamsSource()
|
||||
source: useSearchParamsSource(props)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,26 +1,27 @@
|
|||
import { CircleDashed, Folder, Icon, Tag } from '@phosphor-icons/react';
|
||||
import { IconTypes } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import i18n from '~/app/I18n';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
|
||||
export const filterTypeCondition = {
|
||||
inOrNotIn: {
|
||||
in: 'is',
|
||||
notIn: 'is not'
|
||||
in: i18n.t('is'),
|
||||
notIn: i18n.t('is_not')
|
||||
},
|
||||
textMatch: {
|
||||
contains: 'contains',
|
||||
startsWith: 'starts with',
|
||||
endsWith: 'ends with',
|
||||
equals: 'is'
|
||||
contains: i18n.t('contains'),
|
||||
startsWith: i18n.t('starts_with'),
|
||||
endsWith: i18n.t('ends_with'),
|
||||
equals: i18n.t('equals')
|
||||
},
|
||||
optionalRange: {
|
||||
from: 'from',
|
||||
to: 'to'
|
||||
from: i18n.t('from'),
|
||||
to: i18n.t('to')
|
||||
},
|
||||
trueOrFalse: {
|
||||
true: 'is',
|
||||
false: 'is not'
|
||||
true: i18n.t('is'),
|
||||
false: i18n.t('is_not')
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
Books,
|
||||
ChartBar,
|
||||
Cloud,
|
||||
Database,
|
||||
FlyingSaucer,
|
||||
|
@ -15,6 +14,7 @@ import {
|
|||
TagSimple,
|
||||
User
|
||||
} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useFeatureFlag } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
import { useLocale, useOperatingSystem } from '~/hooks';
|
||||
|
@ -22,14 +22,16 @@ import { usePlatform } from '~/util/Platform';
|
|||
|
||||
import Icon from '../Layout/Sidebar/SidebarLayout/Icon';
|
||||
import SidebarLink from '../Layout/Sidebar/SidebarLayout/Link';
|
||||
import { useLayoutStore } from '../Layout/store';
|
||||
import { NavigationButtons } from '../TopBar/NavigationButtons';
|
||||
|
||||
const Heading = tw.div`mb-1 ml-1 text-xs font-semibold text-gray-400`;
|
||||
const Section = tw.div`space-y-0.5`;
|
||||
|
||||
export default () => {
|
||||
const { platform } = usePlatform();
|
||||
const os = useOperatingSystem();
|
||||
const { platform } = usePlatform();
|
||||
const { sidebar } = useLayoutStore();
|
||||
|
||||
// const isPairingEnabled = useFeatureFlag('p2pPairing');
|
||||
const isBackupsEnabled = useFeatureFlag('backups');
|
||||
|
@ -41,7 +43,10 @@ export default () => {
|
|||
{platform === 'tauri' ? (
|
||||
<div
|
||||
data-tauri-drag-region={os === 'macOS'}
|
||||
className="mb-3 h-3 w-full p-3 pl-[14px] pt-[11px]"
|
||||
className={clsx(
|
||||
'mb-3 flex h-3 w-full p-3 pl-[14px] pt-[11px]',
|
||||
sidebar.collapsed && os === 'macOS' && 'justify-end'
|
||||
)}
|
||||
>
|
||||
<NavigationButtons />
|
||||
</div>
|
||||
|
|
|
@ -29,7 +29,7 @@ const themes: Theme[] = [
|
|||
outsideColor: 'bg-[#F0F0F0]',
|
||||
textColor: 'text-black',
|
||||
border: 'border border-[#E6E6E6]',
|
||||
themeName: 'Light',
|
||||
themeName: i18n.t('light'),
|
||||
themeValue: 'vanilla'
|
||||
},
|
||||
{
|
||||
|
@ -37,7 +37,7 @@ const themes: Theme[] = [
|
|||
outsideColor: 'bg-black',
|
||||
textColor: 'text-white',
|
||||
border: 'border border-[#323342]',
|
||||
themeName: 'Dark',
|
||||
themeName: i18n.t('dark'),
|
||||
themeValue: 'dark'
|
||||
},
|
||||
{
|
||||
|
@ -45,13 +45,14 @@ const themes: Theme[] = [
|
|||
outsideColor: '',
|
||||
textColor: 'text-white',
|
||||
border: 'border border-[#323342]',
|
||||
themeName: 'System',
|
||||
themeName: i18n.t('system'),
|
||||
themeValue: 'system'
|
||||
}
|
||||
];
|
||||
|
||||
// Unsorted list of languages available in the app.
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'ar', label: 'عربي' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
|
@ -210,7 +211,11 @@ export const Component = () => {
|
|||
</div>
|
||||
</Setting>
|
||||
{/* Date Formatting Settings */}
|
||||
<Setting mini title={t('date_format')} description={t('date_format_description')}>
|
||||
<Setting
|
||||
mini
|
||||
title={t('date_time_format')}
|
||||
description={t('date_time_format_description')}
|
||||
>
|
||||
<div className="flex h-[30px] gap-2">
|
||||
<Select
|
||||
value={dateFormat}
|
||||
|
|
|
@ -59,7 +59,7 @@ export const Component = () => {
|
|||
{dayjs(backup.timestamp).toString()}
|
||||
</h1>
|
||||
<p className="mt-0.5 select-text truncate text-sm text-ink-dull">
|
||||
For library '{backup.library_name}'
|
||||
{t('for_library', { name: backup.library_name })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex grow" />
|
||||
|
|
|
@ -200,7 +200,10 @@ export const AddLocationDialog = ({
|
|||
throw error;
|
||||
}
|
||||
|
||||
toast.error({ title: 'Failed to add location', body: `Error: ${error}.` });
|
||||
toast.error({
|
||||
title: t('failed_to_add_location'),
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -217,6 +220,7 @@ export const AddLocationDialog = ({
|
|||
dialog={useDialog(dialogProps)}
|
||||
icon={<Icon name="NewLocation" size={28} />}
|
||||
onSubmit={onSubmit}
|
||||
closeLabel={t('close')}
|
||||
ctaLabel={t('add')}
|
||||
formClassName="min-w-[375px]"
|
||||
errorMessageException={t('location_is_already_linked')}
|
||||
|
|
|
@ -28,6 +28,7 @@ export default (props: Props) => {
|
|||
onSubmit={form.handleSubmit(() => deleteLocation.mutateAsync(props.locationId))}
|
||||
dialog={useDialog(props)}
|
||||
title={t('delete_location')}
|
||||
closeLabel={t('close')}
|
||||
icon={<Icon name="DeleteLocation" size={28} />}
|
||||
description={t('delete_location_description')}
|
||||
ctaDanger
|
||||
|
|
|
@ -47,9 +47,10 @@ const RulesForm = ({ onSubmitted }: Props) => {
|
|||
const REMOTE_ERROR_FORM_FIELD = 'root.serverError';
|
||||
const createIndexerRules = useLibraryMutation(['locations.indexer_rules.create']);
|
||||
const formId = useId();
|
||||
const { t } = useLocale();
|
||||
const modeOptions: { value: RuleKind; label: string }[] = [
|
||||
{ value: 'RejectFilesByGlob', label: 'Reject files' },
|
||||
{ value: 'AcceptFilesByGlob', label: 'Accept files' }
|
||||
{ value: 'RejectFilesByGlob', label: t('reject_files') },
|
||||
{ value: 'AcceptFilesByGlob', label: t('accept_files') }
|
||||
];
|
||||
const form = useZodForm({
|
||||
schema,
|
||||
|
@ -130,8 +131,6 @@ const RulesForm = ({ onSubmitted }: Props) => {
|
|||
if (form.formState.isSubmitSuccessful) onSubmitted?.();
|
||||
}, [form.formState.isSubmitSuccessful, onSubmitted]);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
// The portal is required for Form because this component can be nested inside another form element
|
||||
<>
|
||||
|
@ -150,7 +149,7 @@ const RulesForm = ({ onSubmitted }: Props) => {
|
|||
{...form.register('name')}
|
||||
/>
|
||||
{errors.name && <p className="mt-2 text-sm text-red-500">{errors.name?.message}</p>}
|
||||
<h3 className="mb-[15px] mt-[20px] w-full text-sm font-semibold">Rules</h3>
|
||||
<h3 className="mb-[15px] mt-[20px] w-full text-sm font-semibold">{t('rules')}</h3>
|
||||
<div
|
||||
className={
|
||||
'grid space-y-1 rounded-md border border-app-line/60 bg-app-input p-2'
|
||||
|
|
|
@ -6,6 +6,7 @@ import { IndexerRule, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
|||
import { Button, Divider, Label, toast } from '@sd/ui';
|
||||
import { InfoText } from '@sd/ui/src/forms';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
import RuleButton from './RuleButton';
|
||||
import RulesForm from './RulesForm';
|
||||
|
@ -39,20 +40,25 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
const [toggleNewRule, setToggleNewRule] = useState(false);
|
||||
const deleteIndexerRule = useLibraryMutation(['locations.indexer_rules.delete']);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const deleteRule: MouseEventHandler<HTMLButtonElement> = () => {
|
||||
if (!selectedRule) return;
|
||||
|
||||
showAlertDialog({
|
||||
title: 'Delete',
|
||||
value: 'Are you sure you want to delete this rule?',
|
||||
label: 'Confirm',
|
||||
title: t('delete'),
|
||||
value: t('delete_rule_confirmation'),
|
||||
label: t('confirm'),
|
||||
cancelBtn: true,
|
||||
onSubmit: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteIndexerRule.mutateAsync(selectedRule.id);
|
||||
} catch (error) {
|
||||
toast.error({ title: 'Failed to delete rule', body: `Error: ${error}.` });
|
||||
toast.error({
|
||||
title: t('failed_to_delete_rule'),
|
||||
body: t('error_message', { error })
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setSelectedRule(undefined);
|
||||
|
@ -68,7 +74,7 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
<div className={props.className} onClick={() => setSelectedRule(undefined)}>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<div className="mb-1 grow">
|
||||
<Label>{props.label || 'Indexer rules'}</Label>
|
||||
<Label>{props.label || t('indexer_rules')}</Label>
|
||||
{infoText && <InfoText className="mb-4">{infoText}</InfoText>}
|
||||
</div>
|
||||
{editable && (
|
||||
|
@ -84,7 +90,7 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
)}
|
||||
>
|
||||
<Trash className="-mt-0.5 mr-1.5 inline size-4" />
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
@ -92,7 +98,7 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
onClick={() => setToggleNewRule(!toggleNewRule)}
|
||||
className={clsx('px-5', toggleNewRule && 'opacity-50')}
|
||||
>
|
||||
New
|
||||
{t('new')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
@ -127,8 +133,8 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
|
|||
) : (
|
||||
<p className={clsx(listIndexerRules.isError && 'text-red-500')}>
|
||||
{listIndexerRules.isError
|
||||
? 'Error while retriving indexer rules'
|
||||
: 'No indexer rules available'}
|
||||
? `${t('indexer_rules_error')}`
|
||||
: `${t('indexer_rules_not_available')}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { InputField, InputFieldProps, toast } from '@sd/ui';
|
|||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { openDirectoryPickerDialog } from './openDirectoryPickerDialog';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
export const LocationPathInputField = forwardRef<
|
||||
HTMLInputElement,
|
||||
|
@ -12,6 +13,7 @@ export const LocationPathInputField = forwardRef<
|
|||
>((props, ref) => {
|
||||
const platform = usePlatform();
|
||||
const form = useFormContext();
|
||||
const {t} = useLocale()
|
||||
|
||||
return (
|
||||
<InputField
|
||||
|
@ -26,8 +28,8 @@ export const LocationPathInputField = forwardRef<
|
|||
shouldDirty: true
|
||||
})
|
||||
)
|
||||
.catch((error) => toast.error(String(error)))
|
||||
}
|
||||
.catch((error) => toast.error(t('error_message', { error: String(error) }))
|
||||
)}
|
||||
readOnly={platform.platform !== 'web'}
|
||||
className={clsx('mb-3', platform.platform === 'web' || 'cursor-pointer')}
|
||||
/>
|
||||
|
|
|
@ -33,7 +33,7 @@ export const Component = () => {
|
|||
rightArea={
|
||||
<div className="flex flex-row items-center space-x-5">
|
||||
<SearchInput
|
||||
placeholder="Search locations"
|
||||
placeholder={t('search_locations')}
|
||||
className="h-[33px]"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
|
|
@ -27,6 +27,8 @@ export const Component = () => {
|
|||
return savedSearches.data!.find((s) => s.id == selectedSearchId) ?? null;
|
||||
}, [selectedSearchId, savedSearches.data]);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading title="Saved Searches" description="Manage your saved searches." />
|
||||
|
@ -52,7 +54,9 @@ export const Component = () => {
|
|||
onDelete={() => setSelectedSearchId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-gray-400">No Search Selected</div>
|
||||
<div className="text-sm font-medium text-gray-400">
|
||||
{t('no_search_selected')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -85,6 +85,7 @@ export default (
|
|||
title={t('create_new_tag')}
|
||||
description={t('create_new_tag_description')}
|
||||
ctaLabel={t('create')}
|
||||
closeLabel={t('close')}
|
||||
>
|
||||
<div className="relative mt-3 ">
|
||||
<InputField
|
||||
|
|
|
@ -27,6 +27,7 @@ export default (props: Props) => {
|
|||
dialog={useDialog(props)}
|
||||
onSubmit={form.handleSubmit(() => deleteTag.mutateAsync(props.tagId))}
|
||||
title={t('delete_tag')}
|
||||
closeLabel={t('close')}
|
||||
description={t('delete_tag_description')}
|
||||
ctaDanger
|
||||
ctaLabel={t('delete')}
|
||||
|
|
|
@ -55,6 +55,8 @@ export default (props: UseDialogProps) => {
|
|||
dialog={useDialog(props)}
|
||||
submitDisabled={!form.formState.isValid}
|
||||
title={t('create_new_library')}
|
||||
closeLabel={t('close')}
|
||||
cancelLabel={t('cancel')}
|
||||
description={t('create_new_library_description')}
|
||||
ctaLabel={form.formState.isSubmitting ? t('creating_library') : t('create_library')}
|
||||
>
|
||||
|
|
|
@ -47,6 +47,7 @@ export default function DeleteLibraryDialog(props: Props) {
|
|||
onSubmit={onSubmit}
|
||||
dialog={useDialog(props)}
|
||||
title={t('delete_library')}
|
||||
closeLabel={t('close')}
|
||||
description={t('delete_library_description')}
|
||||
ctaDanger
|
||||
ctaLabel={t('delete')}
|
||||
|
|
|
@ -20,7 +20,7 @@ import { TopBarPortal } from '../TopBar/Portal';
|
|||
export function Component() {
|
||||
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
const result = useLibraryQuery(['tags.get', tagId], { suspense: true });
|
||||
const tag = result.data;
|
||||
const tag = result.data!;
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -28,9 +28,9 @@ export function Component() {
|
|||
|
||||
const { explorerSettings, preferences } = useTagExplorerSettings(tag!);
|
||||
|
||||
const search = useSearchFromSearchParams();
|
||||
const search = useSearchFromSearchParams({ defaultTarget: 'objects' });
|
||||
|
||||
const defaultFilters = useMemo(() => [{ object: { tags: { in: [tag!.id] } } }], [tag!.id]);
|
||||
const defaultFilters = useMemo(() => [{ object: { tags: { in: [tag.id] } } }], [tag.id]);
|
||||
|
||||
const items = useSearchExplorerQuery({
|
||||
search,
|
||||
|
@ -45,7 +45,7 @@ export function Component() {
|
|||
isFetchingNextPage: items.query.isFetchingNextPage,
|
||||
isLoadingPreferences: preferences.isLoading,
|
||||
settings: explorerSettings,
|
||||
parent: { type: 'Tag', tag: tag! }
|
||||
parent: { type: 'Tag', tag: tag }
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useBridgeQuery } from '@sd/client';
|
||||
import { toast } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
|
||||
export function useP2PErrorToast() {
|
||||
const listeners = useBridgeQuery(['p2p.listeners']);
|
||||
const didShowError = useRef(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (!listeners.data) return;
|
||||
|
@ -14,24 +16,21 @@ export function useP2PErrorToast() {
|
|||
if (listeners.data.ipv4.type === 'Error' && listeners.data.ipv6.type === 'Error') {
|
||||
body = (
|
||||
<div>
|
||||
<p>
|
||||
Error creating the IPv4 and IPv6 listeners. Please check your firewall
|
||||
settings!
|
||||
</p>
|
||||
<p>{t('ipv4_ipv6_listeners_error')}</p>
|
||||
<p>{listeners.data.ipv4.error}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (listeners.data.ipv4.type === 'Error') {
|
||||
body = (
|
||||
<div>
|
||||
<p>Error creating the IPv4 listeners. Please check your firewall settings!</p>
|
||||
<p>{t('ipv4_listeners_error')}</p>
|
||||
<p>{listeners.data.ipv4.error}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (listeners.data.ipv6.type === 'Error') {
|
||||
body = (
|
||||
<div>
|
||||
<p>Error creating the IPv6 listeners. Please check your firewall settings!</p>
|
||||
<p>{t('ipv6_listeners_error')}</p>
|
||||
<p>{listeners.data.ipv6.error}</p>
|
||||
</div>
|
||||
);
|
||||
|
@ -40,7 +39,7 @@ export function useP2PErrorToast() {
|
|||
if (body) {
|
||||
toast.error(
|
||||
{
|
||||
title: 'Error starting up networking!',
|
||||
title: t('networking_error'),
|
||||
body
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { z } from '@sd/ui/src/forms';
|
||||
|
||||
export const SortOrderSchema = z.union([z.literal('Asc'), z.literal('Desc')]);
|
||||
import i18n from './I18n';
|
||||
|
||||
export const SortOrderSchema = z.union([
|
||||
z.literal('Asc').describe(i18n.t('ascending')),
|
||||
z.literal('Desc').describe(i18n.t('descending'))
|
||||
]);
|
||||
export type SortOrder = z.infer<typeof SortOrderSchema>;
|
||||
|
||||
export const NodeIdParamsSchema = z.object({ id: z.string() });
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@sd/ui';
|
||||
import { useLocale } from '~/hooks';
|
||||
import {
|
||||
dismissibleNoticeStore,
|
||||
getDismissibleNoticeStore,
|
||||
|
@ -28,6 +29,7 @@ export default ({
|
|||
...props
|
||||
}: Props) => {
|
||||
const dismissibleNoticeStore = useDismissibleNoticeStore();
|
||||
const { t } = useLocale();
|
||||
|
||||
if (dismissibleNoticeStore[storageKey]) return null;
|
||||
|
||||
|
@ -54,7 +56,7 @@ export default ({
|
|||
className="border-white/10 font-medium hover:border-white/20"
|
||||
onClick={onLearnMore}
|
||||
>
|
||||
Learn More
|
||||
{t('learn_more')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
@ -65,7 +67,7 @@ export default ({
|
|||
onDismiss?.();
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
{t('got_it')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue