diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41329b26f..689c7202f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ concurrency: jobs: typescript: - name: TypeScript + name: Type and style check runs-on: ubuntu-22.04 - timeout-minutes: 3 + timeout-minutes: 7 permissions: {} steps: - name: Checkout repository @@ -36,6 +36,15 @@ jobs: - name: Perform typechecks run: pnpm typecheck + - name: Perform style check + run: |- + set -eux + pnpm autoformat only-frontend + if [ -n "$(git diff --name-only --cached)" ]; then + echo "Some files are not correctly formatted. Please run 'pnpm autoformat' and commit the changes." >&2 + exit 1 + fi + eslint: name: ESLint runs-on: ubuntu-22.04 @@ -56,7 +65,7 @@ jobs: cypress: name: Cypress runs-on: macos-14 - timeout-minutes: 30 + timeout-minutes: 45 permissions: {} steps: - name: Checkout repository @@ -174,11 +183,20 @@ jobs: run: cargo fmt --all -- --check clippy: - name: Clippy (${{ matrix.platform }}) - runs-on: ${{ matrix.platform }} strategy: + fail-fast: true matrix: - platform: [ubuntu-22.04, macos-14, windows-latest] + settings: + - host: macos-13 + target: x86_64-apple-darwin + - host: macos-14 + target: aarch64-apple-darwin + - host: windows-latest + target: x86_64-pc-windows-msvc + - host: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + name: Clippy (${{ matrix.settings.host }}) + runs-on: ${{ matrix.settings.host }} permissions: contents: read timeout-minutes: 45 @@ -223,6 +241,7 @@ jobs: - 'extensions/*/**' - 'Cargo.toml' - 'Cargo.lock' + - '.github/workflows/ci.yml' - name: Setup System and Rust if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' @@ -232,22 +251,15 @@ jobs: - name: Run Clippy if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' - uses: actions-rs-plus/clippy-check@v2 + uses: giraffate/clippy-action@v1 with: - args: --workspace --all-features --locked + reporter: github-pr-review + tool_name: 'Clippy (${{ matrix.settings.host }})' + filter_mode: diff_context + github_token: ${{ secrets.GITHUB_TOKEN }} + clippy_flags: --workspace --all-features --locked + fail_on_error: true - # test: - # name: Test (${{ matrix.platform }}) - # runs-on: ${{ matrix.platform }} - # strategy: - # matrix: - # platform: [ubuntu-22.04, macos-latest, windows-latest] - # steps: - # - name: Checkout repository - # uses: actions/checkout@v4 - # - # - name: Setup - # uses: ./.github/actions/setup - # - # - name: Test - # run: cargo test --workspace --all-features + # - name: Run tests + # if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + # run: cargo test --workspace --all-features --locked --target ${{ matrix.settings.target }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efa04a378..5f18dec67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,12 +91,14 @@ If you encounter any issues, ensure that you are using the following versions of - Rust version: **1.78** - Node version: **18.18** -- Pnpm version: **9.0.6** +- Pnpm version: **9.1.1** After cleaning out your build artifacts using `pnpm clean`, `git clean`, or `cargo clean`, it is necessary to re-run the `setup-system` script. Make sure to read the [guidelines](https://spacedrive.com/docs/developers/prerequisites/guidelines) to ensure that your code follows a similar style to ours. +After you finish making your changes and committed them to your branch, make sure to execute `pnpm autoformat` to fix any style inconsistency in your code. + ##### Mobile App To run the mobile app: diff --git a/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx b/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx index 15e05ff1d..62c08d11a 100644 --- a/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx +++ b/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx @@ -1,7 +1,7 @@ import { useDrawerStatus } from '@react-navigation/drawer'; import { useNavigation } from '@react-navigation/native'; import { MotiView } from 'moti'; -import { CaretRight, Gear, Lock, Plus } from 'phosphor-react-native'; +import { CaretRight, CloudArrowDown, Gear, Lock, Plus } from 'phosphor-react-native'; import { useEffect, useRef, useState } from 'react'; import { Alert, Pressable, Text, View } from 'react-native'; import { useClientContext } from '@sd/client'; @@ -12,6 +12,7 @@ import { AnimatedHeight } from '../animation/layout'; import { ModalRef } from '../layout/Modal'; import CreateLibraryModal from '../modal/CreateLibraryModal'; import { Divider } from '../primitive/Divider'; +import ImportModalLibrary from '../modal/ImportLibraryModal'; const DrawerLibraryManager = () => { const [dropdownClosed, setDropdownClosed] = useState(true); @@ -27,6 +28,7 @@ const DrawerLibraryManager = () => { const navigation = useNavigation(); const modalRef = useRef(null); + const modalRef_import = useRef(null); return ( @@ -91,6 +93,14 @@ const DrawerLibraryManager = () => { New Library + modalRef_import.current?.present()} + > + + Import Library + + {/* Manage Library */} { diff --git a/apps/mobile/src/components/layout/ScreenContainer.tsx b/apps/mobile/src/components/layout/ScreenContainer.tsx index eecee3b4d..6dea4bfec 100644 --- a/apps/mobile/src/components/layout/ScreenContainer.tsx +++ b/apps/mobile/src/components/layout/ScreenContainer.tsx @@ -32,7 +32,7 @@ const ScreenContainer = ({ }} contentContainerStyle={twStyle('justify-between gap-10 py-6', style)} style={twStyle( - 'flex-1 bg-black', + 'bg-black', tabHeight && { marginBottom: bottomTabBarHeight } )} > diff --git a/apps/mobile/src/components/modal/ImportLibraryModal.tsx b/apps/mobile/src/components/modal/ImportLibraryModal.tsx new file mode 100644 index 000000000..29e12367d --- /dev/null +++ b/apps/mobile/src/components/modal/ImportLibraryModal.tsx @@ -0,0 +1,140 @@ +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { + CloudLibrary, + useBridgeMutation, + useBridgeQuery, + useClientContext, + useRspcContext +} from '@sd/client'; +import { forwardRef } from 'react'; +import { ActivityIndicator, Text, View } from 'react-native'; +import { Modal, ModalRef } from '~/components/layout/Modal'; +import { Button } from '~/components/primitive/Button'; +import useForwardedRef from '~/hooks/useForwardedRef'; +import { tw } from '~/lib/tailwind'; +import { RootStackParamList } from '~/navigation'; +import { currentLibraryStore } from '~/utils/nav'; + +import Empty from '../layout/Empty'; +import Fade from '../layout/Fade'; + +const ImportModalLibrary = forwardRef((_, ref) => { + const navigation = useNavigation>(); + const modalRef = useForwardedRef(ref); + + const { libraries } = useClientContext(); + + const cloudLibraries = useBridgeQuery(['cloud.library.list']); + const cloudLibrariesData = cloudLibraries.data?.filter( + (cloudLibrary) => !libraries.data?.find((l) => l.uuid === cloudLibrary.uuid) + ); + + return ( + cloudLibraries.refetch()} + > + + {cloudLibraries.isLoading ? ( + + + + ) : ( + + } + ListEmptyComponent={ + + } + keyExtractor={(item) => item.uuid} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + + )} + /> + + )} + + + ); +}); + +interface Props { + data: CloudLibrary; + modalRef: React.RefObject; + navigation: NavigationProp; +} + +const CloudLibraryCard = ({ data, modalRef, navigation }: Props) => { + const rspc = useRspcContext().queryClient; + const joinLibrary = useBridgeMutation(['cloud.library.join']); + return ( + + + {data.name} + + + + ); +}; + +export default ImportModalLibrary; diff --git a/apps/mobile/src/navigation/BackfillWaitingStack.tsx b/apps/mobile/src/navigation/BackfillWaitingStack.tsx new file mode 100644 index 000000000..dcf772f23 --- /dev/null +++ b/apps/mobile/src/navigation/BackfillWaitingStack.tsx @@ -0,0 +1,26 @@ +import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack'; +import React from 'react'; +import BackfillWaiting from '~/screens/BackfillWaiting'; + +const Stack = createNativeStackNavigator(); + +export default function BackfillWaitingStack() { + return ( + + + + ); +} + +export type BackfillWaitingStackParamList = { + BackfillWaiting: undefined; +}; + +export type BackfillWaitingStackScreenProps = + NativeStackScreenProps; diff --git a/apps/mobile/src/navigation/index.tsx b/apps/mobile/src/navigation/index.tsx index 68bbb2630..39e9d190a 100644 --- a/apps/mobile/src/navigation/index.tsx +++ b/apps/mobile/src/navigation/index.tsx @@ -4,6 +4,7 @@ import NotFoundScreen from '~/screens/NotFound'; import DrawerNavigator, { DrawerNavParamList } from './DrawerNavigator'; import SearchStack, { SearchStackParamList } from './SearchStack'; +import BackfillWaitingStack, { BackfillWaitingStackParamList } from './BackfillWaitingStack'; const Stack = createNativeStackNavigator(); // This is the main navigator we nest everything under. @@ -20,6 +21,11 @@ export default function RootNavigator() { component={SearchStack} options={{ headerShown: false }} /> + ); @@ -28,6 +34,7 @@ export default function RootNavigator() { export type RootStackParamList = { Root: NavigatorScreenParams; SearchStack: NavigatorScreenParams; + BackfillWaitingStack: NavigatorScreenParams; NotFound: undefined; }; diff --git a/apps/mobile/src/navigation/tabs/SettingsStack.tsx b/apps/mobile/src/navigation/tabs/SettingsStack.tsx index 36d12cd35..8fb152147 100644 --- a/apps/mobile/src/navigation/tabs/SettingsStack.tsx +++ b/apps/mobile/src/navigation/tabs/SettingsStack.tsx @@ -12,10 +12,12 @@ import PrivacySettingsScreen from '~/screens/settings/client/PrivacySettings'; import AboutScreen from '~/screens/settings/info/About'; import DebugScreen from '~/screens/settings/info/Debug'; import SupportScreen from '~/screens/settings/info/Support'; +import CloudSettings from '~/screens/settings/library/CloudSettings/CloudSettings'; import EditLocationSettingsScreen from '~/screens/settings/library/EditLocationSettings'; import LibraryGeneralSettingsScreen from '~/screens/settings/library/LibraryGeneralSettings'; import LocationSettingsScreen from '~/screens/settings/library/LocationSettings'; import NodesSettingsScreen from '~/screens/settings/library/NodesSettings'; +import SyncSettingsScreen from '~/screens/settings/library/SyncSettings'; import TagsSettingsScreen from '~/screens/settings/library/TagsSettings'; import SettingsScreen from '~/screens/settings/Settings'; @@ -87,6 +89,16 @@ export default function SettingsStack() { component={TagsSettingsScreen} options={{ header: () =>
}} /> +
}} + /> +
}} + /> {/* { + const animation = useSharedValue(0); + const navigation = useNavigation(); + + useEffect(() => { + animation.value = withRepeat( + withTiming(1, { duration: 5000, easing: Easing.inOut(Easing.ease) }), + -1, + true + ); + }, [animation]); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: animation.value + }; + }); + + const enableSync = useLibraryMutation(['sync.backfill'], { + onSuccess: () => { + syncEnabled.refetch(); + navigation.navigate('Root', { + screen: 'Home', + params: { + screen: 'SettingsStack', + params: { + screen: 'SyncSettings' + } + } + }); + } + }); + + const syncEnabled = useLibraryQuery(['sync.enabled']); + + useEffect(() => { + (async () => { + await enableSync.mutateAsync(null); + })(); + }, []); + + return ( + + + + + + + + + + + + + + + Library is being backfilled right now for Sync! + Please hold + while this process takes place. + + + ); +}; + +export default BackfillWaiting; diff --git a/apps/mobile/src/screens/settings/Settings.tsx b/apps/mobile/src/screens/settings/Settings.tsx index 613f3d2ef..900b6696f 100644 --- a/apps/mobile/src/screens/settings/Settings.tsx +++ b/apps/mobile/src/screens/settings/Settings.tsx @@ -1,5 +1,8 @@ +import { DebugState, useDebugState, useDebugStateEnabler } from '@sd/client'; import { + ArrowsClockwise, Books, + Cloud, FlyingSaucer, Gear, GearSix, @@ -14,7 +17,6 @@ import { } from 'phosphor-react-native'; import React from 'react'; import { Platform, SectionList, Text, TouchableWithoutFeedback, View } from 'react-native'; -import { DebugState, useDebugState, useDebugStateEnabler } from '@sd/client'; import ScreenContainer from '~/components/layout/ScreenContainer'; import { SettingsItem } from '~/components/settings/SettingsItem'; import { tw, twStyle } from '~/lib/tailwind'; @@ -86,6 +88,16 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [ icon: TagSimple, navigateTo: 'TagsSettings', title: 'Tags', + }, + { + icon: Cloud, + navigateTo: 'CloudSettings', + title: 'Cloud', + }, + { + icon: ArrowsClockwise, + navigateTo: 'SyncSettings', + title: 'Sync', rounded: 'bottom' } // { diff --git a/apps/mobile/src/screens/settings/client/GeneralSettings.tsx b/apps/mobile/src/screens/settings/client/GeneralSettings.tsx index bcfa70823..ca7e6df26 100644 --- a/apps/mobile/src/screens/settings/client/GeneralSettings.tsx +++ b/apps/mobile/src/screens/settings/client/GeneralSettings.tsx @@ -1,5 +1,5 @@ -import { Text, View } from 'react-native'; import { useBridgeQuery, useDebugState } from '@sd/client'; +import { Text, View } from 'react-native'; import Card from '~/components/layout/Card'; import ScreenContainer from '~/components/layout/ScreenContainer'; import { Divider } from '~/components/primitive/Divider'; diff --git a/apps/mobile/src/screens/settings/info/Debug.tsx b/apps/mobile/src/screens/settings/info/Debug.tsx index 83a5f5973..994aae7c0 100644 --- a/apps/mobile/src/screens/settings/info/Debug.tsx +++ b/apps/mobile/src/screens/settings/info/Debug.tsx @@ -1,6 +1,14 @@ +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { Text, View } from 'react-native'; -import { toggleFeatureFlag, useDebugState, useFeatureFlags } from '@sd/client'; +import { + auth, + toggleFeatureFlag, + useBridgeMutation, + useBridgeQuery, + useDebugState, + useFeatureFlags +} from '@sd/client'; import Card from '~/components/layout/Card'; import { Button } from '~/components/primitive/Button'; import { tw } from '~/lib/tailwind'; @@ -9,6 +17,10 @@ import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack'; const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => { const debugState = useDebugState(); const featureFlags = useFeatureFlags(); + const origin = useBridgeQuery(['cloud.getApiOrigin']); + const setOrigin = useBridgeMutation(['cloud.setApiOrigin']); + + const queryClient = useQueryClient(); return ( @@ -28,6 +40,37 @@ const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => { > Disable Debug Mode + + + ); diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx new file mode 100644 index 000000000..b904005bd --- /dev/null +++ b/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx @@ -0,0 +1,123 @@ +import { useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { useMemo } from 'react'; +import { ActivityIndicator, FlatList, Text, View } from 'react-native'; +import Card from '~/components/layout/Card'; +import Empty from '~/components/layout/Empty'; +import ScreenContainer from '~/components/layout/ScreenContainer'; +import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper'; +import { Button } from '~/components/primitive/Button'; +import { Divider } from '~/components/primitive/Divider'; +import { styled, tw, twStyle } from '~/lib/tailwind'; +import { useAuthStateSnapshot } from '~/stores/auth'; + +import Instance from './Instance'; +import Library from './Library'; +import Login from './Login'; +import ThisInstance from './ThisInstance'; + +export const InfoBox = styled(View, 'rounded-md border gap-1 border-app bg-transparent p-2'); + +const CloudSettings = () => { + return ( + + + + ); +}; + +const AuthSensitiveChild = () => { + const authState = useAuthStateSnapshot(); + if (authState.status === 'loggedIn') return ; + if (authState.status === 'notLoggedIn' || authState.status === 'loggingIn') return ; + + return null; +}; + +const Authenticated = () => { + const { library } = useLibraryContext(); + const cloudLibrary = useLibraryQuery(['cloud.library.get'], { retry: false }); + const createLibrary = useLibraryMutation(['cloud.library.create']); + + const cloudInstances = useMemo( + () => + cloudLibrary.data?.instances.filter( + (instance) => instance.uuid !== library.instance_id + ), + [cloudLibrary.data, library.instance_id] + ); + + if (cloudLibrary.isLoading) { + return ( + + + + ); + } + + return ( + + {cloudLibrary.data ? ( + + + + + + + + {cloudInstances?.length} + + + Instances + + + + + } + contentContainerStyle={twStyle( + cloudInstances?.length === 0 && 'flex-row' + )} + showsHorizontalScrollIndicator={false} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + )} + keyExtractor={(item) => item.id} + numColumns={1} + /> + + + + ) : ( + + + + )} + + ); +}; + +export default CloudSettings; diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/Instance.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/Instance.tsx new file mode 100644 index 000000000..81538e826 --- /dev/null +++ b/apps/mobile/src/screens/settings/library/CloudSettings/Instance.tsx @@ -0,0 +1,59 @@ +import { CloudInstance, HardwareModel } from '@sd/client'; +import { Text, View } from 'react-native'; +import { tw } from '~/lib/tailwind'; + +import { Icon } from '~/components/icons/Icon'; +import { hardwareModelToIcon } from '~/components/overview/Devices'; +import { InfoBox } from './CloudSettings'; + +interface Props { + data: CloudInstance; +} + +const Instance = ({ data }: Props) => { + return ( + + + + + + {data.metadata.name} + + + Id: + + {data.id} + + + + + + + + UUID: + + {data.uuid} + + + + + + + + Public key: + + {data.identity} + + + + + + ); +}; + +export default Instance; diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/Library.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/Library.tsx new file mode 100644 index 000000000..5bd825da2 --- /dev/null +++ b/apps/mobile/src/screens/settings/library/CloudSettings/Library.tsx @@ -0,0 +1,66 @@ +import { CloudLibrary, useLibraryContext, useLibraryMutation } from '@sd/client'; +import { CheckCircle, XCircle } from 'phosphor-react-native'; +import { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import Card from '~/components/layout/Card'; +import { Button } from '~/components/primitive/Button'; +import { Divider } from '~/components/primitive/Divider'; +import { SettingsTitle } from '~/components/settings/SettingsContainer'; +import { tw } from '~/lib/tailwind'; +import { logout, useAuthStateSnapshot } from '~/stores/auth'; + +import { InfoBox } from './CloudSettings'; + +interface LibraryProps { + cloudLibrary?: CloudLibrary; +} + +const Library = ({ cloudLibrary }: LibraryProps) => { + const authState = useAuthStateSnapshot(); + const { library } = useLibraryContext(); + const syncLibrary = useLibraryMutation(['cloud.library.sync']); + const thisInstance = useMemo( + () => cloudLibrary?.instances.find((instance) => instance.uuid === library.instance_id), + [cloudLibrary, library.instance_id] + ); + + return ( + + + Library + {authState.status === 'loggedIn' && ( + + )} + + + Name + + {cloudLibrary?.name} + + + + ); +}; + +export default Library; diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/Login.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/Login.tsx new file mode 100644 index 000000000..f993c968f --- /dev/null +++ b/apps/mobile/src/screens/settings/library/CloudSettings/Login.tsx @@ -0,0 +1,40 @@ +import { Text, View } from 'react-native'; +import Card from '~/components/layout/Card'; +import { Button } from '~/components/primitive/Button'; +import { tw } from '~/lib/tailwind'; +import { cancel, login, useAuthStateSnapshot } from '~/stores/auth'; + +const Login = () => { + const authState = useAuthStateSnapshot(); + const buttonText = { + notLoggedIn: 'Login', + loggingIn: 'Cancel' + }; + return ( + + + + To access cloud related features, please login + + {(authState.status === 'notLoggedIn' || authState.status === 'loggingIn') && ( + + )} + + + ); +}; + +export default Login; diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/ThisInstance.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/ThisInstance.tsx new file mode 100644 index 000000000..e17d00f0c --- /dev/null +++ b/apps/mobile/src/screens/settings/library/CloudSettings/ThisInstance.tsx @@ -0,0 +1,70 @@ +import { CloudLibrary, HardwareModel, useLibraryContext } from '@sd/client'; +import { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import Card from '~/components/layout/Card'; +import { Divider } from '~/components/primitive/Divider'; +import { tw } from '~/lib/tailwind'; + +import { Icon } from '~/components/icons/Icon'; +import { hardwareModelToIcon } from '~/components/overview/Devices'; +import { InfoBox } from './CloudSettings'; + +interface ThisInstanceProps { + cloudLibrary?: CloudLibrary; +} + +const ThisInstance = ({ cloudLibrary }: ThisInstanceProps) => { + const { library } = useLibraryContext(); + const thisInstance = useMemo( + () => cloudLibrary?.instances.find((instance) => instance.uuid === library.instance_id), + [cloudLibrary, library.instance_id] + ); + + if (!thisInstance) return null; + + return ( + + + This Instance + + + + + {thisInstance.metadata.name} + + + + + Id: + {thisInstance.id} + + + + + + + UUID: + {thisInstance.uuid} + + + + + + + Publc Key: + + {thisInstance.identity} + + + + + + ); +}; + +export default ThisInstance; diff --git a/apps/mobile/src/screens/settings/library/SyncSettings.tsx b/apps/mobile/src/screens/settings/library/SyncSettings.tsx new file mode 100644 index 000000000..c2ac34dfa --- /dev/null +++ b/apps/mobile/src/screens/settings/library/SyncSettings.tsx @@ -0,0 +1,133 @@ +import { inferSubscriptionResult } from '@oscartbeaumont-sd/rspc-client'; +import { MotiView } from 'moti'; +import { Circle } from 'phosphor-react-native'; +import React, { useEffect, useState } from 'react'; +import { Text, View } from 'react-native'; +import { + Procedures, + useLibraryMutation, + useLibraryQuery, + useLibrarySubscription +} from '@sd/client'; +import Card from '~/components/layout/Card'; +import ScreenContainer from '~/components/layout/ScreenContainer'; +import { Button } from '~/components/primitive/Button'; +import { tw } from '~/lib/tailwind'; +import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack'; + +const SyncSettingsScreen = ({ navigation }: SettingsStackScreenProps<'SyncSettings'>) => { + const syncEnabled = useLibraryQuery(['sync.enabled']); + const [data, setData] = useState>({}); + + const [startBackfill, setStart] = useState(false); + + useLibrarySubscription(['library.actors'], { onData: setData }); + + useEffect(() => { + if (startBackfill === true) { + navigation.navigate('BackfillWaitingStack', { + screen: 'BackfillWaiting' + }); + } + }, [startBackfill, navigation]); + + return ( + + {syncEnabled.data === false ? ( + + + + + + ) : ( + + {Object.keys(data).map((key) => { + return ( + + + + {key} + + {data[key] ? : } + + ); + })} + + )} + + ); +}; + +export default SyncSettingsScreen; + +function OnlineIndicator({ online }: { online: boolean }) { + const size = 6; + return ( + + {online ? ( + + + + + ) : ( + + )} + + ); +} + +function StartButton({ name }: { name: string }) { + const startActor = useLibraryMutation(['library.startActor']); + return ( + + ); +} + +function StopButton({ name }: { name: string }) { + const stopActor = useLibraryMutation(['library.stopActor']); + return ( + + ); +} diff --git a/apps/mobile/src/stores/auth.ts b/apps/mobile/src/stores/auth.ts new file mode 100644 index 000000000..665a5b3d9 --- /dev/null +++ b/apps/mobile/src/stores/auth.ts @@ -0,0 +1,100 @@ +import { RSPCError } from '@oscartbeaumont-sd/rspc-client'; +import { nonLibraryClient, useSolidStore } from '@sd/client'; +import { Linking } from 'react-native'; +import { createMutable } from 'solid-js/store'; + +interface Store { + state: { status: 'loading' | 'notLoggedIn' | 'loggingIn' | 'loggedIn' | 'loggingOut' }; +} + +// inner object so we can overwrite it in one assignment +const store = createMutable({ + state: { + status: 'loading' + } +}); + +export function useAuthStateSnapshot() { + return useSolidStore(store).state; +} + +nonLibraryClient + .query(['auth.me']) + .then(() => (store.state = { status: 'loggedIn' })) + .catch((e) => { + if (e instanceof RSPCError && e.code === 401) { + // TODO: handle error? + console.error("error", e); + } + store.state = { status: 'notLoggedIn' }; + }); + +type CallbackStatus = 'success' | { error: string } | 'cancel'; +const loginCallbacks = new Set<(status: CallbackStatus) => void>(); + +function onError(error: string) { + loginCallbacks.forEach((cb) => cb({ error })); +} + +export function login() { + if (store.state.status !== 'notLoggedIn') return; + + store.state = { status: 'loggingIn' }; + + let authCleanup = nonLibraryClient.addSubscription(['auth.loginSession'], { + onData(data) { + if (data === 'Complete') { + loginCallbacks.forEach((cb) => cb('success')); + } else if ('Error' in data) { + console.error('[auth] error: ', data.Error); + onError(data.Error); + } else { + console.log('[auth] verification url: ', data.Start.verification_url_complete); + Promise.resolve() + .then(() => Linking.openURL(data.Start.verification_url_complete)) + .then( + (res) => { + authCleanup = res; + }, + (e) => onError(e.message) + ); + } + }, + onError(e) { + onError(e.message); + } + }); + + return new Promise((res, rej) => { + const cb = async (status: CallbackStatus) => { + loginCallbacks.delete(cb); + + if (status === 'success') { + store.state = { status: 'loggedIn' }; + nonLibraryClient.query(['auth.me']); + res(); + } else { + store.state = { status: 'notLoggedIn' }; + rej(JSON.stringify(status)); + } + }; + loginCallbacks.add(cb); + }); +} + +export function set_logged_in() { + store.state = { status: 'loggedIn' }; +} + +export function logout() { + store.state = { status: 'loggingOut' }; + nonLibraryClient.mutation(['auth.logout']); + nonLibraryClient.query(['auth.me']); + store.state = { status: 'notLoggedIn' }; +} + +export async function cancel() { + await loginCallbacks.forEach(async (cb) => await cb('cancel')); + await loginCallbacks.clear(); + store.state = { status: 'notLoggedIn' }; +} diff --git a/core/src/api/auth.rs b/core/src/api/auth.rs index 79a672514..52673323e 100644 --- a/core/src/api/auth.rs +++ b/core/src/api/auth.rs @@ -32,13 +32,21 @@ pub(crate) fn mount() -> AlphaRouter { } async_stream::stream! { + let device_type = if cfg!(target_arch = "wasm32") { + "web".to_string() + } else if cfg!(target_os = "ios") || cfg!(target_os = "android") { + "mobile".to_string() + } else { + "desktop".to_string() + }; + let auth_response = match match node .http .post(&format!( "{}/login/device/code", &node.env.api_url.lock().await )) - .form(&[("client_id", &node.env.client_id)]) + .form(&[("client_id", &node.env.client_id), ("device", &device_type)]) .send() .await .map_err(|e| e.to_string()) diff --git a/core/src/lib.rs b/core/src/lib.rs index cdfda558b..48f131116 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -141,7 +141,9 @@ impl Node { config, event_bus, libraries, - cloud_sync_flag: Arc::new(AtomicBool::new(false)), + cloud_sync_flag: Arc::new(AtomicBool::new( + cfg!(target_os = "ios") || cfg!(target_os = "android"), + )), http: reqwest::Client::new(), env, #[cfg(feature = "ai")] diff --git a/crates/crypto/src/types.rs b/crates/crypto/src/types.rs index 298c07bb2..45416ba48 100644 --- a/crates/crypto/src/types.rs +++ b/crates/crypto/src/types.rs @@ -326,7 +326,7 @@ where I: ArrayLength, { fn from(value: &Key) -> Self { - value.expose().iter().copied().collect() // TODO(brxken128): streamline this? + GenericArray::clone_from_slice(value.expose()) } } diff --git a/docs/product/guides/folder-sync.mdx b/docs/product/guides/folder-sync.mdx deleted file mode 100644 index 18f1b64a5..000000000 --- a/docs/product/guides/folder-sync.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -index: 100 ---- - -# Folder Sync diff --git a/interface/app/$libraryId/Explorer/ExplorerPath.tsx b/interface/app/$libraryId/Explorer/ExplorerPathBar.tsx similarity index 92% rename from interface/app/$libraryId/Explorer/ExplorerPath.tsx rename to interface/app/$libraryId/Explorer/ExplorerPathBar.tsx index bb8e0913c..182279454 100644 --- a/interface/app/$libraryId/Explorer/ExplorerPath.tsx +++ b/interface/app/$libraryId/Explorer/ExplorerPathBar.tsx @@ -21,9 +21,10 @@ import { lookup } from './RevealInNativeExplorer'; import { useExplorerDroppable } from './useExplorerDroppable'; import { useExplorerSearchParams } from './util'; +// todo: ENTIRELY replace with computed combined pathbar+tagbar height export const PATH_BAR_HEIGHT = 32; -export const ExplorerPath = memo(() => { +export const ExplorerPathBar = memo(() => { const os = useOperatingSystem(true); const navigate = useNavigate(); const [{ path: searchPath }] = useExplorerSearchParams(); @@ -118,13 +119,16 @@ export const ExplorerPath = memo(() => { return (
- {paths.map((path) => ( + {paths.map((path, idx) => ( handleOnClick(path)} disabled={path.pathname === (searchPath ?? (location && '/'))} @@ -148,9 +152,10 @@ interface PathProps { onClick: () => void; disabled: boolean; locationPath: string; + isLast: boolean; } -const Path = ({ path, onClick, disabled, locationPath }: PathProps) => { +const Path = ({ path, onClick, disabled, locationPath, isLast }: PathProps) => { const isDark = useIsDark(); const { revealItems } = usePlatform(); const { library } = useLibraryContext(); @@ -192,7 +197,7 @@ const Path = ({ path, onClick, disabled, locationPath }: PathProps) => { } > diff --git a/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx new file mode 100644 index 000000000..cb571d953 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx @@ -0,0 +1,336 @@ +import { Circle } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { + ExplorerItem, + Tag, + Target, + useLibraryMutation, + useLibraryQuery, + useRspcContext, + useSelector +} from '@sd/client'; +import { Shortcut, toast } from '@sd/ui'; +import { useIsDark, useKeybind, useLocale, useOperatingSystem } from '~/hooks'; +import { keybindForOs } from '~/util/keybinds'; + +import { useExplorerContext } from './Context'; +import { explorerStore } from './store'; + +export const TAG_BAR_HEIGHT = 64; +const NUMBER_KEYCODES: string[][] = [ + ['Key1'], + ['Key2'], + ['Key3'], + ['Key4'], + ['Key5'], + ['Key6'], + ['Key7'], + ['Key8'], + ['Key9'] +]; + +// TODO: hoist this to somewhere higher as a utility function +const toTarget = (data: ExplorerItem): Target => { + if (!data || !('id' in data.item)) + throw new Error('Tried to convert an invalid object to Target.'); + + return ( + data.type === 'Object' + ? { + Object: data.item.id + } + : { + FilePath: data.item.id + } + ) satisfies Target; +}; + +type TagBulkAssignHotkeys = typeof explorerStore.tagBulkAssignHotkeys; +function getHotkeysWithNewAssignment( + hotkeys: TagBulkAssignHotkeys, + options: + | { + unassign?: false; + tagId: number; + hotkey: string; + } + | { + unassign: true; + tagId: number; + hotkey?: string; + } +): TagBulkAssignHotkeys { + const hotkeysWithoutCurrentTag = hotkeys.filter( + ({ hotkey, tagId }) => !(tagId === options.tagId || hotkey === options.hotkey) + ); + + if (options.unassign) { + return hotkeysWithoutCurrentTag; + } + + return hotkeysWithoutCurrentTag.concat({ + hotkey: options.hotkey, + tagId: options.tagId + }); +} + +// million-ignore +export const ExplorerTagBar = () => { + const [tagBulkAssignHotkeys] = useSelector(explorerStore, (s) => [s.tagBulkAssignHotkeys]); + const explorer = useExplorerContext(); + const rspc = useRspcContext(); + const tagsRef = useRef(null); + const [isTagsOverflowing, setIsTagsOverflowing] = useState(false); + + const updateOverflowState = () => { + const element = tagsRef.current; + if (element) { + setIsTagsOverflowing( + element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth + ); + } + } + + useEffect(() => { + const element = tagsRef.current; + if (!element) return; + //handles initial render when not resizing + setIsTagsOverflowing(element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth) + //make sure state updates when window resizing + window.addEventListener('resize', () => { + updateOverflowState(); + }) + //remove listeners on unmount + return () => { + window.removeEventListener('resize', () => { + updateOverflowState(); + }) + } + }, [tagsRef]) + + const [tagListeningForKeyPress, setTagListeningForKeyPress] = useState(); + + const { data: allTags = [] } = useLibraryQuery(['tags.list']); + const mutation = useLibraryMutation(['tags.assign'], { + onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + }); + + const { t } = useLocale(); + + // This will automagically listen for any keypress 1-9 while the tag bar is visible. + // These listeners will unmount when ExplorerTagBar is unmounted. + useKeybind( + NUMBER_KEYCODES, + async (e) => { + const targets = Array.from(explorer.selectedItems.entries()).map((item) => + toTarget(item[0]) + ); + + // Silent fail if no files are selected + if (targets.length < 1) return; + + const keyPressed = e.key; + + let tag: Tag | undefined; + + findTag: { + const tagId = tagBulkAssignHotkeys.find( + ({ hotkey }) => hotkey === keyPressed + )?.tagId; + const foundTag = allTags.find((t) => t.id === tagId); + + if (!foundTag) break findTag; + + tag = foundTag; + } + + if (!tag) return; + + try { + await mutation.mutateAsync({ + targets, + tag_id: tag.id, + unassign: false + }); + + toast( + t('tags_bulk_assigned', { + tag_name: tag.name, + file_count: targets.length + }), + { + type: 'success' + } + ); + } catch (err) { + let msg: string = t('error_unknown'); + + if (err instanceof Error || (typeof err === 'object' && err && 'message' in err)) { + msg = err.message as string; + } else if (typeof err === 'string') { + msg = err; + } + + console.error('Tag assignment failed with error', err); + + let failedToastMessage: string = t('tags_bulk_failed_without_tag', { + file_count: targets.length, + error_message: msg + }); + + if (tag) + failedToastMessage = t('tags_bulk_failed_with_tag', { + tag_name: tag.name, + file_count: targets.length, + error_message: msg + }); + + toast(failedToastMessage, { + type: 'error' + }); + } + }, + { + enabled: typeof tagListeningForKeyPress !== 'number' + } + ); + + return ( +
+ {t('tags_bulk_instructions')} + +
    + {/* Did not want to write a .toSorted() predicate for this so lazy spreading things with hotkeys first then the rest after */} + {allTags + .toSorted((tagA, tagB) => { + // Sort this array by hotkeys 1-9 first, then unasssigned tags. I know, it's terrible. + // This 998/999 bit is likely terrible for sorting. I'm bad at writing sort predicates. + // Improvements (probably much simpler than this anyway) are much welcome <3 + // -- iLynxcat 3/jun/2024 + + const hotkeyA = +( + tagBulkAssignHotkeys.find((k) => k.tagId === tagA.id)?.hotkey ?? 998 + ); + const hotkeyB = +( + tagBulkAssignHotkeys.find((k) => k.tagId === tagB.id)?.hotkey ?? 999 + ); + + return hotkeyA - hotkeyB; + }) + .map((tag) => ( +
  • + tagId === tag.id) + ?.hotkey + } + isAwaitingKeyPress={tagListeningForKeyPress === tag.id} + onClick={() => { + setTagListeningForKeyPress(tag.id); + }} + onKeyPress={(e) => { + if (e.key === 'Escape') { + explorerStore.tagBulkAssignHotkeys = + getHotkeysWithNewAssignment(tagBulkAssignHotkeys, { + unassign: true, + tagId: tag.id + }); + + setTagListeningForKeyPress(undefined); + + return; + } + + explorerStore.tagBulkAssignHotkeys = + getHotkeysWithNewAssignment(tagBulkAssignHotkeys, { + tagId: tag.id, + hotkey: e.key + }); + setTagListeningForKeyPress(undefined); + }} + /> +
  • + ))} +
+
+ ); +}; + +interface TagItemProps { + tag: Tag; + assignKey?: string; + isAwaitingKeyPress: boolean; + onKeyPress: (e: KeyboardEvent) => void; + onClick: () => void; +} + +const TagItem = ({ + tag, + assignKey, + isAwaitingKeyPress = false, + onKeyPress, + onClick +}: TagItemProps) => { + const buttonRef = useRef(null); + const isDark = useIsDark(); + + const os = useOperatingSystem(true); + const keybind = keybindForOs(os); + + useKeybind( + [...NUMBER_KEYCODES, ['Escape']], + (e) => { + buttonRef.current?.blur(); // Hides the focus ring after Escape is pressed to cancel assignment + return onKeyPress(e); + }, + { + enabled: isAwaitingKeyPress + } + ); + + return ( + + ); +}; diff --git a/interface/app/$libraryId/Explorer/TopBarOptions.tsx b/interface/app/$libraryId/Explorer/TopBarOptions.tsx index 799be0022..b79dd7ae4 100644 --- a/interface/app/$libraryId/Explorer/TopBarOptions.tsx +++ b/interface/app/$libraryId/Explorer/TopBarOptions.tsx @@ -5,7 +5,8 @@ import { Rows, SidebarSimple, SlidersHorizontal, - SquaresFour + SquaresFour, + Tag } from '@phosphor-icons/react'; import clsx from 'clsx'; import { useMemo } from 'react'; @@ -15,7 +16,7 @@ import { useKeyMatcher, useLocale } from '~/hooks'; import { KeyManager } from '../KeyManager'; import { Spacedrop, SpacedropButton } from '../Spacedrop'; -import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; +import TopBarOptions, { ToolOption, TOP_BAR_ICON_CLASSLIST } from '../TopBar/TopBarOptions'; import { useExplorerContext } from './Context'; import OptionsPanel from './OptionsPanel'; import { explorerStore } from './store'; @@ -29,7 +30,7 @@ const layoutIcons: Record = { export const useExplorerTopBarOptions = () => { const [showInspector, tagAssignMode] = useSelector(explorerStore, (s) => [ s.showInspector, - s.tagAssignMode + s.isTagAssignModeActive ]); const explorer = useExplorerContext(); const controlIcon = useKeyMatcher('Meta').icon; @@ -48,7 +49,7 @@ export const useExplorerTopBarOptions = () => { const option = { layout, toolTipLabel: t(`${layout}_view`), - icon: , + icon: , keybinds: [controlIcon, (i + 1).toString()], topBarActive: !explorer.isLoadingPreferences && settings.layoutMode === layout, @@ -73,7 +74,7 @@ export const useExplorerTopBarOptions = () => { const controlOptions: ToolOption[] = [ { toolTipLabel: t('explorer_settings'), - icon: , + icon: , popOverComponent: , individual: true, showAtResolution: 'sm:flex' @@ -87,7 +88,7 @@ export const useExplorerTopBarOptions = () => { icon: ( ), individual: true, @@ -118,11 +119,28 @@ export const useExplorerTopBarOptions = () => { showAtResolution: 'xl:flex' }, { - toolTipLabel: t('key_manager'), - icon: , + toolTipLabel: 'Key Manager', + icon: , popOverComponent: , individual: true, showAtResolution: 'xl:flex' + }, + { + toolTipLabel: 'Assign tags', + icon: ( + + ), + // TODO: Assign tag mode is not yet implemented! + onClick: () => + (explorerStore.isTagAssignModeActive = !explorerStore.isTagAssignModeActive), + // TODO: remove once tag-assign-mode impl complete + // onClick: () => toast.info('Coming soon!'), + topBarActive: tagAssignMode, + individual: true, + showAtResolution: 'xl:flex' } // { // toolTipLabel: 'Tag Assign Mode', diff --git a/interface/app/$libraryId/Explorer/View/DragScrollable.tsx b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx index 4d4d789ac..7b7a701b8 100644 --- a/interface/app/$libraryId/Explorer/View/DragScrollable.tsx +++ b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx @@ -3,7 +3,7 @@ import { tw } from '@sd/ui'; import { useTopBarContext } from '../../TopBar/Context'; import { useExplorerContext } from '../Context'; -import { PATH_BAR_HEIGHT } from '../ExplorerPath'; +import { PATH_BAR_HEIGHT } from '../ExplorerPathBar'; import { useDragScrollable } from './useDragScrollable'; const Trigger = tw.div`absolute inset-x-0 h-10 pointer-events-none`; diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 6a7807909..77fe48bc7 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -94,7 +94,7 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { const activeItem = useActiveItem(); - useShortcuts(); + useExplorerShortcuts(); useShortcut('explorerEscape', () => explorer.resetSelectedItems([]), { disabled: !selectable || explorer.selectedItems.size === 0 @@ -192,9 +192,12 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { ); }; -const useShortcuts = () => { +const useExplorerShortcuts = () => { const explorer = useExplorerContext(); - const isRenaming = useSelector(explorerStore, (s) => s.isRenaming); + const [isRenaming, tagAssignMode] = useSelector(explorerStore, (s) => [ + s.isRenaming, + s.isTagAssignModeActive + ]); const quickPreviewStore = useQuickPreviewStore(); const meta = useKeyMatcher('Meta'); @@ -207,6 +210,10 @@ const useShortcuts = () => { useShortcut('duplicateObject', duplicate); useShortcut('pasteObject', paste); + useShortcut('toggleTagAssignMode', (e) => { + explorerStore.isTagAssignModeActive = !tagAssignMode; + }); + useShortcut('toggleQuickPreview', (e) => { if (isRenaming || dialogManager.isAnyDialogOpen()) return; if (explorerStore.isCMDPOpen) return; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index f11151f69..a4a98b1e1 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -13,7 +13,7 @@ import { useTopBarContext } from '../TopBar/Context'; import { useExplorerContext } from './Context'; import ContextMenu from './ContextMenu'; import DismissibleNotice from './DismissibleNotice'; -import { ExplorerPath, PATH_BAR_HEIGHT } from './ExplorerPath'; +import { ExplorerPathBar, PATH_BAR_HEIGHT } from './ExplorerPathBar'; import { Inspector, INSPECTOR_WIDTH } from './Inspector'; import ExplorerContextMenu from './ParentContextMenu'; import { getQuickPreviewStore } from './QuickPreview/store'; @@ -24,6 +24,9 @@ import { EmptyNotice } from './View/EmptyNotice'; import 'react-slidedown/lib/slidedown.css'; +import clsx from 'clsx'; + +import { ExplorerTagBar } from './ExplorerTagBar'; import { useExplorerDnd } from './useExplorerDnd'; interface Props { @@ -38,7 +41,10 @@ interface Props { export default function Explorer(props: PropsWithChildren) { const explorer = useExplorerContext(); const layoutStore = useExplorerLayoutStore(); - const showInspector = useSelector(explorerStore, (s) => s.showInspector); + const [showInspector, showTagBar] = useSelector(explorerStore, (s) => [ + s.showInspector, + s.isTagAssignModeActive + ]); const showPathBar = explorer.showPathBar && layoutStore.showPathBar; const rspc = useRspcLibraryContext(); @@ -117,14 +123,20 @@ export default function Explorer(props: PropsWithChildren) {
- {showPathBar && } + {/* TODO: wrap path bar and tag bar in nice wrapper, ideally animate tag bar in/out directly above path bar */} +
+ {showTagBar && } + {showPathBar && } +
{showInspector && ( )} diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index 5ba8bfb96..da6e6a545 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -1,3 +1,6 @@ +import { proxy } from 'valtio'; +import { proxySet } from 'valtio/utils'; +import { z } from 'zod'; import { ThumbKey, resetStore, @@ -7,9 +10,6 @@ import { type ExplorerSettings, type Ordering } from '@sd/client'; -import { proxy } from 'valtio'; -import { proxySet } from 'valtio/utils'; -import { z } from 'zod'; import i18n from '~/app/I18n'; import { @@ -98,7 +98,6 @@ type DragState = }; const state = { - tagAssignMode: false, showInspector: false, showMoreInfo: false, newLocationToRedirect: null as null | number, @@ -106,12 +105,15 @@ const state = { newThumbnails: proxySet() as Set, cutCopyState: { type: 'Idle' } as CutCopyState, drag: null as null | DragState, + isTagAssignModeActive: false, isDragSelecting: false, isRenaming: false, // Used for disabling certain keyboard shortcuts when command palette is open isCMDPOpen: false, isContextMenuOpen: false, - quickRescanLastRun: Date.now() - 200 + quickRescanLastRun: Date.now() - 200, + // Map = { hotkey: '0'...'9', tagId: 1234 } + tagBulkAssignHotkeys: [] as Array<{ hotkey: string; tagId: number }> }; export function flattenThumbnailKey(thumbKey: ThumbKey) { diff --git a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx index 50ed7c3c3..5ba210717 100644 --- a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/LibrariesDropdown.tsx @@ -1,4 +1,4 @@ -import { Gear, Lock, Plus } from '@phosphor-icons/react'; +import { CloudArrowDown, Gear, Lock, Plus } from '@phosphor-icons/react'; import clsx from 'clsx'; import { useClientContext } from '@sd/client'; import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui'; @@ -6,6 +6,7 @@ import { useLocale } from '~/hooks'; import CreateDialog from '../../../settings/node/libraries/CreateDialog'; import { useSidebarContext } from './Context'; +import JoinDialog from '~/app/$libraryId/settings/node/libraries/JoinDialog'; export default () => { const { library, libraries, currentLibraryId } = useClientContext(); @@ -62,6 +63,13 @@ export default () => { onClick={() => dialogManager.create((dp) => )} className="font-medium" /> + dialogManager.create((dp) => )} + className="font-medium" + /> void }) { return (
- +
); } diff --git a/interface/app/$libraryId/TopBar/TopBarMobile.tsx b/interface/app/$libraryId/TopBar/TopBarMobile.tsx index 2ca74c83a..3d6ae447a 100644 --- a/interface/app/$libraryId/TopBar/TopBarMobile.tsx +++ b/interface/app/$libraryId/TopBar/TopBarMobile.tsx @@ -3,7 +3,7 @@ import React, { forwardRef, HTMLAttributes } from 'react'; import { Popover, usePopover } from '@sd/ui'; import TopBarButton, { TopBarButtonProps } from './TopBarButton'; -import { ToolOption, TOP_BAR_ICON_STYLE } from './TopBarOptions'; +import { ToolOption, TOP_BAR_ICON_CLASSLIST } from './TopBarOptions'; const GroupTool = forwardRef< HTMLButtonElement, @@ -40,7 +40,7 @@ export default ({ toolOptions, className }: Props) => { popover={popover} trigger={ - + } > diff --git a/interface/app/$libraryId/TopBar/TopBarOptions.tsx b/interface/app/$libraryId/TopBar/TopBarOptions.tsx index 399137492..cc4c676a3 100644 --- a/interface/app/$libraryId/TopBar/TopBarOptions.tsx +++ b/interface/app/$libraryId/TopBar/TopBarOptions.tsx @@ -26,7 +26,7 @@ interface TopBarChildrenProps { options?: ToolOption[][]; } -export const TOP_BAR_ICON_STYLE = 'm-0.5 w-[18px] h-[18px] text-ink-dull'; +export const TOP_BAR_ICON_CLASSLIST = 'm-0.5 w-[18px] h-[18px] text-ink-dull'; export default ({ options }: TopBarChildrenProps) => { const [windowSize, setWindowSize] = useState(0); @@ -193,7 +193,7 @@ export function WindowsControls({ windowSize }: { windowSize: number }) { active={false} onClick={() => appWindow.minimize()} > - + {maximized ? ( - + ) : ( - + )} appWindow.close()} > - + ); diff --git a/interface/app/$libraryId/debug/cloud.tsx b/interface/app/$libraryId/debug/cloud.tsx index aa1ba6d3b..e783514b3 100644 --- a/interface/app/$libraryId/debug/cloud.tsx +++ b/interface/app/$libraryId/debug/cloud.tsx @@ -1,8 +1,20 @@ -import { auth, useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client'; -import { Button } from '@sd/ui'; +import { CheckCircle, XCircle } from '@phosphor-icons/react'; +import { + CloudInstance, + CloudLibrary, + HardwareModel, + auth, + useLibraryContext, + useLibraryMutation, + useLibraryQuery +} from '@sd/client'; +import { Button, Card, Loader, tw } from '@sd/ui'; +import { Suspense, useMemo } from 'react'; +import { Icon } from '~/components'; import { AuthRequiredOverlay } from '~/components/AuthRequiredOverlay'; import { LoginButton } from '~/components/LoginButton'; -import { useRouteTitle } from '~/hooks'; +import { useLocale, useRouteTitle } from '~/hooks'; +import { hardwareModelToIcon } from '~/util/hardware'; export const Component = () => { useRouteTitle('Cloud'); @@ -12,7 +24,14 @@ export const Component = () => { const authSensitiveChild = () => { if (authState.status === 'loggedIn') return ; if (authState.status === 'notLoggedIn' || authState.status === 'loggingIn') - return ; + return ( +
+ +

To access cloud related features, please login

+ +
+
+ ); return null; }; @@ -20,74 +39,192 @@ export const Component = () => { return
{authSensitiveChild()}
; }; +const DataBox = tw.div`max-w-[300px] rounded-md border border-app-line/50 bg-app-lightBox/20 p-2`; +const Count = tw.div`min-w-[20px] flex h-[20px] px-1 items-center justify-center rounded-full border border-app-button/40 text-[9px]`; + +// million-ignore function Authenticated() { const { library } = useLibraryContext(); - const cloudLibrary = useLibraryQuery(['cloud.library.get'], { suspense: true, retry: false }); - const createLibrary = useLibraryMutation(['cloud.library.create']); - const syncLibrary = useLibraryMutation(['cloud.library.sync']); + const { t } = useLocale(); - const thisInstance = cloudLibrary.data?.instances.find( - (instance) => instance.uuid === library.instance_id - ); + const thisInstance = useMemo(() => { + if (!cloudLibrary.data) return undefined; + return cloudLibrary.data.instances.find( + (instance) => instance.uuid === library.instance_id + ); + }, [cloudLibrary.data, library.instance_id]); return ( - <> + + + + } + > {cloudLibrary.data ? ( -
-
-

Library

-

Name: {cloudLibrary.data.name}

-
- - - - {thisInstance && ( -
-

This Instance

-

Id: {thisInstance.id}

-

UUID: {thisInstance.uuid}

-

Public Key: {thisInstance.identity}

-
- )} -
-

Instances

-
    - {cloudLibrary.data.instances - .filter((instance) => instance.uuid !== library.instance_id) - .map((instance) => ( -
  • -

    Id: {instance.id}

    -

    UUID: {instance.uuid}

    -

    Public Key: {instance.identity}

    -
  • - ))} -
-
+
+ + {thisInstance && } +
) : ( -
+
)} - + ); } + +// million-ignore +const Instances = ({ instances }: { instances: CloudInstance[] }) => { + const { library } = useLibraryContext(); + const filteredInstances = instances.filter((instance) => instance.uuid !== library.instance_id); + return ( +
+
+

Instances

+ {filteredInstances.length} +
+
+ {filteredInstances.map((instance) => ( + +
+ +

+ {instance.metadata.name} +

+
+
+ +

+ Id:{' '} + {instance.id} +

+
+ +

+ UUID:{' '} + + {instance.uuid} + +

+
+ +

+ Public Key:{' '} + + {instance.identity} + +

+
+
+
+ ))} +
+
+ ); +}; + +interface LibraryProps { + cloudLibrary: CloudLibrary; + thisInstance: CloudInstance | undefined; +} + +// million-ignore +const Library = ({ thisInstance, cloudLibrary }: LibraryProps) => { + const syncLibrary = useLibraryMutation(['cloud.library.sync']); + return ( +
+

Library

+ +

+ Name: {cloudLibrary.name} +

+ +
+
+ ); +}; + +interface ThisInstanceProps { + instance: CloudInstance; +} + +// million-ignore +const ThisInstance = ({ instance }: ThisInstanceProps) => { + return ( +
+

This Instance

+ +
+ +

+ {instance.metadata.name} +

+
+
+ +

+ Id: {instance.id} +

+
+ +

+ UUID: {instance.uuid} +

+
+ +

+ Public Key:{' '} + {instance.identity} +

+
+
+
+
+ ); +}; diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 576399779..76606d318 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -35,7 +35,7 @@ import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from import SearchBar from '../search/SearchBar'; import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery'; import { TopBarPortal } from '../TopBar/Portal'; -import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions'; +import { TOP_BAR_ICON_CLASSLIST } from '../TopBar/TopBarOptions'; import LocationOptions from './LocationOptions'; export const Component = () => { @@ -151,7 +151,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) = { toolTipLabel: t('reload'), onClick: () => rescan(location.id), - icon: , + icon: , individual: true, showAtResolution: 'xl:flex' } diff --git a/interface/app/$libraryId/settings/node/libraries/JoinDialog.tsx b/interface/app/$libraryId/settings/node/libraries/JoinDialog.tsx new file mode 100644 index 000000000..b32fd7109 --- /dev/null +++ b/interface/app/$libraryId/settings/node/libraries/JoinDialog.tsx @@ -0,0 +1,105 @@ +import { + LibraryConfigWrapped, + useBridgeMutation, + useBridgeQuery, + useClientContext, + useLibraryContext, + usePlausibleEvent, + useZodForm +} from '@sd/client'; +import { Button, Dialog, Select, SelectOption, toast, useDialog, UseDialogProps, z } from '@sd/ui'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; +import { useLocale } from '~/hooks'; +import { usePlatform } from '~/util/Platform'; + +const schema = z.object({ + libraryId: z.string().refine((value) => value !== 'select_library', { + message: 'Please select a library' + }) +}); + + +export default (props: UseDialogProps & { librariesCtx: LibraryConfigWrapped[] | undefined }) => { + const cloudLibraries = useBridgeQuery(['cloud.library.list']); + const joinLibrary = useBridgeMutation(['cloud.library.join']); + + const { t } = useLocale(); + const navigate = useNavigate(); + const platform = usePlatform(); + const queryClient = useQueryClient(); + + const form = useZodForm({ schema, defaultValues: { libraryId: 'select_library' } }); + + // const queryClient = useQueryClient(); + // const submitPlausibleEvent = usePlausibleEvent(); + // const platform = usePlatform(); + + const onSubmit = form.handleSubmit(async (data) => { + try { + const library = await joinLibrary.mutateAsync(data.libraryId); + + queryClient.setQueryData(['library.list'], (libraries: any) => { + // The invalidation system beat us to it + if ((libraries || []).find((l: any) => l.uuid === library.uuid)) return libraries; + + return [...(libraries || []), library]; + }); + + platform.refreshMenuBar && platform.refreshMenuBar(); + + navigate(`/${library.uuid}`, { replace: true }); + } catch (e: any) { + console.error(e); + toast.error(e); + } + }); + + return ( + +
+ {cloudLibraries.isLoading && {t('loading')}...} + {cloudLibraries.data && ( + + )} +
+
+ ); +}; diff --git a/interface/app/$libraryId/settings/node/libraries/index.tsx b/interface/app/$libraryId/settings/node/libraries/index.tsx index 517cf59c2..dc8ff541b 100644 --- a/interface/app/$libraryId/settings/node/libraries/index.tsx +++ b/interface/app/$libraryId/settings/node/libraries/index.tsx @@ -1,16 +1,21 @@ -import { useBridgeQuery, useLibraryContext } from '@sd/client'; +import { useBridgeQuery, useClientContext, useFeatureFlag, useLibraryContext } from '@sd/client'; import { Button, dialogManager } from '@sd/ui'; import { useLocale } from '~/hooks'; import { Heading } from '../../Layout'; import CreateDialog from './CreateDialog'; +import JoinDialog from './JoinDialog'; import ListItem from './ListItem'; export const Component = () => { const librariesQuery = useBridgeQuery(['library.list']); const libraries = librariesQuery.data; + const cloudEnabled = useFeatureFlag('cloudSync'); + const { library } = useLibraryContext(); + const { libraries: librariesCtx } = useClientContext(); + const librariesCtxData = librariesCtx.data; const { t } = useLocale(); @@ -30,10 +35,22 @@ export const Component = () => { > {t('add_library')} + {cloudEnabled && ( + + )}
} /> -
{libraries ?.sort((a, b) => { diff --git a/interface/hooks/useIsLocationIndexing.ts b/interface/hooks/useIsLocationIndexing.ts index c77604e5e..e9e4d510a 100644 --- a/interface/hooks/useIsLocationIndexing.ts +++ b/interface/hooks/useIsLocationIndexing.ts @@ -28,6 +28,7 @@ export const useIsLocationIndexing = (locationId: number): boolean => { ) { return job.completed_task_count === 0; } + return false; }) ) || false; diff --git a/interface/hooks/useRedirectToNewLocation.ts b/interface/hooks/useRedirectToNewLocation.ts index 4310753bd..d34ec3096 100644 --- a/interface/hooks/useRedirectToNewLocation.ts +++ b/interface/hooks/useRedirectToNewLocation.ts @@ -1,6 +1,6 @@ +import { useLibraryQuery, useSelector } from '@sd/client'; import { useEffect } from 'react'; import { useNavigate } from 'react-router'; -import { useLibraryQuery, useSelector } from '@sd/client'; import { explorerStore } from '~/app/$libraryId/Explorer/store'; import { LibraryIdParamsSchema } from '../app/route-schemas'; diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts index a7208aed5..997fb4485 100644 --- a/interface/hooks/useShortcut.ts +++ b/interface/hooks/useShortcut.ts @@ -37,6 +37,10 @@ const shortcuts = { macOS: ['Meta', 'KeyJ'], all: ['Control', 'KeyJ'] }, + toggleTagAssignMode: { + macOS: ['Meta', 'Alt', 'KeyT'], + all: ['Control', 'Alt', 'KeyT'] + }, navBackwardHistory: { macOS: ['Meta', '['], all: ['Control', '['] diff --git a/interface/locales/ar/common.json b/interface/locales/ar/common.json index 2cca3cd02..79b32e7a4 100644 --- a/interface/locales/ar/common.json +++ b/interface/locales/ar/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Connect your cloud accounts to Spacedrive.", "connect_device": "Connect a device", "connect_device_description": "Spacedrive works best on all your devices.", + "connect_library_to_cloud": "قم بتوصيل المكتبة بـ Spacedrive Cloud", "connected": "متصل", + "connecting_library_to_cloud": "جارٍ توصيل المكتبة بـ Spacedrive Cloud...", "contacts": "جهات الاتصال", "contacts_description": "إدارة جهات الاتصال الخاصة بك في Spacedrive.", "contains": "contains", @@ -258,14 +260,16 @@ "feedback_login_description": "تسجيل الدخول يسمح لنا بالرد على ملاحظاتك", "feedback_placeholder": "ملاحظاتك...", "feedback_toast_error_message": "حدث خطأ أثناء إرسال ملاحظاتك. يرجى المحاولة مرة أخرى.", - "file": "file", "file_already_exist_in_this_location": "الملف موجود بالفعل في هذا الموقع", "file_directory_name": "اسم الملف/الدليل", "file_extension_description": "امتداد الملف (على سبيل المثال، .mp4، .jpg، .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "قواعد فهرسة الملفات", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "ملفات", + "file_zero": "ملفات", + "files_many": "files", "filter": "Filter", "filters": "مرشحات", "flash": "فلاش", diff --git a/interface/locales/be/common.json b/interface/locales/be/common.json index db3c1e119..a068dffad 100644 --- a/interface/locales/be/common.json +++ b/interface/locales/be/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Падключыце воблачныя акаўнты да Spacedrive.", "connect_device": "Падключыце прыладу", "connect_device_description": "Spacedrive лепш за ўсё працуе пры выкарыстанні на ўсіх вашых прыладах.", + "connect_library_to_cloud": "Падключыце бібліятэку да Spacedrive Cloud", "connected": "Падключана", + "connecting_library_to_cloud": "Падключэнне бібліятэкі да Spacedrive Cloud...", "contacts": "Кантакты", "contacts_description": "Кіруйце кантактамі ў Spacedrive.", "contains": "змяшчае", @@ -258,14 +260,16 @@ "feedback_login_description": "Уваход у сістэму дазваляе нам адказваць на ваш фідбэк", "feedback_placeholder": "Ваш фідбэк...", "feedback_toast_error_message": "Пры адпраўленні вашага фідбэку адбылася абмыла. Калі ласка, паспрабуйце яшчэ раз.", - "file": "файл", "file_already_exist_in_this_location": "Файл ужо існуе ў гэтай лакацыі", "file_directory_name": "Імя файла/папкі", "file_extension_description": "Пашырэнне файла (напрыклад, .mp4, .jpg, .txt)", "file_from": "Файл {{file}} з {{name}}", "file_indexing_rules": "Правілы індэксацыі файлаў", + "file_one": "файл", "file_picker_not_supported": "Сістэма выбару файлаў не падтрымліваецца на гэтай платформе", - "files": "файлы", + "file_two": "файлы", + "file_zero": "файлы", + "files_many": "файлы", "filter": "Фільтр", "filters": "Фільтры", "flash": "Успышка", diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json index 4c473f773..804ef8818 100644 --- a/interface/locales/de/common.json +++ b/interface/locales/de/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Verbinde deine Cloud-Konten mit Spacedrive.", "connect_device": "Ein Gerät anschließen", "connect_device_description": "Spacedrive funktioniert am besten auf all deinen Geräten.", + "connect_library_to_cloud": "Bibliothek mit Spacedrive Cloud verbinden", "connected": "Verbunden", + "connecting_library_to_cloud": "Bibliothek mit Spacedrive Cloud verbinden …", "contacts": "Kontakte", "contacts_description": "Verwalte deine Kontakte in Spacedrive.", "contains": "enthält", @@ -258,14 +260,16 @@ "feedback_login_description": "Die Anmeldung ermöglicht es uns, auf Ihr Feedback zu antworten", "feedback_placeholder": "Ihr Feedback...", "feedback_toast_error_message": "Beim Senden deines Feedbacks ist ein Fehler aufgetreten. Bitte versuche es erneut.", - "file": "file", "file_already_exist_in_this_location": "Die Datei existiert bereits an diesem Speicherort", "file_directory_name": "Datei-/Verzeichnisname", "file_extension_description": "Dateierweiterung (z. B. .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Dateiindizierungsregeln", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "Dateien", + "file_zero": "Dateien", + "files_many": "files", "filter": "Filter", "filters": "Filter", "flash": "Blitz", diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 5d0b0c7f3..274ea90dd 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -90,7 +90,9 @@ "connect_cloud_description": "Connect your cloud accounts to Spacedrive.", "connect_device": "Connect a device", "connect_device_description": "Spacedrive works best on all your devices.", + "connect_library_to_cloud": "Connect library to Spacedrive Cloud", "connected": "Connected", + "connecting_library_to_cloud": "Connecting library to Spacedrive Cloud...", "contacts": "Contacts", "contacts_description": "Manage your contacts in Spacedrive.", "contains": "contains", @@ -214,6 +216,7 @@ "error": "Error", "error_loading_original_file": "Error loading original file", "error_message": "Error: {{error}}.", + "error_unknown": "An unknown error occurred.", "executable": "Executable", "expand": "Expand", "explorer": "Explorer", @@ -260,14 +263,17 @@ "feedback_login_description": "Logging in allows us to respond to your feedback", "feedback_placeholder": "Your feedback...", "feedback_toast_error_message": "There was an error submitting your feedback. Please try again.", - "file": "file", "file_already_exist_in_this_location": "File already exists in this location", "file_directory_name": "File/Directory name", "file_extension_description": "File extension (e.g., .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "File indexing rules", + "file_many": "files", + "file_one": "file", + "file_other": "files", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "files", + "file_zero": "files", "filter": "Filter", "filters": "Filters", "flash": "Flash", @@ -356,6 +362,7 @@ "join_discord": "Join Discord", "join_library": "Join a Library", "join_library_description": "Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data.", + "joining": "Joining", "key": "Key", "key_manager": "Key Manager", "key_manager_description": "Create encryption keys, mount and unmount your keys to see files decrypted on the fly.", @@ -597,6 +604,7 @@ "security_description": "Keep your client safe.", "see_less": "See less", "see_more": "See more", + "select_library": "Select a Cloud Library", "send": "Send", "send_report": "Send Report", "sender": "Sender", @@ -666,6 +674,11 @@ "tag_one": "Tag", "tag_other": "Tags", "tags": "Tags", + "tags_bulk_assigned": "Assigned tag \"{{tag_name}}\" to {{file_count}} $t(file, { \"count\": {{file_count}} }).", + "tags_bulk_failed_with_tag": "Could not assign tag \"{{tag_name}}\" to {{file_count}} $t(file, { \"count\": {{file_count}} }): {{error_message}}", + "tags_bulk_failed_without_tag": "Could not tag {{file_count}} $t(file, { \"count\": {{file_count}} }): {{error_message}}", + "tags_bulk_instructions": "Select one or more files and press a key to assign the corresponding tag.", + "tags_bulk_mode_active": "Tag assign mode is enabled.", "tags_description": "Manage your tags.", "tags_notice_message": "No items assigned to this tag.", "task": "task", diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json index 9152389f7..c1cfc7beb 100644 --- a/interface/locales/es/common.json +++ b/interface/locales/es/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Conecta tus cuentas en la nube a Spacedrive.", "connect_device": "Conectar un dispositivo", "connect_device_description": "Spacedrive funciona mejor en todos tus dispositivos.", + "connect_library_to_cloud": "Conecte la biblioteca a Spacedrive Cloud", "connected": "Conectado", + "connecting_library_to_cloud": "Conectando la biblioteca a Spacedrive Cloud...", "contacts": "Contactos", "contacts_description": "Administra tus contactos en Spacedrive.", "contains": "contiene", @@ -258,14 +260,16 @@ "feedback_login_description": "Iniciar sesión nos permite responder a tu retroalimentación", "feedback_placeholder": "Tu retroalimentación...", "feedback_toast_error_message": "Hubo un error al enviar tu retroalimentación. Por favor, inténtalo de nuevo.", - "file": "file", "file_already_exist_in_this_location": "El archivo ya existe en esta ubicación", "file_directory_name": "Nombre de archivo/directorio", "file_extension_description": "Extensión de archivo (por ejemplo, .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Reglas de indexación de archivos", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "archivos", + "file_zero": "archivos", + "files_many": "files", "filter": "Filtro", "filters": "Filtros", "flash": "Destello", diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json index 4a741c9b0..fcff764bd 100644 --- a/interface/locales/fr/common.json +++ b/interface/locales/fr/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Connectez vos comptes cloud à Spacedrive.", "connect_device": "Connecter un appareil", "connect_device_description": "Spacedrive fonctionne mieux sur tous vos appareils.", + "connect_library_to_cloud": "Connecter la bibliothèque à Spacedrive Cloud", "connected": "Connecté", + "connecting_library_to_cloud": "Connexion de la bibliothèque à Spacedrive Cloud...", "contacts": "Contacts", "contacts_description": "Gérez vos contacts dans Spacedrive.", "contains": "contient", @@ -258,14 +260,16 @@ "feedback_login_description": "La connexion nous permet de répondre à votre retour d'information", "feedback_placeholder": "Votre retour d'information...", "feedback_toast_error_message": "Une erreur s'est produite lors de l'envoi de votre retour d'information. Veuillez réessayer.", - "file": "file", "file_already_exist_in_this_location": "Le fichier existe déjà à cet emplacement", "file_directory_name": "Nom du fichier/répertoire", "file_extension_description": "Extension de fichier (par exemple, .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Règles d'indexation des fichiers", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "des dossiers", + "file_zero": "des dossiers", + "files_many": "files", "filter": "Filtre", "filters": "Filtres", "flash": "Éclair", diff --git a/interface/locales/it/common.json b/interface/locales/it/common.json index 137d38728..ecf7f3b21 100644 --- a/interface/locales/it/common.json +++ b/interface/locales/it/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Collegate i vostri account cloud a Spacedrive.", "connect_device": "Collegare un dispositivo", "connect_device_description": "Spacedrive funziona al meglio su tutti i dispositivi.", + "connect_library_to_cloud": "Connetti la libreria a Spacedrive Cloud", "connected": "Connesso", + "connecting_library_to_cloud": "Collegamento della libreria a Spacedrive Cloud...", "contacts": "Contatti", "contacts_description": "Gestisci i tuoi contatti su Spacedrive.", "contains": "contiene", @@ -258,14 +260,16 @@ "feedback_login_description": "Effettuando l'accesso possiamo rispondere al tuo feedback", "feedback_placeholder": "Il tuo feedback...", "feedback_toast_error_message": "Si è verificato un errore durante l'invio del tuo feedback. Riprova.", - "file": "file", "file_already_exist_in_this_location": "Il file esiste già in questa posizione", "file_directory_name": "Nome del file/directory", "file_extension_description": "Estensione del file (ad esempio, .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Regole di indicizzazione dei file", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "File", + "file_zero": "File", + "files_many": "files", "filter": "Filtro", "filters": "Filtri", "flash": "Veloce", diff --git a/interface/locales/ja/common.json b/interface/locales/ja/common.json index 51f6cf0b3..de87c8d9c 100644 --- a/interface/locales/ja/common.json +++ b/interface/locales/ja/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "クラウドアカウントをSpacedriveに接続する。", "connect_device": "デバイスを接続する", "connect_device_description": "Spacedriveはすべてのデバイスで最適に機能します。", + "connect_library_to_cloud": "ライブラリをSpacedrive Cloudに接続する", "connected": "接続中", + "connecting_library_to_cloud": "ライブラリを Spacedrive Cloud に接続しています...", "contacts": "連絡先", "contacts_description": "Spacedriveで連絡先を管理。", "contains": "が次を含む", @@ -258,14 +260,16 @@ "feedback_login_description": "ログインすることで、フィードバックを送ることができます。", "feedback_placeholder": "フィードバックを入力...", "feedback_toast_error_message": "フィードバックの送信中にエラーが発生しました。もう一度お試しください。", - "file": "ファイル", "file_already_exist_in_this_location": "このファイルは既にこのロケーションに存在します", "file_directory_name": "ファイル/ディレクトリ名", "file_extension_description": "ファイル拡張子 (例: .mp4、.jpg、.txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "ファイルのインデックス化ルール", + "file_one": "ファイル", "file_picker_not_supported": "このプラットフォームではファイルピッカーはサポートされていません", - "files": "ファイル", + "file_two": "ファイル", + "file_zero": "ファイル", + "files_many": "ファイル", "filter": "フィルター", "filters": "フィルター", "flash": "閃光", diff --git a/interface/locales/nl/common.json b/interface/locales/nl/common.json index 74a0be05a..3554aac2f 100644 --- a/interface/locales/nl/common.json +++ b/interface/locales/nl/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Verbind uw cloudaccounts met Spacedrive.", "connect_device": "Een apparaat aansluiten", "connect_device_description": "Spacedrive werkt het beste op al uw apparaten.", + "connect_library_to_cloud": "Verbind de bibliotheek met Spacedrive Cloud", "connected": "Verbonden", + "connecting_library_to_cloud": "Bibliotheek verbinden met Spacedrive Cloud...", "contacts": "Contacten", "contacts_description": "Beheer je contacten in Spacedrive.", "contains": "bevatten", @@ -258,14 +260,16 @@ "feedback_login_description": "Inloggen stelt ons in staat om te reageren op jouw feedback", "feedback_placeholder": "Jouw feedback...", "feedback_toast_error_message": "Er is een fout opgetreden bij het verzenden van je feedback. Probeer het opnieuw.", - "file": "file", "file_already_exist_in_this_location": "Bestand bestaat al op deze locatie", "file_directory_name": "Bestands-/mapnaam", "file_extension_description": "Bestandsextensie (bijvoorbeeld .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Bestand indexeringsregels", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "bestanden", + "file_zero": "bestanden", + "files_many": "files", "filter": "Filter", "filters": "Filters", "flash": "Flash", diff --git a/interface/locales/ru/common.json b/interface/locales/ru/common.json index a4fc6c6a4..608371df7 100644 --- a/interface/locales/ru/common.json +++ b/interface/locales/ru/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Подключите облачные аккаунты к Spacedrive.", "connect_device": "Подключите устройство", "connect_device_description": "Spacedrive лучше всего работает при использовании на всех ваших устройствах.", + "connect_library_to_cloud": "Подключите библиотеку к Spacedrive Cloud", "connected": "Подключено", + "connecting_library_to_cloud": "Подключение библиотеки к Spacedrive Cloud...", "contacts": "Контакты", "contacts_description": "Управляйте контактами в Spacedrive.", "contains": "содержит", @@ -258,14 +260,16 @@ "feedback_login_description": "Вход в систему позволяет нам отвечать на ваш фидбек", "feedback_placeholder": "Ваш фидбек...", "feedback_toast_error_message": "При отправке вашего фидбека произошла ошибка. Пожалуйста, попробуйте еще раз.", - "file": "файл", "file_already_exist_in_this_location": "Файл уже существует в этой локации", "file_directory_name": "Имя файла/папки", "file_extension_description": "Расширение файла (например, .mp4, .jpg, .txt)", "file_from": "Файл {{file}} из {{name}}", "file_indexing_rules": "Правила индексации файлов", + "file_one": "файл", "file_picker_not_supported": "Система выбора файлов не поддерживается на этой платформе", - "files": "файлы", + "file_two": "файлы", + "file_zero": "файлы", + "files_many": "файлы", "filter": "Фильтр", "filters": "Фильтры", "flash": "Вспышка", diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json index a132d005d..f15564171 100644 --- a/interface/locales/tr/common.json +++ b/interface/locales/tr/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "Bulut hesaplarınızı Spacedrive'a bağlayın.", "connect_device": "Bir cihaz bağlayın", "connect_device_description": "Spacedrive tüm cihazlarınızda en iyi şekilde çalışır.", + "connect_library_to_cloud": "Kitaplığı Spacedrive Cloud'a bağlayın", "connected": "Bağlı", + "connecting_library_to_cloud": "Kitaplık Spacedrive Cloud'a bağlanıyor...", "contacts": "Kişiler", "contacts_description": "Kişilerinizi Spacedrive'da yönetin.", "contains": "içerir", @@ -258,14 +260,16 @@ "feedback_login_description": "Giriş yapmak, geribildiriminize yanıt vermemizi sağlar", "feedback_placeholder": "Geribildiriminiz...", "feedback_toast_error_message": "Geribildiriminizi gönderirken bir hata oluştu. Lütfen tekrar deneyin.", - "file": "file", "file_already_exist_in_this_location": "Dosya bu konumda zaten mevcut", "file_directory_name": "Dosya/Dizin adı", "file_extension_description": "Dosya uzantısı (ör. .mp4, .jpg, .txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "Dosya İndeksleme Kuralları", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "Dosyalar", + "file_zero": "Dosyalar", + "files_many": "files", "filter": "Filtre", "filters": "Filtreler", "flash": "Flaş", diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json index 22b448f28..52ddf8589 100644 --- a/interface/locales/zh-CN/common.json +++ b/interface/locales/zh-CN/common.json @@ -1,6 +1,6 @@ { "about": "关于", - "about_vision_text": "我们很多人拥有不止云账户,磁盘没有备份,数据也有丢失的风险。我们依赖于像 Google 照片、iCloud 这样的云服务,但是它们容量有限,且互操作性几乎为零,云服务和操作系统之间也无法协作。我们的照片不应该困在一种生态系统中,也不应该当广告数据来收割。它们应该与操作系统无关、永久保存、由我们自己所有。我们创造的数据是我们的遗产,它们的寿命会比我们还要长——既然这些数据定义了我们的生活,开源技术是确保我们对这些数据拥有绝对控制权的唯一方式,它的规模没有限制。", + "about_vision_text": "我们很多人拥有不止一个云账户,磁盘没有备份,数据也有丢失的风险。我们依赖于像 Google 相册、iCloud 这样的云服务,但是它们容量有限,且互操作性几乎为零,云服务和操作系统之间也无法协作。我们的照片不应该困在单一一种生态系统中,也不应该被用于广告营销而被收割。它们应该与操作系统无关、永久保存、由我们自己所有。我们创造的数据是我们的遗产,它们的寿命会比我们还要长。开源技术是唯一能确保我们绝对控制定义我们生活的数据,并在无限规模上保留这些定义了我们的生活的数据的方式。", "about_vision_title": "项目远景", "accept": "接受", "accept_files": "Accept files", @@ -18,16 +18,16 @@ "add_location_tooltip": "将路径添加为索引", "add_locations": "添加位置", "add_tag": "添加标签", - "added_location": "Added Location {{name}}", - "adding_location": "Adding Location {{name}}", - "advanced": "先进的", + "added_location": "已添加位置 {{name}}", + "adding_location": "添加位置 {{name}}", + "advanced": "高级", "advanced_settings": "高级设置", - "album": "Album", - "alias": "Alias", + "album": "相册", + "alias": "别名", "all_jobs_have_been_cleared": "所有任务已清除。", "alpha_release_description": "感谢试用 Spacedrive。现在 Spacedrive 处于 Alpha 发布阶段,展示了激动人心的新功能。作为初始版本,它可能包含一些错误。我们恳请您在我们的 Discord 频道上反馈遇到的任何问题,您宝贵的反馈将有助于极大增强用户体验。", "alpha_release_title": "Alpha 版本", - "app_crashed": "APP CRASHED", + "app_crashed": "应用程序崩溃了", "app_crashed_description": "We're past the event horizon...", "appearance": "外观", "appearance_description": "调整客户端的外观。", @@ -39,9 +39,9 @@ "ascending": "上升", "ask_spacedrive": "询问 Spacedrive", "assign_tag": "分配标签", - "audio": "Audio", + "audio": "音频", "audio_preview_not_supported": "不支持音频预览。", - "auto": "汽车", + "auto": "自动", "back": "返回", "backfill_sync": "回填同步操作", "backfill_sync_description": "库暂停直至回填完成", @@ -50,7 +50,7 @@ "bitrate": "比特率", "blur_effects": "模糊效果", "blur_effects_description": "某些组件将应用模糊效果。", - "book": "book", + "book": "书籍", "cancel": "取消", "cancel_selection": "取消选择", "canceled": "取消", @@ -82,14 +82,16 @@ "completed": "完全的", "completed_with_errors": "已完成但有错误", "compress": "压缩", - "config": "Config", - "configure_location": "配置位置", - "confirm": "Confirm", + "config": "配置文件", + "configure_location": "配置文件位置", + "confirm": "确认", "connect_cloud": "连接云", "connect_cloud_description": "将您的云帐户连接到 Spacedrive。", "connect_device": "连接设备", "connect_device_description": "Spacedrive 在您的所有设备上都能发挥最佳效果。", + "connect_library_to_cloud": "将图书馆连接到 Spacedrive Cloud", "connected": "已连接", + "connecting_library_to_cloud": "将图书馆连接到 Spacedrive Cloud...", "contacts": "联系人", "contacts_description": "在 Spacedrive 中管理您的联系人。", "contains": "包含", @@ -126,7 +128,7 @@ "cut": "剪切", "cut_object": "剪切对象", "cut_success": "剪切项目", - "dark": "Dark", + "dark": "暗色", "data_folder": "数据文件夹", "database": "Database", "date": "日期", @@ -151,10 +153,10 @@ "delete_location_description": "删除位置时,Spacedrive 会从数据库中移除所有与之相关的文件,但是不会删除文件本身。", "delete_object": "删除对象", "delete_rule": "删除规则", - "delete_rule_confirmation": "Are you sure you want to delete this rule?", + "delete_rule_confirmation": "您确定要删除这个规则吗?", "delete_tag": "删除标签", - "delete_tag_description": "您确定要删除这个标签吗?此操作不能撤销,打过标签的文件将会取消标签。", - "delete_warning": "警告:这将永久删除您的{{type}},我们目前还没有回收站…", + "delete_tag_description": "您确定要删除这个标签吗?此操作不能撤销,打过标签的文件将会丢失标签。", + "delete_warning": "警告:这将永久删除您的{{type}},我们目前还没有回收站……", "descending": "降序", "description": "描述", "deselect": "取消选择", @@ -199,10 +201,10 @@ "enabled": "启用", "encrypt": "加密", "encrypt_library": "加密库", - "encrypt_library_coming_soon": "库加密即将推出", + "encrypt_library_coming_soon": "资料库加密即将推出", "encrypt_library_description": "为这个库启用加密,这只会加密Spacedrive数据库,不会加密文件本身。", - "encrypted": "Encrypted", - "ends_with": "以。。结束", + "encrypted": "已加密", + "ends_with": "以... 结束", "ephemeral_notice_browse": "直接从您的设备浏览您的文件和文件夹。", "ephemeral_notice_consider_indexing": "考虑索引您本地的位置,以获得更快和更高效的浏览。", "equals": "是", @@ -258,14 +260,16 @@ "feedback_login_description": "登录使我们能够回复您的反馈", "feedback_placeholder": "您的反馈...", "feedback_toast_error_message": "提交反馈时出错,请重试。", - "file": "file", "file_already_exist_in_this_location": "文件已存在于此位置", "file_directory_name": "文件/目录名称", "file_extension_description": "文件扩展名(例如 .mp4、.jpg、.txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "文件索引规则", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "文件", + "file_zero": "文件", + "files_many": "files", "filter": "筛选", "filters": "过滤器", "flash": "闪光", @@ -333,30 +337,30 @@ "invalid_glob": "无效的全局变量", "invalid_name": "名称无效", "invalid_path": "路径无效", - "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", - "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", + "ipv4_ipv6_listeners_error": "创建 IPv4 和 IPv6 监听器出错。请检查防火墙设置!", + "ipv4_listeners_error": "创建 IPv4 监听器出错。请检查防火墙设置!", "ipv6": "IPv6网络", "ipv6_description": "允许使用 IPv6 网络进行点对点通信", - "ipv6_listeners_error": "Error creating the IPv6 listeners. Please check your firewall settings!", + "ipv6_listeners_error": "创建 IPv6 监听器时出错。请检查防火墙设置!", "is": "是", "is_not": "不是", "item": "item", "item_size": "项目大小", - "items": "items", + "items": "项目", "job_error_description": "作业已完成,但有错误。\n请参阅下面的错误日志以获取更多信息。\n如果您需要帮助,请联系支持人员并提供此错误。", "job_has_been_canceled": "作业已取消。", "job_has_been_paused": "作业已暂停。", "job_has_been_removed": "作业已移除。", "job_has_been_resumed": "作业已恢复。", "join": "加入", - "join_discord": "加入Discord", - "join_library": "加入一个库", + "join_discord": "加入 Discord", + "join_library": "加入一个资料库", "join_library_description": "库是一个安全的,设备上的数据库。您的文件保持原位,库对其进行目录编制并存储所有Spacedrive相关数据。", "key": "键位", "key_manager": "密钥管理器", "key_manager_description": "创建加密密钥,挂载和卸载密钥以即时查看解密文件。", "keybinds": "快捷键", - "keybinds_description": "查看和管理客户端键绑定", + "keybinds_description": "查看和管理客户端快捷键", "keys": "密钥", "kilometers": "千米", "kind": "种类", @@ -379,8 +383,8 @@ "library_overview": "库概览", "library_settings": "库设置", "library_settings_description": "与当前活动库相关的一般设置。", - "light": "光", - "link": "Link", + "light": "浅色", + "link": "链接", "list_view": "列表视图", "list_view_notice_description": "通过列表视图轻松导航您的文件和文件夹。这种视图以简单、有组织的列表形式显示文件,让您能够快速定位和访问所需文件。", "loading": "正在加载", @@ -451,7 +455,7 @@ "network_settings_advanced_description": "有关当前网络设置的高级信息。", "network_settings_description": "与网络和连接相关的设置。", "networking": "网络", - "networking_error": "Error starting up networking!", + "networking_error": "网络启动错误!", "networking_port": "网络端口", "networking_port_description": "Spacedrive 点对点网络通信使用的端口。除非您有防火墙来限制,否则应保持此项禁用。不要在互联网上暴露自己!", "new": "新的", @@ -462,7 +466,7 @@ "new_tab": "新标签", "new_tag": "新标签", "new_update_available": "新版本可用!", - "no_apps_available": "No apps available", + "no_apps_available": "没有可用的应用程序", "no_favorite_items": "没有最喜欢的物品", "no_git_files": "没有 Git 文件", "no_hidden_files": "没有隐藏文件", @@ -494,21 +498,21 @@ "open": "打开", "open_file": "打开文件", "open_in_new_tab": "在新标签页中打开", - "open_logs": "Open Logs", + "open_logs": "查看日志", "open_new_location_once_added": "添加新位置后立即打开", "open_new_tab": "打开新标签页", "open_object": "打开对象", "open_object_from_quick_preview_in_native_file_manager": "在本机文件管理器中从快速预览中打开对象", "open_settings": "打开设置", "open_with": "打开方式", - "opening_trash": "Opening Trash", + "opening_trash": "打开回收站", "or": "或", "overview": "概览", "p2p_visibility": "P2P 可见性", "p2p_visibility_contacts_only": "仅限联系人", "p2p_visibility_description": "配置谁可以看到您的 Spacedrive 安装。", - "p2p_visibility_disabled": "残疾人", - "p2p_visibility_everyone": "每个人", + "p2p_visibility_disabled": "关闭", + "p2p_visibility_everyone": "所有人", "package": "Package", "page": "页面", "page_shortcut_description": "应用程序中的不同页面", @@ -526,8 +530,8 @@ "paused": "已暂停", "peers": "个端点", "people": "人们", - "pin": "别针", - "please_select_emoji": "Please select an emoji", + "pin": "Pin", + "please_select_emoji": "请选择一个表情", "prefix_a": "a", "preview_media_bytes": "预览媒体", "preview_media_bytes_description": "所有预览媒体文件(例如缩略图)的总大小。", @@ -535,7 +539,7 @@ "privacy_description": "Spacedrive是为隐私而构建的,这就是为什么我们是开源的,以本地优先。因此,我们会非常明确地告诉您与我们分享了什么数据。", "queued": "排队", "quick_preview": "快速预览", - "quick_rescan_started": "Quick rescan started", + "quick_rescan_started": "正在快速重新扫描目录。", "quick_view": "快速查看", "random": "随机的", "receiver": "接收者", @@ -563,10 +567,10 @@ "rescan_directory": "重新扫描目录", "rescan_location": "重新扫描位置", "reset": "重置", - "reset_and_quit": "Reset & Quit App", - "reset_confirmation": "Are you sure you want to reset Spacedrive? Your database will be deleted.", - "reset_to_continue": "We detected you may have created your library with an older version of Spacedrive. Please reset it to continue using the app!", - "reset_warning": "YOU WILL LOSE ANY EXISTING SPACEDRIVE DATA!", + "reset_and_quit": "重置并退出应用程序", + "reset_confirmation": "您确定要重置 Spacedrive 吗?您的数据库将被删除。", + "reset_to_continue": "我们检测到您可能使用旧版本的 Spacedrive 创建了您的资料库。请重置以继续使用应用程序!", + "reset_warning": "您会丢失现有的所有 Spacedrive数据!", "resolution": "解决", "resources": "资源", "restore": "恢复", @@ -574,14 +578,14 @@ "retry": "重试", "reveal_in_native_file_manager": "在本机文件管理器中显示", "revel_in_browser": "在{{browser}}中显示", - "rules": "Rules", + "rules": "规则", "running": "运行中", "save": "保存", "save_changes": "保存更改", "save_search": "保存搜索", - "save_spacedrop": "Save Spacedrop", + "save_spacedrop": "保存 Spacedrop", "saved_searches": "保存的搜索", - "screenshot": "Screenshot", + "screenshot": "屏幕截图", "search": "搜索", "search_extensions": "搜索扩展", "search_for_files_and_actions": "搜索文件和操作...", @@ -589,10 +593,10 @@ "secure_delete": "安全删除", "security": "安全", "security_description": "确保您的客户端安全。", - "see_less": "See less", - "see_more": "See more", + "see_less": "更少", + "see_more": "更多", "send": "发送", - "send_report": "Send Report", + "send_report": "发送报告", "sender": "发件人", "sender_description": "此过程将同步操作发送到 Spacedrive Cloud。", "settings": "设置", @@ -601,7 +605,7 @@ "share_anonymous_usage": "分享匿名使用情况", "share_anonymous_usage_description": "分享完全匿名的遥测数据,帮助开发者改进应用程序", "share_bare_minimum": "分享最基本信息", - "share_bare_minimum_description": "只分享我是Spacedrive的活跃用户和一些技术细节", + "share_bare_minimum_description": "只分享我是 Spacedrive 的活跃用户和一些技术细节", "sharing": "共享", "sharing_description": "管理有权访问您的库的人。", "show_details": "显示详情", @@ -612,16 +616,16 @@ "show_slider": "显示滑块", "show_tags": "显示标签", "size": "大小", - "size_b": "乙", - "size_bs": "乙", - "size_gb": "国标", - "size_gbs": "GB", - "size_kb": "千字节", - "size_kbs": "千字节", + "size_b": "B", + "size_bs": "Bs", + "size_gb": "GB", + "size_gbs": "GBs", + "size_kb": "kB", + "size_kbs": "kBs", "size_mb": "MB", "size_mbs": "MB", - "size_tb": "结核病", - "size_tbs": "TB", + "size_tb": "TB", + "size_tbs": "TBs", "skip_login": "跳过登录", "software": "软件", "sort_by": "排序依据", @@ -633,16 +637,16 @@ "spacedrop_already_progress": "Spacedrop 已在进行中", "spacedrop_contacts_only": "仅限联系人", "spacedrop_description": "与在您的网络上运行 Spacedrive 的设备即时共享。", - "spacedrop_disabled": "残疾人", - "spacedrop_everyone": "每个人", + "spacedrop_disabled": "关闭", + "spacedrop_everyone": "所有人", "spacedrop_rejected": "Spacedrop 被拒绝", "square_thumbnails": "方形缩略图", "star_on_github": "在 GitHub 上送一个 star", "start": "开始", "starting": "开始...", - "starts_with": "以。。开始", + "starts_with": "以...开始", "stop": "停止", - "stopping": "停止...", + "stopping": "正在停止...", "success": "成功", "support": "支持", "switch_to_grid_view": "切换到网格视图", @@ -652,21 +656,21 @@ "switch_to_previous_tab": "切换到上一个标签页", "sync": "同步", "syncPreviewMedia_label": "将此位置的预览媒体与您的设备同步", - "sync_description": "管理Spacedrive的同步方式。", - "sync_with_library": "与库同步", - "sync_with_library_description": "如果启用,您的键绑定将与库同步,否则它们只适用于此客户端。", + "sync_description": "管理 Spacedrive 的同步方式。", + "sync_with_library": "与资料库同步", + "sync_with_library_description": "如果启用,您的快捷方式将与资料库同步,否则它们只适用于此客户端。", "system": "系统", "tag": "标签", "tag_other": "标签", "tags": "标签", "tags_description": "管理您的标签。", "tags_notice_message": "没有项目分配给该标签。", - "task": "task", - "task_other": "tasks", + "task": "任务", + "task_other": "任务", "telemetry_description": "启用以向开发者提供详细的使用情况和遥测数据来改善应用程序。禁用则将只发送基本数据:您的活动状态、应用版本、应用内核版本以及平台(例如移动端、web 端或桌面端)。", "telemetry_title": "共享额外的遥测和使用数据", "temperature": "温度", - "text": "Text", + "text": "文本", "text_file": "文本文件", "text_size": "文字大小", "thank_you_for_your_feedback": "感谢您的反馈!", @@ -685,42 +689,42 @@ "toggle_sidebar": "切换侧边栏", "tools": "工具", "total_bytes_capacity": "总容量", - "total_bytes_capacity_description": "连接到库的所有节点的总容量。 在 Alpha 期间可能会显示不正确的值。", + "total_bytes_capacity_description": "连接到资料库的所有节点的总容量。 在 Alpha 期间可能会显示不正确的值。", "total_bytes_free": "可用空间", - "total_bytes_free_description": "连接到库的所有节点上的可用空间。", + "total_bytes_free_description": "连接到资料库的所有节点上的可用空间。", "total_bytes_used": "总使用空间", - "total_bytes_used_description": "连接到库的所有节点上使用的总空间。", - "trash": "垃圾", + "total_bytes_used_description": "连接到资料库的所有节点上使用的总空间。", + "trash": "回收站", "type": "类型", "ui_animations": "用户界面动画", "ui_animations_description": "打开和关闭时对话框和其他用户界面元素将产生动画效果。", - "unknown": "Unknown", + "unknown": "未知", "unnamed_location": "未命名位置", "update": "更新", "update_downloaded": "更新已下载。重新启动 Spacedrive 以安装", - "updated_successfully": "成功更新,您当前使用的是版本 {{version}}", - "uploaded_file": "Uploaded file!", + "updated_successfully": "成功更新,您当前使用的版本是 {{version}}", + "uploaded_file": "文件成功上传!", "usage": "使用情况", "usage_description": "您的库使用情况和硬件信息", - "vaccum": "真空", - "vaccum_library": "真空库", + "vaccum": "清理", + "vaccum_library": "清理库", "vaccum_library_description": "重新打包数据库以释放不必要的空间。", "value": "值", "value_required": "所需值", "version": "版本 {{version}}", - "video": "Video", + "video": "视频", "video_preview_not_supported": "不支持视频预览。", "view_changes": "查看更改", "want_to_do_this_later": "想稍后再做吗?", - "web_page_archive": "Web Page Archive", - "website": "网站", + "web_page_archive": "网页归档", + "website": "网页", "widget": "小部件", - "with_descendants": "与后代", + "with_descendants": "子目录", "your_account": "您的账户", - "your_account_description": "Spacedrive账号和信息。", + "your_account_description": "Spacedrive 账号和信息。", "your_local_network": "您的本地网络", "your_privacy": "您的隐私", - "zoom": "飞涨", + "zoom": "缩放", "zoom_in": "放大", "zoom_out": "缩小" -} +} \ No newline at end of file diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json index 9ba476984..8abf6b03e 100644 --- a/interface/locales/zh-TW/common.json +++ b/interface/locales/zh-TW/common.json @@ -89,7 +89,9 @@ "connect_cloud_description": "將您的雲端帳戶連接到 Spacedrive。", "connect_device": "連接裝置", "connect_device_description": "Spacedrive 在您的所有裝置上都能發揮最佳效果。", + "connect_library_to_cloud": "將圖書館連接到 Spacedrive Cloud", "connected": "已連接", + "connecting_library_to_cloud": "正在將圖書館連接到 Spacedrive Cloud...", "contacts": "聯繫人", "contacts_description": "在Spacedrive中管理您的聯繫人。", "contains": "包含", @@ -258,14 +260,16 @@ "feedback_login_description": "登入可讓我們回應您的回饋", "feedback_placeholder": "您的回饋...", "feedback_toast_error_message": "提交回饋時發生錯誤。請重試。", - "file": "file", "file_already_exist_in_this_location": "該位置已存在該檔案", "file_directory_name": "檔案/目錄名稱", "file_extension_description": "檔案副檔名(例如 .mp4、.jpg、.txt)", "file_from": "File {{file}} from {{name}}", "file_indexing_rules": "文件索引規則", + "file_one": "file", "file_picker_not_supported": "File picker not supported on this platform", - "files": "files", + "file_two": "文件", + "file_zero": "文件", + "files_many": "files", "filter": "篩選", "filters": "篩選器", "flash": "閃光", diff --git a/interface/util/hardware.ts b/interface/util/hardware.ts index a4c37c20e..99d6691ce 100644 --- a/interface/util/hardware.ts +++ b/interface/util/hardware.ts @@ -8,6 +8,10 @@ export function hardwareModelToIcon(hardwareModel: HardwareModel) { return 'Laptop'; case 'MacStudio': return 'SilverBox'; + case 'IPhone': + return 'Mobile'; + case 'Android': + return 'MobileAndroid'; case 'MacMini': return 'MiniSilverBox'; case 'Other': diff --git a/package.json b/package.json index 421958aac..6622fbdaf 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint:fix": "turbo run lint -- --fix", "clean": "cargo clean; git clean -qfX .", "test-data": "./scripts/test-data.sh", - "i18n:sync": "npx i18next-locales-sync -p en -s $(find ./interface/locales -wholename '*/common.json' | awk -F'/' '$4 != \"en\" { ORS=\" \"; print $4 }') -l ./interface/locales" + "i18n:sync": "npx i18next-locales-sync -p en -s $(find ./interface/locales -wholename '*/common.json' | awk -F'/' '$4 != \"en\" { ORS=\" \"; print $4 }') -l ./interface/locales", + "autoformat": "./scripts/autoformat.sh" }, "pnpm": { "patchedDependencies": { diff --git a/packages/client/src/stores/auth.ts b/packages/client/src/stores/auth.ts index 2cdab08a7..d261fc845 100644 --- a/packages/client/src/stores/auth.ts +++ b/packages/client/src/stores/auth.ts @@ -41,7 +41,7 @@ function onError(error: string) { loginCallbacks.forEach((cb) => cb({ error })); } -export function login(config: ProviderConfig) { +export async function login(config: ProviderConfig) { if (store.state.status !== 'notLoggedIn') return; store.state = { status: 'loggingIn' }; @@ -86,10 +86,10 @@ export function login(config: ProviderConfig) { }); } -export function logout() { +export async function logout() { store.state = { status: 'loggingOut' }; - nonLibraryClient.mutation(['auth.logout']); - nonLibraryClient.query(['auth.me']); + await nonLibraryClient.mutation(['auth.logout']); + await nonLibraryClient.query(['auth.me']); store.state = { status: 'notLoggedIn' }; } diff --git a/packages/ui/src/Select.tsx b/packages/ui/src/Select.tsx index c6f959d2a..bed7b6d71 100644 --- a/packages/ui/src/Select.tsx +++ b/packages/ui/src/Select.tsx @@ -65,7 +65,7 @@ export const Select = forwardRef( - + {props.children} diff --git a/scripts/autoformat.sh b/scripts/autoformat.sh new file mode 100755 index 000000000..f335468ea --- /dev/null +++ b/scripts/autoformat.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -Eeumo pipefail + +has() { + command -v "$1" >/dev/null 2>&1 +} + +handle_exit() { + _exit=$? + set +e + trap '' SIGINT + trap - EXIT + if [ "$_exit" -ne 0 ]; then + git restore --staged . + git restore . + fi + exit "$_exit" +} + +cleanup() { + set +e + trap '' SIGINT + trap - EXIT + jobs -p | xargs kill -SIGTERM + git restore --staged . + git restore . + kill -- -$$ 2>/dev/null +} + +if ! has git pnpm; then + echo "Missing at on of the required dependencies: git, pnpm" >&2 + exit 1 +fi + +__dirname="$(CDPATH='' cd "$(dirname "$0")" && pwd -P)" + +# Change to the root directory of the repository +cd "$__dirname/.." + +if [ -n "$(git diff --name-only HEAD)" ] || [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "Uncommitted changes found. Please commit or stash your changes before running this script." >&2 + exit 1 +fi + +# Find the common ancestor of the current branch and main +if [ -n "${CI:-}" ]; then + ancestor="HEAD" +elif ! ancestor="$(git merge-base HEAD origin/main)"; then + echo "Failed to find the common ancestor of the current branch and main." >&2 + exit 1 +fi + +# Handle errors and cleanup after formating has started +trap 'handle_exit' EXIT +trap 'cleanup' SIGINT + +# Run the linter and formatter for frontend +# Use a background processes to avoid pnpm weird handling of CTRL+C +pnpm run -r lint --fix & +wait +pnpm run format & +wait + +if [ "${1:-}" != "only-frontend" ]; then + # Run clippy and formatter for backend + cargo clippy --fix --all --all-targets --all-features --allow-dirty --allow-staged + cargo fmt --all +fi + +# Add all fixes for changes made in this branch +git diff --cached --name-only "$ancestor" | xargs git add + +# Restore unrelated changes +git restore . diff --git a/turbo.json b/turbo.json index 65e1c39a0..ea9c9edd6 100644 --- a/turbo.json +++ b/turbo.json @@ -16,5 +16,5 @@ "cache": false } }, - "globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP"] + "globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP", "DEV"] }