Merge branch 'main' into eng-1748-spacedrop-refactor-spacedrop-cloud

This commit is contained in:
Utku Bakir 2024-05-06 19:49:20 -04:00
commit f0e81b9e33
122 changed files with 4268 additions and 1767 deletions

View file

@ -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}*`);

View file

@ -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:

View file

@ -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
View file

@ -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",

View file

@ -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

View file

@ -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
}
}

View file

@ -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") {

View file

@ -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};

View file

@ -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"

View file

@ -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:#?}");
}

View file

@ -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;

View file

@ -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;

View file

@ -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!}

View file

@ -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>
);
};

View file

@ -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`}

View 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;

View file

@ -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} />

View file

@ -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')}>

View file

@ -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>

View file

@ -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

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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

View 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]);
};

View 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;
}

View file

@ -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> =

View file

@ -146,7 +146,7 @@ export default function TabNavigator() {
listeners={() => ({
focus: () => {
setActiveIndex(index);
}
},
})}
/>
))}

View file

@ -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}

View file

@ -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';

View file

@ -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';

View file

@ -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} />

View file

@ -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';

View file

@ -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>

View file

@ -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;

View file

@ -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]

View file

@ -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

View file

@ -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.

View file

@ -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>
)}

View file

@ -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 })
});
}
}}

View file

@ -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 })
});
}
}}

View file

@ -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>
)}
</>
);

View file

@ -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 })
});
}
}}

View file

@ -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 />
}

View file

@ -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]"
>

View file

@ -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 ?? ''}

View file

@ -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) => {

View file

@ -69,7 +69,7 @@ export default () => {
>
{SortOrderSchema.options.map((o) => (
<SelectOption key={o.value} value={o.value}>
{o.value}
{o.description}
</SelectOption>
))}
</Select>

View file

@ -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 })
});
}
}}

View file

@ -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 })
});
}
}}

View file

@ -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'

View file

@ -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>
);
};

View file

@ -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 =

View file

@ -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)) {

View file

@ -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 })
});
}
}

View file

@ -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'))
]);

View file

@ -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;
}
}

View file

@ -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 (

View file

@ -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>

View file

@ -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>
}

View file

@ -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>
)}
</>

View file

@ -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}

View file

@ -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')}

View file

@ -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" />

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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>

View file

@ -30,7 +30,7 @@ export function Component() {
orderingKeys: objectOrderingKeysSchema
});
const search = useSearchFromSearchParams();
const search = useSearchFromSearchParams({ defaultTarget: 'objects' });
const explorer = useExplorer({
items: labels.data || null,

View file

@ -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),

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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`;
}

View file

@ -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') => {

View file

@ -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();

View file

@ -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>

View file

@ -31,7 +31,7 @@ export function Component() {
const { t } = useLocale();
const search = useSearchFromSearchParams();
const search = useSearchFromSearchParams({ defaultTarget: 'paths' });
const items = useSearchExplorerQuery({
search,

View file

@ -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)

View file

@ -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)
});
}

View file

@ -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;

View file

@ -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>

View file

@ -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}

View file

@ -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" />

View file

@ -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')}

View file

@ -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

View file

@ -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'

View file

@ -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>

View file

@ -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')}
/>

View file

@ -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)}
/>

View file

@ -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>
</>

View file

@ -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

View file

@ -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')}

View file

@ -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')}
>

View file

@ -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')}

View file

@ -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 (

View file

@ -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
},
{

View file

@ -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() });

View file

@ -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