From cc7c9d5793adb8b0b0348796bb32de90ffb85d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Vasconcellos?= Date: Mon, 21 Oct 2024 12:47:40 -0300 Subject: [PATCH 1/3] Improve file thumbnails and Quick Preview (+ some code clean-up and rust deps update) (#2758) * Update rspc, prisma-client-rust, axum and tanstack-query - Deleted some unused examples and fully commented out frontend code - Implement many changes required due to the updates - Update most rust dependencies * Re-enable p2p * Fix server * Auto format * Fix injected script format - Update some github actions - Update pnpm lock file * Fix devtools showing up when app opens - Fix million complaining about Sparkles component * Fix sd-server * Fix and improve thumbnails rendering - Fix core always saying a new thumbnail was generated even for files that it skiped thumbnail generation - Rewrite FileThumb and improve related components * Ignore tmp files when running prettier * Improve FileThumb component performance - Rework useExplorerDraggable and useExplorerItemData hooks due to reduce unecessary re-renders * More fixes for thumb component - A couple of minor performance improvements to frontend code * auto format * Fix Thumbnail and QuickPreview * Fix logic for when to show 'fail to load original' error message in QuickPreview - Updated prisma-client-rust, libp2p, tauri, tauri-specta, rspc and hyper * Fix type checking - Format scripts * Add script prettier config * Fix serde missing feature - Use rust-libp2p spacedrive fork again - Update rspc * Autoformat + fix pnpm lock * Fix thumbnail first load again * Autoformat * autoformat * Fix rust-libp2p fork url again? * Remove usePathsInfiniteQuery hook * Update tauri 2.0.6 --- .github/actions/setup-pnpm/action.yml | 4 +- .github/actions/setup-system/action.yml | 2 +- .prettierignore | 2 + Cargo.lock | Bin 286608 -> 275006 bytes Cargo.toml | 62 +- apps/desktop/package.json | 8 +- apps/desktop/src-tauri/Cargo.toml | 13 +- apps/desktop/src-tauri/src/tauri_plugins.rs | 94 +-- apps/desktop/src/patches.ts | 2 +- apps/mobile/modules/sd-core/src/index.ts | 6 +- apps/mobile/package.json | 10 +- .../src/components/browse/BrowseLocations.tsx | 3 +- .../src/components/drawer/DrawerLocations.tsx | 3 +- .../src/components/explorer/Explorer.tsx | 4 +- .../explorer/sections/FavoriteButton.tsx | 4 +- apps/mobile/src/components/job/JobGroup.tsx | 2 +- .../src/components/modal/AddTagModal.tsx | 4 +- .../components/modal/CreateLibraryModal.tsx | 2 +- .../components/modal/ImportLibraryModal.tsx | 4 +- .../src/components/modal/ImportModal.tsx | 2 +- .../confirmModals/DeleteLibraryModal.tsx | 4 +- .../confirmModals/DeleteLocationModal.tsx | 4 +- .../modal/confirmModals/DeleteTagModal.tsx | 4 +- .../modal/inspector/ActionsModal.tsx | 4 +- .../modal/inspector/RenameModal.tsx | 2 +- .../components/modal/tag/CreateTagModal.tsx | 2 +- .../components/modal/tag/UpdateTagModal.tsx | 6 +- .../src/components/overview/Devices.tsx | 4 +- .../src/components/overview/OverviewStats.tsx | 4 +- .../search/filters/SavedSearches.tsx | 2 +- apps/mobile/src/hooks/useFiltersSearch.ts | 3 +- apps/mobile/src/hooks/useSavedSearch.ts | 5 +- apps/mobile/src/screens/BackfillWaiting.tsx | 6 +- apps/mobile/src/screens/browse/Location.tsx | 7 +- apps/mobile/src/screens/search/Search.tsx | 7 +- .../library/CloudSettings/CloudSettings.tsx | 4 +- .../library/CloudSettings/Library.tsx | 2 +- .../settings/library/EditLocationSettings.tsx | 28 +- .../screens/settings/library/SyncSettings.tsx | 10 +- apps/mobile/src/stores/auth.ts | 2 +- apps/server/Cargo.toml | 13 +- apps/server/src/main.rs | 34 +- apps/web/package.json | 4 +- apps/web/src/patches.ts | 2 +- core/Cargo.toml | 9 +- core/crates/heavy-lifting/Cargo.toml | 2 +- .../media_processor/helpers/thumbnailer.rs | 23 +- .../src/media_processor/tasks/thumbnailer.rs | 19 +- core/crates/indexer-rules/Cargo.toml | 2 +- core/src/custom_uri/async_read_body.rs | 61 -- core/src/custom_uri/mod.rs | 28 +- core/src/custom_uri/serve_file.rs | 25 +- core/src/custom_uri/utils.rs | 23 +- core/src/p2p/operations/rspc.rs | 20 +- crates/ai/Cargo.toml | 2 +- crates/ffmpeg/src/thumbnailer.rs | 20 +- crates/p2p/Cargo.toml | 8 +- crates/prisma-cli/Cargo.toml | 4 +- crates/sync/example/Cargo.toml | 27 - crates/sync/example/README.md | 18 - .../sync/example/prisma/migrations/.gitkeep | 0 crates/sync/example/prisma/schema.prisma | 34 - crates/sync/example/src/api/mod.rs | 175 ----- crates/sync/example/src/main.rs | 45 -- crates/sync/example/src/utils.rs | 28 - crates/sync/example/web/.gitignore | 2 - crates/sync/example/web/README.md | 34 - crates/sync/example/web/index.html | 15 - crates/sync/example/web/package.json | 24 - crates/sync/example/web/postcss.config.js | 6 - crates/sync/example/web/src/App.tsx | 172 ----- crates/sync/example/web/src/index.css | 3 - crates/sync/example/web/src/index.tsx | 16 - crates/sync/example/web/src/test.ts | 47 -- crates/sync/example/web/src/utils/bindings.ts | 80 --- crates/sync/example/web/src/utils/rspc.ts | 29 - crates/sync/example/web/tailwind.config.js | 8 - crates/sync/example/web/tsconfig.json | 13 - crates/sync/example/web/vite.config.ts | 12 - .../Explorer/ContextMenu/OpenWith.tsx | 10 +- .../Explorer/ContextMenu/SharedItems.tsx | 2 +- .../$libraryId/Explorer/ExplorerTagBar.tsx | 2 +- .../Explorer/FilePath/DecryptDialog.tsx | 179 ----- .../Explorer/FilePath/DeleteDialog.tsx | 2 +- .../Explorer/FilePath/EncryptDialog.tsx | 177 ----- .../Explorer/FilePath/EraseDialog.tsx | 68 -- .../Explorer/FilePath/ErrorBarrier.tsx | 40 ++ .../$libraryId/Explorer/FilePath/Image.tsx | 11 +- .../Explorer/FilePath/LayeredFileIcon.tsx | 23 +- .../$libraryId/Explorer/FilePath/Original.tsx | 133 ++-- .../$libraryId/Explorer/FilePath/Thumb.tsx | 614 +++++++++++------- .../Explorer/Inspector/FavoriteButton.tsx | 2 +- .../$libraryId/Explorer/Inspector/index.tsx | 2 +- .../Explorer/QuickPreview/index.tsx | 189 +++--- .../Explorer/View/Grid/DragSelect/index.tsx | 64 +- .../Explorer/View/GridView/Item/index.tsx | 54 +- .../Explorer/View/GridView/index.tsx | 12 +- .../Explorer/View/ListView/index.tsx | 62 +- .../Explorer/View/MediaView/Item.tsx | 2 +- .../Explorer/View/MediaView/index.tsx | 28 +- .../Explorer/View/RenamableItemText.tsx | 6 +- .../app/$libraryId/Explorer/View/ViewItem.tsx | 11 +- interface/app/$libraryId/Explorer/index.tsx | 2 +- interface/app/$libraryId/Explorer/store.ts | 15 +- .../app/$libraryId/Explorer/useExplorer.ts | 57 +- .../Explorer/useExplorerDraggable.tsx | 56 +- .../Explorer/useExplorerItemData.tsx | 99 ++- .../Explorer/useExplorerPreferences.ts | 2 +- .../app/$libraryId/Layout/CMDK/index.tsx | 5 +- .../Layout/CMDK/pages/CMDKLocations.tsx | 5 +- .../$libraryId/Layout/CMDK/pages/CMDKTags.tsx | 3 +- .../Layout/Sidebar/DebugPopover.tsx | 15 +- .../Layout/Sidebar/JobManager/JobGroup.tsx | 2 +- .../Layout/Sidebar/JobManager/index.tsx | 2 +- .../Layout/Sidebar/SidebarLayout/Footer.tsx | 2 +- .../Sidebar/sections/Locations/index.tsx | 5 +- .../Layout/Sidebar/sections/Tags/index.tsx | 3 +- interface/app/$libraryId/Spacedrop/index.tsx | 2 +- interface/app/$libraryId/debug/actors.tsx | 12 +- interface/app/$libraryId/debug/cloud.tsx | 6 +- interface/app/$libraryId/ephemeral.tsx | 31 +- interface/app/$libraryId/location/$id.tsx | 3 +- interface/app/$libraryId/overview/index.tsx | 5 +- interface/app/$libraryId/search/Filters.tsx | 6 +- .../$libraryId/settings/client/account.tsx | 14 +- .../$libraryId/settings/client/backups.tsx | 8 +- .../settings/client/network/index.tsx | 2 +- .../$libraryId/settings/library/general.tsx | 2 +- .../app/$libraryId/settings/library/index.tsx | 1 - .../library/keys/BackupRestoreDialog.tsx | 121 ---- .../settings/library/keys/KeyViewerDialog.tsx | 160 ----- .../library/keys/MasterPasswordDialog.tsx | 187 ------ .../settings/library/keys/index.tsx | 293 --------- .../settings/library/locations/$id.tsx | 2 +- .../settings/library/saved-searches/index.tsx | 2 +- .../app/$libraryId/settings/library/sync.tsx | 12 +- .../settings/library/tags/CreateDialog.tsx | 2 +- .../settings/node/libraries/DeleteDialog.tsx | 2 +- .../settings/resources/changelog.tsx | 7 +- .../settings/resources/dependencies.tsx | 51 -- interface/app/index.tsx | 2 +- interface/app/onboarding/join-library.tsx | 4 +- interface/components/Devtools.tsx | 16 +- interface/components/Sparkles.tsx | 5 +- interface/hooks/useHomeDir.ts | 13 +- interface/hooks/useOperatingSystem.ts | 16 +- interface/package.json | 10 +- interface/util/useTraceUpdate.tsx | 28 + package.json | 2 +- packages/client/package.json | 10 +- packages/client/src/core.ts | 2 +- packages/client/src/explorer/index.ts | 1 - .../src/explorer/useExplorerInfiniteQuery.ts | 3 +- .../client/src/explorer/useExplorerQuery.ts | 9 +- .../src/explorer/useObjectsInfiniteQuery.ts | 19 +- .../explorer/useObjectsOffsetInfiniteQuery.ts | 7 +- .../src/explorer/usePathsExplorerQuery.ts | 2 - .../src/explorer/usePathsInfiniteQuery.ts | 134 ---- .../explorer/usePathsOffsetInfiniteQuery.ts | 9 +- .../client/src/hooks/useClientContext.tsx | 20 +- packages/client/src/index.ts | 2 +- packages/client/src/lib/humanizeSize.ts | 7 + packages/client/src/rspc-cursed.ts | 18 +- packages/client/src/rspc.tsx | 11 +- packages/client/src/solid/index.ts | 1 - packages/client/src/solid/solid.solid.tsx | 1 - .../client/src/solid/useUniversalQuery.ts | 14 - packages/client/src/stores/auth.ts | 2 +- packages/client/src/stores/debugState.ts | 4 +- packages/config/vite/narrowSolidPlugin.ts | 8 +- packages/ui/package.json | 2 +- pnpm-lock.yaml | Bin 1062415 -> 1054980 bytes scripts/list-dup-deps.sh | 9 - scripts/preprep.mjs | 5 +- scripts/tauri.mjs | 3 +- scripts/utils/fetch.mjs | 2 +- 176 files changed, 1378 insertions(+), 3348 deletions(-) delete mode 100644 core/src/custom_uri/async_read_body.rs delete mode 100644 crates/sync/example/Cargo.toml delete mode 100644 crates/sync/example/README.md delete mode 100644 crates/sync/example/prisma/migrations/.gitkeep delete mode 100644 crates/sync/example/prisma/schema.prisma delete mode 100644 crates/sync/example/src/api/mod.rs delete mode 100644 crates/sync/example/src/main.rs delete mode 100644 crates/sync/example/src/utils.rs delete mode 100644 crates/sync/example/web/.gitignore delete mode 100644 crates/sync/example/web/README.md delete mode 100644 crates/sync/example/web/index.html delete mode 100644 crates/sync/example/web/package.json delete mode 100644 crates/sync/example/web/postcss.config.js delete mode 100644 crates/sync/example/web/src/App.tsx delete mode 100644 crates/sync/example/web/src/index.css delete mode 100644 crates/sync/example/web/src/index.tsx delete mode 100644 crates/sync/example/web/src/test.ts delete mode 100644 crates/sync/example/web/src/utils/bindings.ts delete mode 100644 crates/sync/example/web/src/utils/rspc.ts delete mode 100644 crates/sync/example/web/tailwind.config.js delete mode 100644 crates/sync/example/web/tsconfig.json delete mode 100644 crates/sync/example/web/vite.config.ts delete mode 100644 interface/app/$libraryId/Explorer/FilePath/DecryptDialog.tsx delete mode 100644 interface/app/$libraryId/Explorer/FilePath/EncryptDialog.tsx delete mode 100644 interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx create mode 100644 interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx delete mode 100644 interface/app/$libraryId/settings/library/keys/BackupRestoreDialog.tsx delete mode 100644 interface/app/$libraryId/settings/library/keys/KeyViewerDialog.tsx delete mode 100644 interface/app/$libraryId/settings/library/keys/MasterPasswordDialog.tsx delete mode 100644 interface/app/$libraryId/settings/library/keys/index.tsx delete mode 100644 interface/app/$libraryId/settings/resources/dependencies.tsx create mode 100644 interface/util/useTraceUpdate.tsx delete mode 100644 packages/client/src/explorer/usePathsInfiniteQuery.ts delete mode 100644 packages/client/src/solid/useUniversalQuery.ts delete mode 100755 scripts/list-dup-deps.sh diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index 89cda1ffc..baf889960 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -9,9 +9,7 @@ runs: using: 'composite' steps: - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 9.0.6 + uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/actions/setup-system/action.yml b/.github/actions/setup-system/action.yml index d6287844f..6b934c9e0 100644 --- a/.github/actions/setup-system/action.yml +++ b/.github/actions/setup-system/action.yml @@ -29,7 +29,7 @@ runs: - name: Install LLVM and Clang if: ${{ runner.os == 'Windows' }} - uses: KyleMayes/install-llvm-action@v1 + uses: KyleMayes/install-llvm-action@v2 with: cached: ${{ steps.cache-llvm-restore.outputs.cache-hit }} version: '15' diff --git a/.prettierignore b/.prettierignore index 4d06ac882..96e019a26 100644 --- a/.prettierignore +++ b/.prettierignore @@ -35,3 +35,5 @@ package*.json # Dont format locales json interface/locales + +scripts/utils/.tmp/* diff --git a/Cargo.lock b/Cargo.lock index 234727c7242447f9d4618a26bd20b09f9f1c1e81..a453654f593a9481d5447218662abd971447730b 100644 GIT binary patch delta 26316 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

6QMPPd71cX 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 .fallback(|| async { "404 Not Found: We're past the event horizon..." }); // Only allow current device to access it - let listenera = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; - let listen_addra = listenera.local_addr()?; - let listenerb = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; - let listen_addrb = listenerb.local_addr()?; - let listenerc = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; - let listen_addrc = listenerc.local_addr()?; - let listenerd = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; - let listen_addrd = listenerd.local_addr()?; - - // let listen_addr = listener.local_addr()?; // We get it from a listener so `0` is turned into a random port + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let listen_addr = listener.local_addr()?; // We get it from a listener so `0` is turned into a random port let (tx, mut rx) = tokio::sync::mpsc::channel(1); - info!("Internal server listening on: http://{listen_addra:?} http://{listen_addrb:?} http://{listen_addrc:?} http://{listen_addrd:?}"); - let server = axum::Server::builder(CombinedIncoming { - a: AddrIncoming::from_listener(listenera)?, - b: AddrIncoming::from_listener(listenerb)?, - c: AddrIncoming::from_listener(listenerc)?, - d: AddrIncoming::from_listener(listenerd)?, - }); + info!("Internal server listening on: http://{listen_addr:?}"); tokio::spawn(async move { - server - .serve(app.into_make_service()) - .with_graceful_shutdown(async { + axum::serve(listener, app) + .with_graceful_shutdown(async move { rx.recv().await; }) .await @@ -96,12 +79,7 @@ pub async fn sd_server_plugin( }); let script = format!( - r#"window.__SD_CUSTOM_SERVER_AUTH_TOKEN__ = "{auth_token}"; window.__SD_CUSTOM_URI_SERVER__ = [{}];"#, - [listen_addra, listen_addrb, listen_addrc, listen_addrd] - .iter() - .map(|addr| format!("'http://{addr}'")) - .collect::>() - .join(","), + r#"window.__SD_CUSTOM_SERVER_AUTH_TOKEN__ = "{auth_token}"; window.__SD_CUSTOM_URI_SERVER__ = ['http://{listen_addr}'];"#, ); Ok(tauri::plugin::Builder::new("sd-server") @@ -127,15 +105,12 @@ struct QueryParams { token: Option, } -async fn auth_middleware( +async fn auth_middleware( Query(query): Query, State(auth_token): State, - request: Request, - next: Next, -) -> Result -where - B: Send, -{ + request: Request, + next: Next, +) -> Result { let req = if query.token.as_ref() != Some(&auth_token) { let (mut parts, body) = request.into_parts(); @@ -158,38 +133,3 @@ where Ok(next.run(req).await) } - -struct CombinedIncoming { - a: AddrIncoming, - b: AddrIncoming, - c: AddrIncoming, - d: AddrIncoming, -} - -impl Accept for CombinedIncoming { - type Conn = ::Conn; - type Error = ::Error; - - fn poll_accept( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { - if let Poll::Ready(Some(value)) = Pin::new(&mut self.a).poll_accept(cx) { - return Poll::Ready(Some(value)); - } - - if let Poll::Ready(Some(value)) = Pin::new(&mut self.b).poll_accept(cx) { - return Poll::Ready(Some(value)); - } - - if let Poll::Ready(Some(value)) = Pin::new(&mut self.c).poll_accept(cx) { - return Poll::Ready(Some(value)); - } - - if let Poll::Ready(Some(value)) = Pin::new(&mut self.d).poll_accept(cx) { - return Poll::Ready(Some(value)); - } - - Poll::Pending - } -} diff --git a/apps/desktop/src/patches.ts b/apps/desktop/src/patches.ts index 8fd5d4500..f1c33a0a7 100644 --- a/apps/desktop/src/patches.ts +++ b/apps/desktop/src/patches.ts @@ -1,4 +1,4 @@ -import { tauriLink } from '@oscartbeaumont-sd/rspc-tauri/src/v2'; +import { tauriLink } from '@spacedrive/rspc-tauri/src/v2'; globalThis.isDev = import.meta.env.DEV; globalThis.rspcLinks = [ diff --git a/apps/mobile/modules/sd-core/src/index.ts b/apps/mobile/modules/sd-core/src/index.ts index fb034782a..dfb338f36 100644 --- a/apps/mobile/modules/sd-core/src/index.ts +++ b/apps/mobile/modules/sd-core/src/index.ts @@ -1,4 +1,4 @@ -import { AlphaRSPCError, Link, RspcRequest } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { Link, RSPCError, RspcRequest } from '@spacedrive/rspc-client'; import { EventEmitter, requireNativeModule } from 'expo-modules-core'; // It loads the native module object from the JSI or falls back to @@ -15,7 +15,7 @@ export function reactNativeLink(): Link { string, { resolve: (result: any) => void; - reject: (error: Error | AlphaRSPCError) => void; + reject: (error: Error | RSPCError) => void; } >(); @@ -29,7 +29,7 @@ export function reactNativeLink(): Link { activeMap.delete(id); } else if (result.type === 'error') { const { message, code } = result.data; - activeMap.get(id)?.reject(new AlphaRSPCError(code, message)); + activeMap.get(id)?.reject(new RSPCError(code, message)); activeMap.delete(id); } else { console.error(`rspc: received event of unknown type '${result.type}'`); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5dc682437..525c790ba 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -21,8 +21,8 @@ "@dr.pogodin/react-native-fs": "^2.24.1", "@gorhom/bottom-sheet": "^4.6.1", "@hookform/resolvers": "^3.1.0", - "@oscartbeaumont-sd/rspc-client": "github:spacedriveapp/rspc#path:packages/client&bc882f4724", - "@oscartbeaumont-sd/rspc-react": "github:spacedriveapp/rspc#path:packages/react&bc882f4724", + "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", + "@spacedrive/rspc-react": "github:spacedriveapp/rspc#path:packages/react&6a77167495", "@react-native-async-storage/async-storage": "~1.23.1", "@react-native-masked-view/masked-view": "^0.3.1", "@react-navigation/bottom-tabs": "^6.5.19", @@ -32,7 +32,7 @@ "@sd/assets": "workspace:*", "@sd/client": "workspace:*", "@shopify/flash-list": "1.6.4", - "@tanstack/react-query": "^4.36.1", + "@tanstack/react-query": "^5.59", "babel-preset-solid": "^1.9.0", "class-variance-authority": "^0.7.0", "dayjs": "^1.11.10", @@ -74,8 +74,8 @@ "twrnc": "^4.1.0", "use-count-up": "^3.0.1", "use-debounce": "^9.0.4", - "valtio": "^1.11.2", - "zod": "~3.22.4" + "valtio": "^2.0", + "zod": "^3.23" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/apps/mobile/src/components/browse/BrowseLocations.tsx b/apps/mobile/src/components/browse/BrowseLocations.tsx index 75787d7a4..4159fbe7f 100644 --- a/apps/mobile/src/components/browse/BrowseLocations.tsx +++ b/apps/mobile/src/components/browse/BrowseLocations.tsx @@ -1,4 +1,5 @@ import { useNavigation } from '@react-navigation/native'; +import { keepPreviousData } from '@tanstack/react-query'; import { Plus } from 'phosphor-react-native'; import { useRef, useState } from 'react'; import { FlatList, Text, View } from 'react-native'; @@ -22,7 +23,7 @@ const BrowseLocations = () => { const modalRef = useRef(null); const [showAll, setShowAll] = useState(false); - const result = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const result = useLibraryQuery(['locations.list'], { placeholderData: keepPreviousData }); const locations = result.data; return ( diff --git a/apps/mobile/src/components/drawer/DrawerLocations.tsx b/apps/mobile/src/components/drawer/DrawerLocations.tsx index 2e1d8995c..0a5f87559 100644 --- a/apps/mobile/src/components/drawer/DrawerLocations.tsx +++ b/apps/mobile/src/components/drawer/DrawerLocations.tsx @@ -1,5 +1,6 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types'; import { useNavigation } from '@react-navigation/native'; +import { keepPreviousData } from '@tanstack/react-query'; import { useRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { @@ -73,7 +74,7 @@ const DrawerLocations = () => { const modalRef = useRef(null); - const result = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const result = useLibraryQuery(['locations.list'], { placeholderData: keepPreviousData }); const locations = result.data || []; return ( diff --git a/apps/mobile/src/components/explorer/Explorer.tsx b/apps/mobile/src/components/explorer/Explorer.tsx index 10b448d7c..d3281aa7d 100644 --- a/apps/mobile/src/components/explorer/Explorer.tsx +++ b/apps/mobile/src/components/explorer/Explorer.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native'; import { FlashList } from '@shopify/flash-list'; -import { UseInfiniteQueryResult } from '@tanstack/react-query'; +import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; import { useRef } from 'react'; import { ActivityIndicator } from 'react-native'; @@ -32,7 +32,7 @@ type ExplorerProps = { items: ExplorerItem[] | null; /** Function to fetch next page of items. */ loadMore: () => void; - query: UseInfiniteQueryResult>; + query: UseInfiniteQueryResult>>; count?: number; empty?: never; isEmpty?: never; diff --git a/apps/mobile/src/components/explorer/sections/FavoriteButton.tsx b/apps/mobile/src/components/explorer/sections/FavoriteButton.tsx index 5a09e515c..2dd851b7a 100644 --- a/apps/mobile/src/components/explorer/sections/FavoriteButton.tsx +++ b/apps/mobile/src/components/explorer/sections/FavoriteButton.tsx @@ -12,7 +12,7 @@ type Props = { const FavoriteButton = (props: Props) => { const [favorite, setFavorite] = useState(props.data.favorite); - const { mutate: toggleFavorite, isLoading } = useLibraryMutation('files.setFavorite', { + const { mutate: toggleFavorite, isPending } = useLibraryMutation('files.setFavorite', { onSuccess: () => { // TODO: Invalidate search queries setFavorite(!favorite); @@ -22,7 +22,7 @@ const FavoriteButton = (props: Props) => { return ( toggleFavorite({ id: props.data.id, favorite: !favorite })} style={props.style} > diff --git a/apps/mobile/src/components/job/JobGroup.tsx b/apps/mobile/src/components/job/JobGroup.tsx index 26dc832ac..9000a7b0a 100644 --- a/apps/mobile/src/components/job/JobGroup.tsx +++ b/apps/mobile/src/components/job/JobGroup.tsx @@ -191,7 +191,7 @@ function Options({ activeJob, group, setShowChildJobs, showChildJobs }: OptionsP const clearJob = useLibraryMutation(['jobs.clear'], { onSuccess: () => { - rspc.queryClient.invalidateQueries(['jobs.reports']); + rspc.queryClient.invalidateQueries({ queryKey: ['jobs.reports'] }); } }); diff --git a/apps/mobile/src/components/modal/AddTagModal.tsx b/apps/mobile/src/components/modal/AddTagModal.tsx index 929820c4c..ae69a74ff 100644 --- a/apps/mobile/src/components/modal/AddTagModal.tsx +++ b/apps/mobile/src/components/modal/AddTagModal.tsx @@ -35,8 +35,8 @@ const AddTagModal = forwardRef((_, ref) => { const mutation = useLibraryMutation(['tags.assign'], { onSuccess: () => { // this makes sure that the tags are updated in the UI - rspc.queryClient.invalidateQueries(['tags.getForObject']); - rspc.queryClient.invalidateQueries(['search.paths']); + rspc.queryClient.invalidateQueries({ queryKey: ['tags.getForObject'] }); + rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }); modalRef.current?.dismiss(); } }); diff --git a/apps/mobile/src/components/modal/CreateLibraryModal.tsx b/apps/mobile/src/components/modal/CreateLibraryModal.tsx index 346988473..83d005bfd 100644 --- a/apps/mobile/src/components/modal/CreateLibraryModal.tsx +++ b/apps/mobile/src/components/modal/CreateLibraryModal.tsx @@ -17,7 +17,7 @@ const CreateLibraryModal = forwardRef((_, ref) => { const submitPlausibleEvent = usePlausibleEvent(); - const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation( + const { mutate: createLibrary, isPending: createLibLoading } = useBridgeMutation( 'library.create', { onSuccess: (lib) => { diff --git a/apps/mobile/src/components/modal/ImportLibraryModal.tsx b/apps/mobile/src/components/modal/ImportLibraryModal.tsx index 82de9fdd4..f15edb421 100644 --- a/apps/mobile/src/components/modal/ImportLibraryModal.tsx +++ b/apps/mobile/src/components/modal/ImportLibraryModal.tsx @@ -100,7 +100,7 @@ const CloudLibraryCard = ({ data, modalRef, navigation }: Props) => { diff --git a/apps/mobile/src/components/overview/Devices.tsx b/apps/mobile/src/components/overview/Devices.tsx index 8643d08af..1f65407b9 100644 --- a/apps/mobile/src/components/overview/Devices.tsx +++ b/apps/mobile/src/components/overview/Devices.tsx @@ -1,5 +1,5 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; -import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { RSPCError } from '@spacedrive/rspc-client'; import { UseQueryResult } from '@tanstack/react-query'; import React, { useEffect, useState } from 'react'; import { Platform, Text, View } from 'react-native'; @@ -16,7 +16,7 @@ import StatCard from './StatCard'; interface Props { node: NodeState | undefined; - stats: UseQueryResult; + stats: UseQueryResult; } export function hardwareModelToIcon(hardwareModel: HardwareModel) { diff --git a/apps/mobile/src/components/overview/OverviewStats.tsx b/apps/mobile/src/components/overview/OverviewStats.tsx index 2a7ecd0de..3ba73a8ce 100644 --- a/apps/mobile/src/components/overview/OverviewStats.tsx +++ b/apps/mobile/src/components/overview/OverviewStats.tsx @@ -1,5 +1,5 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; -import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { RSPCError } from '@spacedrive/rspc-client'; import { UseQueryResult } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { Platform, Text, View } from 'react-native'; @@ -47,7 +47,7 @@ const StatItem = ({ title, bytes, isLoading, style }: StatItemProps) => { }; interface Props { - stats: UseQueryResult; + stats: UseQueryResult; } const OverviewStats = ({ stats }: Props) => { diff --git a/apps/mobile/src/components/search/filters/SavedSearches.tsx b/apps/mobile/src/components/search/filters/SavedSearches.tsx index e4dacb47e..5a63ac22f 100644 --- a/apps/mobile/src/components/search/filters/SavedSearches.tsx +++ b/apps/mobile/src/components/search/filters/SavedSearches.tsx @@ -71,7 +71,7 @@ const SavedSearch = ({ search }: Props) => { const dataForSearch = useSavedSearch(search); const rspc = useRspcLibraryContext(); const deleteSearch = useLibraryMutation('search.saved.delete', { - onSuccess: () => rspc.queryClient.invalidateQueries(['search.saved.list']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.saved.list'] }) }); return ( { diff --git a/apps/mobile/src/hooks/useSavedSearch.ts b/apps/mobile/src/hooks/useSavedSearch.ts index 0bdaa7e59..cf61b0ecc 100644 --- a/apps/mobile/src/hooks/useSavedSearch.ts +++ b/apps/mobile/src/hooks/useSavedSearch.ts @@ -1,4 +1,5 @@ import { IconTypes } from '@sd/assets/util'; +import { keepPreviousData } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { SavedSearch, SearchFilterArgs, Tag, useLibraryQuery } from '@sd/client'; import { kinds } from '~/components/search/filters/Kind'; @@ -44,11 +45,11 @@ export function useSavedSearch(search: SavedSearch) { }; const locations = useLibraryQuery(['locations.list'], { - keepPreviousData: true, + placeholderData: keepPreviousData, enabled: filterKeys.includes('locations') }); const tags = useLibraryQuery(['tags.list'], { - keepPreviousData: true, + placeholderData: keepPreviousData, enabled: filterKeys.includes('tags') }); diff --git a/apps/mobile/src/screens/BackfillWaiting.tsx b/apps/mobile/src/screens/BackfillWaiting.tsx index ab13c54e7..10f7e76ba 100644 --- a/apps/mobile/src/screens/BackfillWaiting.tsx +++ b/apps/mobile/src/screens/BackfillWaiting.tsx @@ -52,10 +52,8 @@ const BackfillWaiting = () => { const syncEnabled = useLibraryQuery(['sync.enabled']); useEffect(() => { - (async () => { - await enableSync.mutateAsync(null); - })(); - }, []); + enableSync.mutate(null); + }, [enableSync]); return ( diff --git a/apps/mobile/src/screens/browse/Location.tsx b/apps/mobile/src/screens/browse/Location.tsx index 81f9a152c..ca1bff307 100644 --- a/apps/mobile/src/screens/browse/Location.tsx +++ b/apps/mobile/src/screens/browse/Location.tsx @@ -62,10 +62,13 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP filters: [...defaultFilters, ...layoutFilter].filter(Boolean), take: 30 }, - order, - onSuccess: () => getExplorerStore().resetNewThumbnails() + order }); + useEffect(() => { + getExplorerStore().resetNewThumbnails(); + }, [path]); + useEffect(() => { // Set screen title to location. if (path && path !== '') { diff --git a/apps/mobile/src/screens/search/Search.tsx b/apps/mobile/src/screens/search/Search.tsx index f0f0a3ad0..bf6dd3a0f 100644 --- a/apps/mobile/src/screens/search/Search.tsx +++ b/apps/mobile/src/screens/search/Search.tsx @@ -1,6 +1,6 @@ import { useIsFocused } from '@react-navigation/native'; import { ArrowLeft, DotsThree, FunnelSimple } from 'phosphor-react-native'; -import { Suspense, useDeferredValue, useMemo, useState } from 'react'; +import { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Platform, Pressable, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ObjectKindEnum, useLibraryQuery, usePathsExplorerQuery } from '@sd/client'; @@ -41,10 +41,11 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => { filters: [...layoutSearchFilter, ...searchStore.mergedFilters] }, enabled: isFocused && searchStore.mergedFilters.length >= 1, // only fetch when screen is focused & filters are applied - suspense: true, - onSuccess: () => getExplorerStore().resetNewThumbnails() + suspense: true }); + useEffect(() => getExplorerStore().resetNewThumbnails(), [objects]); + useFiltersSearch(deferredSearch); const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length; diff --git a/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx b/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx index f121eb8c4..892556d0e 100644 --- a/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx +++ b/apps/mobile/src/screens/settings/library/CloudSettings/CloudSettings.tsx @@ -111,10 +111,10 @@ const Authenticated = () => { ); @@ -147,11 +147,11 @@ function StopButton({ name }: { name: string }) { ); diff --git a/apps/mobile/src/stores/auth.ts b/apps/mobile/src/stores/auth.ts index 3ef1079d8..336b3ff22 100644 --- a/apps/mobile/src/stores/auth.ts +++ b/apps/mobile/src/stores/auth.ts @@ -1,4 +1,4 @@ -import { RSPCError } from '@oscartbeaumont-sd/rspc-client'; +import { RSPCError } from '@spacedrive/rspc-client'; import { Linking } from 'react-native'; import { createMutable } from 'solid-js/store'; import { nonLibraryClient, useSolidStore } from '@sd/client'; diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index f649ab23b..f4050942f 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -16,12 +16,13 @@ default = [] sd-core = { path = "../../core", features = ["ffmpeg", "heif"] } # Workspace dependencies -axum = { workspace = true, features = ["headers"] } -http = { workspace = true } -rspc = { workspace = true, features = ["axum"] } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "signal", "sync"] } -tracing = { workspace = true } +axum = { workspace = true } +axum-extra = { workspace = true, features = ["typed-header"] } +http = { workspace = true } +rspc = { workspace = true, features = ["axum"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "signal", "sync"] } +tracing = { workspace = true } # Specific Desktop dependencies include_dir = "0.7.3" diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 252d45676..5a6304bf3 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,12 +1,15 @@ use std::{collections::HashMap, env, net::SocketAddr, path::Path}; use axum::{ + body::Body, extract::{FromRequestParts, State}, - headers::{authorization::Basic, Authorization}, http::Request, middleware::Next, response::{IntoResponse, Response}, routing::get, +}; +use axum_extra::{ + headers::{authorization::Basic, Authorization}, TypedHeader, }; use sd_core::{custom_uri, Node}; @@ -24,11 +27,7 @@ pub struct AppState { auth: HashMap, } -async fn basic_auth( - State(state): State, - request: Request, - next: Next, -) -> Response { +async fn basic_auth(State(state): State, request: Request, next: Next) -> Response { let request = if !state.auth.is_empty() { let (mut parts, body) = request.into_parts(); @@ -175,10 +174,7 @@ async fn main() { .route( "/", get(|| async move { - use axum::{ - body::{self, Full}, - response::Response, - }; + use axum::{body::Body, response::Response}; use http::{header, HeaderValue, StatusCode}; match ASSETS_DIR.get_file("index.html") { @@ -188,11 +184,11 @@ async fn main() { header::CONTENT_TYPE, HeaderValue::from_str("text/html").unwrap(), ) - .body(body::boxed(Full::from(file.contents()))) + .body(Body::from(file.contents())) .unwrap(), None => Response::builder() .status(StatusCode::NOT_FOUND) - .body(body::boxed(axum::body::Empty::new())) + .body(Body::empty()) .unwrap(), } }), @@ -201,10 +197,7 @@ async fn main() { "/*id", get( |axum::extract::Path(path): axum::extract::Path| async move { - use axum::{ - body::{self, Empty, Full}, - response::Response, - }; + use axum::{body::Body, response::Response}; use http::{header, HeaderValue, StatusCode}; let path = path.trim_start_matches('/'); @@ -218,7 +211,7 @@ async fn main() { ) .unwrap(), ) - .body(body::boxed(Full::from(file.contents()))) + .body(Body::from(file.contents())) .unwrap(), None => match ASSETS_DIR.get_file("index.html") { Some(file) => Response::builder() @@ -227,11 +220,11 @@ async fn main() { header::CONTENT_TYPE, HeaderValue::from_str("text/html").unwrap(), ) - .body(body::boxed(Full::from(file.contents()))) + .body(Body::from(file.contents())) .unwrap(), None => Response::builder() .status(StatusCode::NOT_FOUND) - .body(body::boxed(Empty::new())) + .body(Body::empty()) .unwrap(), }, } @@ -254,8 +247,7 @@ async fn main() { let mut addr = "[::]:8080".parse::().unwrap(); // This listens on IPv6 and IPv4 addr.set_port(port); info!("Listening on http://localhost:{}", port); - axum::Server::bind(&addr) - .serve(app.into_make_service()) + axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app) .with_graceful_shutdown(signal) .await .expect("Error with HTTP server!"); diff --git a/apps/web/package.json b/apps/web/package.json index b487de1d8..ad2a51ebf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,10 +17,10 @@ "lint": "eslint src --cache" }, "dependencies": { - "@oscartbeaumont-sd/rspc-client": "github:spacedriveapp/rspc#path:packages/client&bc882f4724", + "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", "@sd/client": "workspace:*", "@sd/interface": "workspace:*", - "@tanstack/react-query": "^4.36.1", + "@tanstack/react-query": "^5.59", "html-to-image": "^1.11.11", "html2canvas": "^1.4.1", "react": "^18.2.0", diff --git a/apps/web/src/patches.ts b/apps/web/src/patches.ts index 2faac6997..a545a1217 100644 --- a/apps/web/src/patches.ts +++ b/apps/web/src/patches.ts @@ -1,4 +1,4 @@ -import { wsBatchLink } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { wsBatchLink } from '@spacedrive/rspc-client'; globalThis.isDev = import.meta.env.DEV; globalThis.rspcLinks = [ diff --git a/core/Cargo.toml b/core/Cargo.toml index 49aac5516..3d3762464 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -64,7 +64,7 @@ reqwest = { workspace = true, features = ["json", "native-tls-vendor rmp-serde = { workspace = true } rmpv = { workspace = true } rspc = { workspace = true, features = ["alpha", "axum", "chrono", "unstable", "uuid"] } -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } specta = { workspace = true } strum = { workspace = true, features = ["derive"] } @@ -85,15 +85,16 @@ ctor = "0.2.8" directories = "5.0" flate2 = "1.0" hostname = "0.4.0" -http-body = "0.4.6" # Update blocked by http +http-body = "1.0" http-range = "0.1.5" -int-enum = "0.5" # Update blocked due to API breaking changes +hyper-util = { version = "0.1.9", features = ["tokio"] } +int-enum = "0.5" # Update blocked due to API breaking changes mini-moka = "0.10.3" serde-hashkey = "0.4.5" serde_repr = "0.1.19" serde_with = "3.8" slotmap = "1.0" -sysinfo = "0.29.11" # Update blocked due to API breaking changes +sysinfo = "0.29.11" # Update blocked due to API breaking changes tar = "0.4.41" tower-service = "0.3.2" tracing-appender = "0.2.3" diff --git a/core/crates/heavy-lifting/Cargo.toml b/core/crates/heavy-lifting/Cargo.toml index 75e99359c..20743e9fa 100644 --- a/core/crates/heavy-lifting/Cargo.toml +++ b/core/crates/heavy-lifting/Cargo.toml @@ -44,7 +44,7 @@ prisma-client-rust = { workspace = true } rmp-serde = { workspace = true } rmpv = { workspace = true } rspc = { workspace = true } -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } specta = { workspace = true } strum = { workspace = true, features = ["derive", "phf"] } diff --git a/core/crates/heavy-lifting/src/media_processor/helpers/thumbnailer.rs b/core/crates/heavy-lifting/src/media_processor/helpers/thumbnailer.rs index d1637ebb3..6d919fbae 100644 --- a/core/crates/heavy-lifting/src/media_processor/helpers/thumbnailer.rs +++ b/core/crates/heavy-lifting/src/media_processor/helpers/thumbnailer.rs @@ -25,7 +25,8 @@ use image::{imageops, DynamicImage, GenericImageView}; use serde::{Deserialize, Serialize}; use specta::Type; use tokio::{ - fs, io, + fs::{self, File}, + io::{self, AsyncWriteExt}, sync::{oneshot, Mutex}, task::spawn_blocking, time::{sleep, Instant}, @@ -450,15 +451,29 @@ async fn generate_image_thumbnail( trace!("Created shard directory and writing it to disk"); - let res = fs::write(output_path, &webp).await.map_err(|e| { + let mut file = File::create(output_path).await.map_err(|e| { + thumbnailer::NonCriticalThumbnailerError::SaveThumbnail( + file_path.clone(), + FileIOError::from((output_path, e)).to_string(), + ) + })?; + + file.write_all(&webp).await.map_err(|e| { + thumbnailer::NonCriticalThumbnailerError::SaveThumbnail( + file_path.clone(), + FileIOError::from((output_path, e)).to_string(), + ) + })?; + + file.sync_all().await.map_err(|e| { thumbnailer::NonCriticalThumbnailerError::SaveThumbnail( file_path, FileIOError::from((output_path, e)).to_string(), ) - }); + })?; trace!("Wrote thumbnail to disk"); - res + return Ok(()); } #[instrument( diff --git a/core/crates/heavy-lifting/src/media_processor/tasks/thumbnailer.rs b/core/crates/heavy-lifting/src/media_processor/tasks/thumbnailer.rs index 0180014a9..1497e3cc4 100644 --- a/core/crates/heavy-lifting/src/media_processor/tasks/thumbnailer.rs +++ b/core/crates/heavy-lifting/src/media_processor/tasks/thumbnailer.rs @@ -379,21 +379,20 @@ fn process_thumbnail_generation_output( match status { GenerationStatus::Generated => { *generated += 1; + // This if is REALLY needed, due to the sheer performance of the thumbnailer, + // I restricted to only send events notifying for thumbnails in the current + // opened directory, sending events for the entire location turns into a + // humongous bottleneck in the frontend lol, since it doesn't even knows + // what to do with thumbnails for inner directories lol + // - fogodev + if with_priority { + reporter.new_thumbnail(thumb_key); + } } GenerationStatus::Skipped => { *skipped += 1; } } - - // This if is REALLY needed, due to the sheer performance of the thumbnailer, - // I restricted to only send events notifying for thumbnails in the current - // opened directory, sending events for the entire location turns into a - // humongous bottleneck in the frontend lol, since it doesn't even knows - // what to do with thumbnails for inner directories lol - // - fogodev - if with_priority { - reporter.new_thumbnail(thumb_key); - } } Err(e) => { errors.push(media_processor::NonCriticalMediaProcessorError::from(e).into()); diff --git a/core/crates/indexer-rules/Cargo.toml b/core/crates/indexer-rules/Cargo.toml index 218f04d75..472bd2442 100644 --- a/core/crates/indexer-rules/Cargo.toml +++ b/core/crates/indexer-rules/Cargo.toml @@ -19,7 +19,7 @@ globset = { workspace = true, features = ["serde1"] } prisma-client-rust = { workspace = true } rmp-serde = { workspace = true } rspc = { workspace = true } -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive", "rc"] } specta = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } diff --git a/core/src/custom_uri/async_read_body.rs b/core/src/custom_uri/async_read_body.rs deleted file mode 100644 index 1a1cc523a..000000000 --- a/core/src/custom_uri/async_read_body.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::{ - io, - pin::Pin, - task::{Context, Poll}, -}; - -use axum::http::HeaderMap; -use bytes::Bytes; -use futures::Stream; -use http_body::Body; -use pin_project_lite::pin_project; -use tokio::io::{AsyncRead, AsyncReadExt, Take}; -use tokio_util::io::ReaderStream; - -// This code was taken from: https://github.com/tower-rs/tower-http/blob/e8eb54966604ea7fa574a2a25e55232f5cfe675b/tower-http/src/services/fs/mod.rs#L30 -pin_project! { - // NOTE: This could potentially be upstreamed to `http-body`. - /// Adapter that turns an [`impl AsyncRead`][tokio::io::AsyncRead] to an [`impl Body`][http_body::Body]. - #[derive(Debug)] - pub struct AsyncReadBody { - #[pin] - reader: ReaderStream, - } -} - -impl AsyncReadBody -where - T: AsyncRead, -{ - pub(crate) fn with_capacity_limited( - read: T, - capacity: usize, - max_read_bytes: u64, - ) -> AsyncReadBody> { - AsyncReadBody { - reader: ReaderStream::with_capacity(read.take(max_read_bytes), capacity), - } - } -} - -impl Body for AsyncReadBody -where - T: AsyncRead, -{ - type Data = Bytes; - type Error = io::Error; - - fn poll_data( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { - self.project().reader.poll_next(cx) - } - - fn poll_trailers( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll, Self::Error>> { - Poll::Ready(Ok(None)) - } -} diff --git a/core/src/custom_uri/mod.rs b/core/src/custom_uri/mod.rs index 33af7c727..89bd85122 100644 --- a/core/src/custom_uri/mod.rs +++ b/core/src/custom_uri/mod.rs @@ -29,7 +29,7 @@ use std::{ use async_stream::stream; use axum::{ - body::{self, Body, BoxBody, Full, StreamBody}, + body::Body, extract::{self, State}, http::{HeaderMap, HeaderValue, Request, Response, StatusCode}, middleware, @@ -38,8 +38,8 @@ use axum::{ Router, }; use bytes::Bytes; -use http_body::combinators::UnsyncBoxBody; use hyper::{header, upgrade::OnUpgrade}; +use hyper_util::rt::TokioIo; use mini_moka::sync::Cache; use tokio::{ fs::{self, File}, @@ -50,7 +50,6 @@ use uuid::Uuid; use self::{serve_file::serve_file, utils::*}; -mod async_read_body; mod mpsc_to_async_write; mod serve_file; mod utils; @@ -97,7 +96,7 @@ async fn request_to_remote_node( p2p: Arc, identity: RemoteIdentity, mut request: Request, -) -> Response> { +) -> Response { let request_upgrade_header = request.headers().get(header::UPGRADE).cloned(); let maybe_client_upgrade = request.extensions_mut().remove::(); @@ -121,17 +120,20 @@ async fn request_to_remote_node( }; tokio::spawn(async move { - let Ok(mut request_upgraded) = request_upgraded.await.map_err(|e| { + let Ok(request_upgraded) = request_upgraded.await.map_err(|e| { warn!(?e, "Error upgrading websocket request;"); }) else { return; }; - let Ok(mut response_upgraded) = response_upgraded.await.map_err(|e| { + let Ok(response_upgraded) = response_upgraded.await.map_err(|e| { warn!(?e, "Error upgrading websocket response;"); }) else { return; }; + let mut request_upgraded = TokioIo::new(request_upgraded); + let mut response_upgraded = TokioIo::new(response_upgraded); + copy_bidirectional(&mut request_upgraded, &mut response_upgraded) .await .map_err(|e| { @@ -147,7 +149,7 @@ async fn request_to_remote_node( async fn get_or_init_lru_entry( state: &LocalState, extract::Path((lib_id, loc_id, path_id)): ExtractedPath, -) -> Result<(CacheValue, Arc), Response> { +) -> Result<(CacheValue, Arc), Response> { let library_id = Uuid::from_str(&lib_id).map_err(bad_request)?; let location_id = loc_id.parse::().map_err(bad_request)?; let file_path_id = path_id @@ -245,7 +247,7 @@ pub fn base_router() -> Router { } else { StatusCode::INTERNAL_SERVER_ERROR }) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) })?; let metadata = file.metadata().await; serve_file( @@ -290,7 +292,7 @@ pub fn base_router() -> Router { } else { StatusCode::INTERNAL_SERVER_ERROR }) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) })?; let resp = InfallibleResponse::builder().header( @@ -335,11 +337,11 @@ pub fn base_router() -> Router { // TODO: Content Type Ok(InfallibleResponse::builder().status(StatusCode::OK).body( - body::boxed(StreamBody::new(stream! { + Body::from_stream(stream! { while let Some(item) = rx.recv().await { yield item; } - })), + }), )) } } @@ -364,7 +366,7 @@ pub fn base_router() -> Router { } else { StatusCode::INTERNAL_SERVER_ERROR }) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) })?; let resp = InfallibleResponse::builder().header( @@ -453,7 +455,7 @@ async fn infer_the_mime_type( ext: &str, file: &mut File, metadata: &Metadata, -) -> Result> { +) -> Result> { let ext = ext.to_lowercase(); let mime_type = match ext.as_str() { // AAC audio diff --git a/core/src/custom_uri/serve_file.rs b/core/src/custom_uri/serve_file.rs index fd80a69e5..460684cf5 100644 --- a/core/src/custom_uri/serve_file.rs +++ b/core/src/custom_uri/serve_file.rs @@ -3,18 +3,18 @@ use crate::util::InfallibleResponse; use std::{fs::Metadata, time::UNIX_EPOCH}; use axum::{ - body::{self, BoxBody, Full, StreamBody}, + body::Body, http::{header, request, HeaderValue, Method, Response, StatusCode}, }; use http_range::HttpRange; use tokio::{ fs::File, - io::{self, AsyncSeekExt, SeekFrom}, + io::{self, AsyncReadExt, AsyncSeekExt, SeekFrom}, }; use tokio_util::io::ReaderStream; use tracing::error; -use super::{async_read_body::AsyncReadBody, utils::*}; +use super::utils::*; // default capacity 64KiB const DEFAULT_CAPACITY: usize = 65536; @@ -31,7 +31,7 @@ pub(crate) async fn serve_file( metadata: io::Result, req: request::Parts, mut resp: InfallibleResponse, -) -> Result, Response> { +) -> Result, Response> { if let Ok(metadata) = metadata { // We only accept range queries if `files.metadata() == Ok(_)` // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges @@ -48,7 +48,7 @@ pub(crate) async fn serve_file( return Ok(resp .status(StatusCode::OK) .header("Content-Length", HeaderValue::from_static("0")) - .body(body::boxed(Full::from("")))); + .body(Body::from(""))); } // ETag @@ -73,9 +73,7 @@ pub(crate) async fn serve_file( // Used for normal requests if let Some(etag) = req.headers.get("If-None-Match") { if etag.as_bytes() == etag_header.as_bytes() { - return Ok(resp - .status(StatusCode::NOT_MODIFIED) - .body(body::boxed(Full::from("")))); + return Ok(resp.status(StatusCode::NOT_MODIFIED).body(Body::from(""))); } } @@ -104,7 +102,7 @@ pub(crate) async fn serve_file( .map_err(internal_server_error)?, ) .status(StatusCode::RANGE_NOT_SATISFIABLE) - .body(body::boxed(Full::from("")))); + .body(Body::from(""))); } let range = ranges.first().expect("checked above"); @@ -116,7 +114,7 @@ pub(crate) async fn serve_file( .map_err(internal_server_error)?, ) .status(StatusCode::RANGE_NOT_SATISFIABLE) - .body(body::boxed(Full::from("")))); + .body(Body::from(""))); } file.seek(SeekFrom::Start(range.start)) @@ -140,14 +138,13 @@ pub(crate) async fn serve_file( HeaderValue::from_str(&range.length.to_string()) .map_err(internal_server_error)?, ) - .body(body::boxed(AsyncReadBody::with_capacity_limited( - file, + .body(Body::from_stream(ReaderStream::with_capacity( + file.take(range.length), DEFAULT_CAPACITY, - range.length, )))); } } } - Ok(resp.body(body::boxed(StreamBody::new(ReaderStream::new(file))))) + Ok(resp.body(Body::from_stream(ReaderStream::new(file)))) } diff --git a/core/src/custom_uri/utils.rs b/core/src/custom_uri/utils.rs index 645da5106..cb54b815f 100644 --- a/core/src/custom_uri/utils.rs +++ b/core/src/custom_uri/utils.rs @@ -3,50 +3,49 @@ use crate::util::InfallibleResponse; use std::{fmt::Debug, panic::Location}; use axum::{ - body::{self, BoxBody}, + body::Body, http::{self, HeaderValue, Method, Request, Response, StatusCode}, middleware::Next, }; -use http_body::Full; use tracing::debug; #[track_caller] -pub(crate) fn bad_request(e: impl Debug) -> http::Response { +pub(crate) fn bad_request(e: impl Debug) -> http::Response { debug!(caller = %Location::caller(), ?e, "400: Bad Request;"); InfallibleResponse::builder() .status(StatusCode::BAD_REQUEST) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) } #[track_caller] -pub(crate) fn not_found(e: impl Debug) -> http::Response { +pub(crate) fn not_found(e: impl Debug) -> http::Response { debug!(caller = %Location::caller(), ?e, "404: Not Found;"); InfallibleResponse::builder() .status(StatusCode::NOT_FOUND) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) } #[track_caller] -pub(crate) fn internal_server_error(e: impl Debug) -> http::Response { +pub(crate) fn internal_server_error(e: impl Debug) -> http::Response { debug!(caller = %Location::caller(), ?e, "500: Internal Server Error;"); InfallibleResponse::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) } #[track_caller] -pub(crate) fn not_implemented(e: impl Debug) -> http::Response { +pub(crate) fn not_implemented(e: impl Debug) -> http::Response { debug!(caller = %Location::caller(), ?e, "501: Not Implemented;"); InfallibleResponse::builder() .status(StatusCode::NOT_IMPLEMENTED) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) } -pub(crate) async fn cors_middleware(req: Request, next: Next) -> Response { +pub(crate) async fn cors_middleware(req: Request, next: Next) -> Response { if req.method() == Method::OPTIONS { return Response::builder() .header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS") @@ -54,7 +53,7 @@ pub(crate) async fn cors_middleware(req: Request, next: Next) -> Respon .header("Access-Control-Allow-Headers", "*") .header("Access-Control-Max-Age", "86400") .status(StatusCode::OK) - .body(body::boxed(Full::from(""))) + .body(Body::from("")) .expect("Invalid static response!"); } diff --git a/core/src/p2p/operations/rspc.rs b/core/src/p2p/operations/rspc.rs index ed86c0912..e5e0b3cc8 100644 --- a/core/src/p2p/operations/rspc.rs +++ b/core/src/p2p/operations/rspc.rs @@ -1,9 +1,11 @@ use std::{error::Error, sync::Arc}; -use axum::{body::Body, http, Router}; -use hyper::{server::conn::Http, Response}; +use axum::{extract::Request, http, Router}; +use hyper::{body::Incoming, client::conn::http1::handshake, server::conn::http1, Response}; +use hyper_util::rt::TokioIo; use sd_p2p::{RemoteIdentity, UnicastStream, P2P}; use tokio::io::AsyncWriteExt; +use tower_service::Service; use tracing::debug; use crate::{p2p::Header, Node}; @@ -13,7 +15,7 @@ pub async fn remote_rspc( p2p: Arc, identity: RemoteIdentity, request: http::Request, -) -> Result, Box> { +) -> Result, Box> { let peer = p2p .peers() .get(&identity) @@ -23,7 +25,7 @@ pub async fn remote_rspc( stream.write_all(&Header::RspcRemote.to_bytes()).await?; - let (mut sender, conn) = hyper::client::conn::handshake(stream).await?; + let (mut sender, conn) = handshake(TokioIo::new(stream)).await?; tokio::task::spawn(async move { if let Err(e) = conn.await { println!("Connection error: {:?}", e); @@ -49,10 +51,12 @@ pub(crate) async fn receiver( todo!("No way buddy!"); } - Http::new() - .http1_only(true) - .http1_keep_alive(true) - .serve_connection(stream, service) + let hyper_service = + hyper::service::service_fn(move |request: Request| service.clone().call(request)); + + http1::Builder::new() + .keep_alive(true) + .serve_connection(TokioIo::new(stream), hyper_service) .with_upgrades() .await .map_err(Into::into) diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index e24bd3c0c..b521ee93d 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -38,7 +38,7 @@ uuid = { workspace = true, features = ["serde", "v4"] } # Specific AI dependencies # Note: half and ndarray version must be the same as used in ort -half = { version = "2.1", features = ['num-traits'] } +half = { version = "2.4", features = ['num-traits'] } ndarray = "0.15" url = '2.5' diff --git a/crates/ffmpeg/src/thumbnailer.rs b/crates/ffmpeg/src/thumbnailer.rs index afd008813..6333e34c4 100644 --- a/crates/ffmpeg/src/thumbnailer.rs +++ b/crates/ffmpeg/src/thumbnailer.rs @@ -4,7 +4,7 @@ use std::{io, ops::Deref, path::Path}; use image::{imageops, DynamicImage, RgbImage}; use sd_utils::error::FileIOError; -use tokio::{fs, task::spawn_blocking}; +use tokio::{fs, io::AsyncWriteExt, task::spawn_blocking}; use tracing::error; use webp::Encoder; @@ -37,12 +37,18 @@ impl Thumbnailer { .await .map_err(|e| FileIOError::from((path, e)))?; - fs::write( - output_thumbnail_path, - &*self.process_to_webp_bytes(video_file_path).await?, - ) - .await - .map_err(|e| FileIOError::from((output_thumbnail_path, e)).into()) + let webp = self.process_to_webp_bytes(video_file_path).await?; + let mut file = fs::File::create(output_thumbnail_path) + .await + .map_err(|e: io::Error| FileIOError::from((output_thumbnail_path, e)))?; + + file.write_all(&webp) + .await + .map_err(|e| FileIOError::from((output_thumbnail_path, e)))?; + + file.sync_all() + .await + .map_err(|e| FileIOError::from((output_thumbnail_path, e)).into()) } /// Processes an video input file and returns a webp encoded thumbnail as bytes diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index 14d21c20b..4727ec7f9 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -31,11 +31,11 @@ uuid = { workspace = true, features = ["serde"] } # Specific P2P dependencies dns-lookup = "2.0" -flume = "=0.11.0" # Must match version used by `mdns-sd` +flume = "=0.11.1" # Must match version used by `mdns-sd` hash_map_diff = "0.2.0" if-watch = { version = "=3.2.0", features = ["tokio"] } # Override features used by libp2p-quic -libp2p-stream = "=0.1.0-alpha" # Update blocked due to custom patch -mdns-sd = "0.11.1" +libp2p-stream = "=0.2.0-alpha" # Update blocked due to custom patch +mdns-sd = "0.11.5" rand_core = "0.6.4" stable-vec = "0.4.1" sync_wrapper = "1.0" @@ -43,7 +43,7 @@ zeroize = { version = "1.8", features = ["derive"] } [dependencies.libp2p] features = ["autonat", "dcutr", "macros", "noise", "quic", "relay", "serde", "tokio", "yamux"] -version = "=0.53.2" # Update blocked due to custom patch +version = "=0.54.1" # Update blocked due to custom patch [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/prisma-cli/Cargo.toml b/crates/prisma-cli/Cargo.toml index 37c764459..c5c0fb669 100644 --- a/crates/prisma-cli/Cargo.toml +++ b/crates/prisma-cli/Cargo.toml @@ -14,5 +14,5 @@ sd-sync-generator = { path = "../sync-generator" } [dependencies.prisma-client-rust-generator] default-features = false features = ["migrations", "specta", "sqlite", "sqlite-create-many"] -git = "https://github.com/brendonovich/prisma-client-rust" -rev = "4f9ef9d38c" +git = "https://github.com/spacedriveapp/prisma-client-rust" +rev = "b22ad7dc7d" diff --git a/crates/sync/example/Cargo.toml b/crates/sync/example/Cargo.toml deleted file mode 100644 index 6893daee8..000000000 --- a/crates/sync/example/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "sd-sync-example" -version = "0.1.0" - -edition.workspace = true -license.workspace = true -publish = false -repository.workspace = true -rust-version.workspace = true - -[dependencies] -# Spacedrive Sub-crates -sd-sync = { path = ".." } - -# Workspace dependencies -axum = { workspace = true } -http = { workspace = true } -prisma-client-rust = { workspace = true } -rspc = { workspace = true, features = ["axum"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["full"] } -uuid = { workspace = true, features = ["v4"] } - -# Specific Core dependencies -dotenv = "0.15.0" -tower-http = { version = "0.4.4", features = ["cors"] } # Update blocked by http diff --git a/crates/sync/example/README.md b/crates/sync/example/README.md deleted file mode 100644 index c3adc9cab..000000000 --- a/crates/sync/example/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Create rspc app - -This app was scaffolded using the [create-rspc-app](https://rspc.dev) CLI. - -## Usage - -```bash -# Terminal One -cd web -pnpm i -pnpm dev - -# Terminal Two -cd api/ -cargo prisma generate -cargo prisma db push -cargo run -``` diff --git a/crates/sync/example/prisma/migrations/.gitkeep b/crates/sync/example/prisma/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/crates/sync/example/prisma/schema.prisma b/crates/sync/example/prisma/schema.prisma deleted file mode 100644 index efc4ee911..000000000 --- a/crates/sync/example/prisma/schema.prisma +++ /dev/null @@ -1,34 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -datasource db { - provider = "sqlite" - url = "file:dev.db" -} - -generator client { - provider = "cargo prisma" - output = "../src/prisma.rs" -} - -generator sync { - provider = "cargo run -p prisma-cli --bin sync --" - output = "../src/prisma_sync.rs" -} - -/// @owned -model FilePath { - id Bytes @id - path String - - object Object? @relation(fields: [object_id], references: [id]) - object_id Bytes? -} - -/// @shared -model Object { - id Bytes @id - name String - - paths FilePath[] @relation() -} diff --git a/crates/sync/example/src/api/mod.rs b/crates/sync/example/src/api/mod.rs deleted file mode 100644 index 14cc6d257..000000000 --- a/crates/sync/example/src/api/mod.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use rspc::*; -use sd_sync::*; -use serde_json::*; -use std::path::PathBuf; -use tokio::sync::Mutex; -use uuid::Uuid; - -use crate::prisma::{file_path, PrismaClient}; - -pub struct Ctx { - pub dbs: HashMap, - pub prisma: PrismaClient, -} - -type Router = rspc::Router>>; - -fn to_map(v: &impl serde::Serialize) -> serde_json::Map { - match to_value(v).unwrap() { - Value::Object(m) => m, - _ => unreachable!(), - } -} - -pub(crate) fn new() -> RouterBuilder>> { - Router::new() - .config(Config::new().export_ts_bindings( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("web/src/utils/bindings.ts"), - )) - .mutation("testCreate", |r| { - r(|ctx, _: String| async move { - let prisma = &ctx.lock().await.prisma; - - let res = prisma - .file_path() - .create(vec![], String::new(), vec![]) - .exec_raw() - .await - .unwrap(); - - file_path::Create::operation_from_data(&res); - - Ok(()) - }) - }) - .mutation("createDatabase", |r| { - r(|ctx, _: String| async move { - let dbs = &mut ctx.lock().await.dbs; - let uuid = Uuid::new_v4(); - - dbs.insert(uuid, Db::new(uuid)); - - let ids = dbs.keys().copied().collect::>(); - - for db in dbs.values_mut() { - for id in &ids { - db.register_node(*id); - } - } - - Ok(uuid) - }) - }) - .mutation("removeDatabases", |r| { - r(|ctx, _: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - dbs.drain(); - - Ok(()) - }) - }) - .query("dbs", |r| { - r(|ctx, _: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - Ok(dbs.iter().map(|(id, _)| *id).collect::>()) - }) - }) - .query("db.tags", |r| { - r(|ctx, id: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let id = id.parse().unwrap(); - - Ok(dbs.get(&id).unwrap().tags.clone()) - }) - }) - .query("file_path.list", |r| { - r(|ctx, id: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let db = dbs.get(&id.parse().unwrap()).unwrap(); - - let file_paths = db.file_paths.values().map(Clone::clone).collect::>(); - - Ok(file_paths) - }) - }) - .mutation("file_path.create", |r| { - r(|ctx, db: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let db = dbs.get_mut(&db.parse().unwrap()).unwrap(); - - let id = Uuid::new_v4(); - - let file_path = FilePath { - id, - path: String::new(), - file: None, - }; - - let op = db.create_crdt_operation(CRDTOperationType::Owned(OwnedOperation { - model: "FilePath".to_string(), - items: vec![OwnedOperationItem { - id: serde_json::to_value(id).unwrap(), - data: OwnedOperationData::Create( - serde_json::from_value(serde_json::to_value(&file_path).unwrap()) - .unwrap(), - ), - }], - })); - - db.receive_crdt_operations(vec![op]); - - file_path - }) - }) - .query("message.list", |r| { - r(|ctx, id: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let db = dbs.get(&id.parse().unwrap()).unwrap(); - - Ok(db._operations.clone()) - }) - }) - .mutation("pullOperations", |r| { - r(|ctx, db_id: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let db_id = db_id.parse().unwrap(); - - let ops = dbs.values().flat_map(|db| db._operations.clone()).collect(); - - let db = dbs.get_mut(&db_id).unwrap(); - - db.receive_crdt_operations(ops); - - Ok(()) - }) - }) - .query("operations", |r| { - r(|ctx, _: String| async move { - let dbs = &mut ctx.lock().await.dbs; - - let mut hashmap = HashMap::new(); - - for db in dbs.values_mut() { - for op in &db._operations { - hashmap.insert(op.id, op.clone()); - } - } - - let mut array = hashmap.into_values().collect::>(); - - array.sort_by(|a, b| a.id.partial_cmp(&b.id).unwrap()); - - Ok(array) - }) - }) -} diff --git a/crates/sync/example/src/main.rs b/crates/sync/example/src/main.rs deleted file mode 100644 index a04eae216..000000000 --- a/crates/sync/example/src/main.rs +++ /dev/null @@ -1,45 +0,0 @@ -use api::Ctx; -use axum::{ - http::{HeaderValue, Method}, - routing::get, -}; -use std::{net::SocketAddr, sync::Arc}; -use tokio::sync::Mutex; -use tower_http::cors::CorsLayer; - -mod api; -mod prisma; -mod prisma_sync; -mod utils; - -async fn router() -> axum::Router { - let router = api::new().build().arced(); - - let ctx = Arc::new(Mutex::new(Ctx { - dbs: Default::default(), - prisma: prisma::new_client().await.unwrap(), - })); - - axum::Router::new() - .route("/", get(|| async { "Hello 'rspc'!" })) - .route("/rspc/:id", router.endpoint(move || ctx.clone()).axum()) - .layer( - CorsLayer::new() - .allow_origin("http://localhost:3000".parse::().unwrap()) - .allow_headers(vec![http::header::CONTENT_TYPE]) - .allow_methods([Method::GET, Method::POST]), - ) -} - -#[tokio::main] -async fn main() { - dotenv::dotenv().ok(); - - let addr = "[::]:9000".parse::().unwrap(); // This listens on IPv6 and IPv4 - println!("{} listening on http://{}", env!("CARGO_CRATE_NAME"), addr); - axum::Server::bind(&addr) - .serve(router().await.into_make_service()) - .with_graceful_shutdown(utils::axum_shutdown_signal()) - .await - .expect("Error with HTTP server!"); -} diff --git a/crates/sync/example/src/utils.rs b/crates/sync/example/src/utils.rs deleted file mode 100644 index f6437d365..000000000 --- a/crates/sync/example/src/utils.rs +++ /dev/null @@ -1,28 +0,0 @@ -use tokio::signal; - -/// shutdown_signal will inform axum to gracefully shutdown when the process is asked to shutdown. -pub async fn axum_shutdown_signal() { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } - - println!("signal received, starting graceful shutdown"); -} diff --git a/crates/sync/example/web/.gitignore b/crates/sync/example/web/.gitignore deleted file mode 100644 index 76add878f..000000000 --- a/crates/sync/example/web/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/crates/sync/example/web/README.md b/crates/sync/example/web/README.md deleted file mode 100644 index 434f7bb9d..000000000 --- a/crates/sync/example/web/README.md +++ /dev/null @@ -1,34 +0,0 @@ -## Usage - -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. - -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. - -```bash -$ npm install # or pnpm install or yarn install -``` - -### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) - -## Available Scripts - -In the project directory, you can run: - -### `npm dev` or `npm start` - -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.
- -### `npm run build` - -Builds the app for production to the `dist` folder.
-It correctly bundles Solid in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! - -## Deployment - -You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/crates/sync/example/web/index.html b/crates/sync/example/web/index.html deleted file mode 100644 index f22a9d4f1..000000000 --- a/crates/sync/example/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Solid App - - - -

- - - - diff --git a/crates/sync/example/web/package.json b/crates/sync/example/web/package.json deleted file mode 100644 index 0c95d0ba5..000000000 --- a/crates/sync/example/web/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "example-2", - "version": "0.0.0", - "description": "", - "scripts": { - "dev": "vite", - "build": "vite build", - "serve": "vite preview", - "typecheck": "tsc --noEmit" - }, - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "solid-js": "^1.8.3" - }, - "devDependencies": { - "@tanstack/react-query": "^4.36.1", - "typescript": "^5.6.2", - "vite": "^5.2.0", - "tailwindcss": "^3.3.3" - } -} diff --git a/crates/sync/example/web/postcss.config.js b/crates/sync/example/web/postcss.config.js deleted file mode 100644 index 054c147cb..000000000 --- a/crates/sync/example/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/crates/sync/example/web/src/App.tsx b/crates/sync/example/web/src/App.tsx deleted file mode 100644 index 6c90cbd4b..000000000 --- a/crates/sync/example/web/src/App.tsx +++ /dev/null @@ -1,172 +0,0 @@ -// import clsx from 'clsx'; -// import { Suspense, useState } from 'react'; -// import { tests } from './test'; -// import { CRDTOperationType, rspc } from './utils/rspc'; - -// export function App() { -// const dbs = rspc.useQuery(['dbs', 'cringe']); - -// const operations = rspc.useQuery(['operations', 'cringe']); - -// const createDb = rspc.useMutation('createDatabase'); -// const removeDbs = rspc.useMutation('removeDatabases'); -// const testCreate = rspc.useMutation('testCreate'); - -// return ( -//
-//
-//
-// -// -// -//
-//
    -// {Object.entries(tests).map(([key, test]) => ( -//
  • -// -//
  • -// ))} -//
-//
-//
-//
    -// {dbs.data?.map((id) => ( -// -// -// -// ))} -//
-//
-//
-//

All Operations

-//
    -// {operations.data?.map((op) => ( -//
  • -//

    ID: {op.id}

    -//

    Timestamp: {op.timestamp.toString()}

    -//

    Node: {op.node}

    -//
  • -// ))} -//
-//
-//
-// ); -// } - -// interface DatabaseViewProps { -// id: string; -// } -// const TABS = ['File Paths', 'Objects', 'Tags', 'Operations']; - -// function DatabaseView(props: DatabaseViewProps) { -// const [currentTab, setCurrentTab] = useState<(typeof TABS)[number]>('Operations'); - -// const pullOperations = rspc.useMutation('pullOperations'); - -// return ( -//
-//
-//

{props.id}

-// -//
-//
-// -// -// {currentTab === 'File Paths' && } -// {currentTab === 'Operations' && } -// -//
-//
-// ); -// } - -// function FilePathList(props: { db: string }) { -// const createFilePath = rspc.useMutation('file_path.create'); -// const filePaths = rspc.useQuery(['file_path.list', props.db]); - -// return ( -//
-// {filePaths.data && ( -//
    -// {filePaths.data -// .sort((a, b) => a.id.localeCompare(b.id)) -// .map((path) => ( -//
  • {JSON.stringify(path)}
  • -// ))} -//
-// )} -// -//
-// ); -// } - -// function messageType(msg: CRDTOperationType) { -// if ('items' in msg) { -// return 'Owned'; -// } else if ('record_id' in msg) { -// return 'Shared'; -// } -// } - -// function OperationList(props: { db: string }) { -// const messages = rspc.useQuery(['message.list', props.db]); - -// return ( -//
-// {messages.data && ( -// -// {messages.data -// .sort((a, b) => Number(a.timestamp - b.timestamp)) -// .map((message) => ( -// -// -// -// -// -// ))} -//
{message.id} -// {new Date( -// Number(message.timestamp) / 10000000 -// ).toLocaleTimeString()} -// -// {messageType(message.typ)} -//
-// )} -//
-// ); -// } - -// const ButtonStyles = 'bg-blue-500 text-white px-2 py-1 rounded-md'; - -export {}; diff --git a/crates/sync/example/web/src/index.css b/crates/sync/example/web/src/index.css deleted file mode 100644 index b5c61c956..000000000 --- a/crates/sync/example/web/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/crates/sync/example/web/src/index.tsx b/crates/sync/example/web/src/index.tsx deleted file mode 100644 index 6c9a09bac..000000000 --- a/crates/sync/example/web/src/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// /* @refresh reload */ -// import { Suspense } from 'react'; -// import { createRoot } from 'react-dom/client'; -// import { App } from './App'; -// import './index.css'; -// import { queryClient, rspc, rspcClient } from './utils/rspc'; - -// createRoot(document.getElementById('root') as HTMLElement).render( -// -// -// -// -// -// ); - -export {}; diff --git a/crates/sync/example/web/src/test.ts b/crates/sync/example/web/src/test.ts deleted file mode 100644 index e516517cc..000000000 --- a/crates/sync/example/web/src/test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// import { queryClient, rspcClient } from './utils/rspc'; - -// function test(fn: () => Promise) { -// return async () => { -// await fn(); -// queryClient.invalidateQueries(); -// }; -// } - -// const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); - -// export const tests = { -// three: { -// name: 'Three', -// run: test(async () => { -// const [db1, db2, db3] = await Promise.all([ -// rspcClient.mutation(['createDatabase', ' ']), -// rspcClient.mutation(['createDatabase', ' ']), -// rspcClient.mutation(['createDatabase', ' ']) -// ]); - -// const dbs = await rspcClient.query(['dbs', 'cringe']); - -// for (const db of dbs) { -// await rspcClient.mutation(['file_path.create', db]); -// } - -// for (const db of dbs) { -// await rspcClient.mutation(['pullOperations', db]); -// } - -// await rspcClient.mutation(['file_path.create', dbs[0]]); -// await rspcClient.mutation(['file_path.create', dbs[0]]); - -// for (const db of dbs) { -// await rspcClient.mutation(['pullOperations', db]); -// } - -// await rspcClient.mutation(['pullOperations', dbs[1]]); -// await rspcClient.mutation(['pullOperations', dbs[1]]); -// await rspcClient.mutation(['pullOperations', dbs[1]]); -// await rspcClient.mutation(['pullOperations', dbs[1]]); -// }) -// } -// }; - -export {}; diff --git a/crates/sync/example/web/src/utils/bindings.ts b/crates/sync/example/web/src/utils/bindings.ts deleted file mode 100644 index 3d31e95bb..000000000 --- a/crates/sync/example/web/src/utils/bindings.ts +++ /dev/null @@ -1,80 +0,0 @@ -// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually. - -export type Procedures = { - queries: - | { key: 'db.tags'; input: string; result: Record } - | { key: 'dbs'; input: string; result: Array } - | { key: 'file_path.list'; input: string; result: Array } - | { key: 'message.list'; input: string; result: Array } - | { key: 'operations'; input: string; result: Array }; - mutations: - | { key: 'createDatabase'; input: string; result: string } - | { key: 'file_path.create'; input: string; result: FilePath } - | { key: 'pullOperations'; input: string; result: null } - | { key: 'removeDatabases'; input: string; result: null } - | { key: 'testCreate'; input: string; result: null }; - subscriptions: never; -}; - -export interface CRDTOperation { - node: string; - timestamp: bigint; - id: string; - typ: CRDTOperationType; -} - -export type CRDTOperationType = SharedOperation | RelationOperation | OwnedOperation; - -export interface Color { - red: number; - green: number; - blue: number; -} - -export interface FilePath { - id: string; - path: string; - file: string | null; -} - -export interface OwnedOperation { - model: string; - items: Array; -} - -export type OwnedOperationData = - | { Create: Record } - | { Update: Record } - | 'Delete'; - -export interface OwnedOperationItem { - id: any; - data: OwnedOperationData; -} - -export interface RelationOperation { - relation_item: string; - relation_group: string; - relation: string; - data: RelationOperationData; -} - -export type RelationOperationData = 'Create' | { Update: { field: string; value: any } } | 'Delete'; - -export interface SharedOperation { - record_id: string; - model: string; - data: SharedOperationData; -} - -export type SharedOperationCreateData = { Unique: Record } | 'Atomic'; - -export type SharedOperationData = - | { Create: SharedOperationCreateData } - | { Update: { field: string; value: any } } - | 'Delete'; - -export interface Tag { - color: Color; - name: string; -} diff --git a/crates/sync/example/web/src/utils/rspc.ts b/crates/sync/example/web/src/utils/rspc.ts deleted file mode 100644 index 9991bd5f0..000000000 --- a/crates/sync/example/web/src/utils/rspc.ts +++ /dev/null @@ -1,29 +0,0 @@ -// import { createClient, httpLink } from '@oscartbeaumont-sd/rspc-client'; -// import { createReactHooks } from '@oscartbeaumont-sd/rspc-react'; -// import { QueryClient } from '@tanstack/react-query'; -// import type { Procedures } from './bindings'; - -// export * from './bindings'; - -// // These are generated by rspc in Rust for you. - -// const rspc = createReactHooks(); - -// const rspcClient = rspc.createClient({ -// links: [httpLink({ url: 'http://localhost:9000/rspc' })] -// }); - -// const queryClient = new QueryClient({ -// defaultOptions: { -// queries: { -// suspense: true -// }, -// mutations: { -// onSuccess: () => queryClient.invalidateQueries() -// } -// } -// }); - -// export { rspc, rspcClient, queryClient }; - -export {}; diff --git a/crates/sync/example/web/tailwind.config.js b/crates/sync/example/web/tailwind.config.js deleted file mode 100644 index 7cf6cc57b..000000000 --- a/crates/sync/example/web/tailwind.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: {} - }, - plugins: [] -}; diff --git a/crates/sync/example/web/tsconfig.json b/crates/sync/example/web/tsconfig.json deleted file mode 100644 index 1d5d18140..000000000 --- a/crates/sync/example/web/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "types": ["vite/client"], - "noEmit": true, - "isolatedModules": true - } -} diff --git a/crates/sync/example/web/vite.config.ts b/crates/sync/example/web/vite.config.ts deleted file mode 100644 index 29f022f92..000000000 --- a/crates/sync/example/web/vite.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [react()], - server: { - port: 3000 - }, - build: { - target: 'esnext' - } -}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/OpenWith.tsx b/interface/app/$libraryId/Explorer/ContextMenu/OpenWith.tsx index 381548a0a..d11ba36bc 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/OpenWith.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/OpenWith.tsx @@ -81,9 +81,9 @@ const Items = ({ const paths = selectedEphemeralPaths.map((obj) => obj.path); const { t } = useLocale(); - const { data: apps } = useQuery( - ['openWith', ids, paths], - async () => { + const { data: apps } = useQuery({ + queryKey: ['openWith', ids, paths], + queryFn: async () => { const handleError = (res: Result) => { if (res?.status === 'error') { toast.error('Failed to get applications capable to open file'); @@ -104,8 +104,8 @@ const Items = ({ .then((res) => res.flat()) .then((res) => res.sort((a, b) => a.name.localeCompare(b.name))); }, - { initialData: [] } - ); + initialData: [] + }); return ( <> diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index 1c79d03ac..41a882e0e 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -224,7 +224,7 @@ const SpacedropNodes = () => { { spacedrop.mutateAsync({ identity: id, diff --git a/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx index aca3ec3f1..2dd8ef46e 100644 --- a/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx +++ b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx @@ -117,7 +117,7 @@ export const ExplorerTagBar = () => { const { data: allTags = [] } = useLibraryQuery(['tags.list']); const mutation = useLibraryMutation(['tags.assign'], { - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const { t } = useLocale(); diff --git a/interface/app/$libraryId/Explorer/FilePath/DecryptDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/DecryptDialog.tsx deleted file mode 100644 index 0f2cbbc2e..000000000 --- a/interface/app/$libraryId/Explorer/FilePath/DecryptDialog.tsx +++ /dev/null @@ -1,179 +0,0 @@ -// import { RadioGroup } from '@headlessui/react'; -// import { Info } from '@phosphor-icons/react'; -// import { useLibraryMutation, useLibraryQuery } from '@sd/client'; -// import { Button, Dialog, Tooltip, UseDialogProps, useDialog } from '@sd/ui'; -// import { PasswordInput, Switch, useZodForm, z } from '@sd/ui/src/forms'; -// import { showAlertDialog } from '~/components'; -// import { usePlatform } from '~/util/Platform'; - -// const schema = z.object({ -// type: z.union([z.literal('password'), z.literal('key')]), -// outputPath: z.string(), -// mountAssociatedKey: z.boolean(), -// password: z.string(), -// saveToKeyManager: z.boolean() -// }); - -// interface Props extends UseDialogProps { -// location_id: number; -// path_id: number; -// } - -// export default (props: Props) => { -// const platform = usePlatform(); - -// const mountedUuids = useLibraryQuery(['keys.listMounted'], { -// onSuccess: (data) => { -// hasMountedKeys = data.length > 0 ? true : false; -// if (!hasMountedKeys) { -// form.setValue('type', 'password'); -// } else { -// form.setValue('type', 'key'); -// } -// } -// }); - -// let hasMountedKeys = -// mountedUuids.data !== undefined && mountedUuids.data.length > 0 ? true : false; - -// const decryptFile = useLibraryMutation('files.decryptFiles', { -// onSuccess: () => { -// showAlertDialog({ -// title: 'Success', -// value: 'The decryption job has started successfully. You may track the progress in the job overview panel.' -// }); -// }, -// onError: () => { -// showAlertDialog({ -// title: 'Error', -// value: 'The decryption job failed to start.' -// }); -// } -// }); - -// const form = useZodForm({ -// defaultValues: { -// type: hasMountedKeys ? 'key' : 'password', -// saveToKeyManager: true, -// outputPath: '', -// password: '', -// mountAssociatedKey: true -// }, -// schema -// }); - -// return ( -// -// decryptFile.mutateAsync({ -// location_id: props.location_id, -// file_path_ids: [props.path_id], -// output_path: data.outputPath !== '' ? data.outputPath : null, -// mount_associated_key: data.mountAssociatedKey, -// password: data.type === 'password' ? data.password : null, -// save_to_library: data.type === 'password' ? data.saveToKeyManager : null -// }) -// )} -// title="Decrypt a file" -// description="Leave the output file blank for the default." -// loading={decryptFile.isLoading} -// ctaLabel="Decrypt" -// > -//
-//

Key Type

-// form.setValue('type', e)} -// className="mt-2 flex flex-row gap-2" -// > -// -// {({ checked }) => ( -// -// )} -// -// -// {({ checked }) => ( -// -// )} -// -// - -// {form.watch('type') === 'key' && ( -//
-// form.setValue('mountAssociatedKey', e)} -// /> -// -// Automatically mount key -// -// -// -// -//
-// )} - -// {form.watch('type') === 'password' && ( -// <> -// - -//
-// -// -// Save to Key Manager -// -// -// -// -//
-// -// )} - -//

Output file

-// -//
-//
-// ); -// }; diff --git a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx index bd9d2fdb0..8ce5a5184 100644 --- a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx @@ -110,7 +110,7 @@ export default (props: Props) => { dialog={useDialog(props)} title={t('delete_dialog_title', { prefix, type: translatedType })} description={description} - loading={deleteFile.isLoading} + loading={deleteFile.isPending} ctaLabel={t('delete_forever')} ctaSecondLabel={t('move_to_trash')} closeLabel={t('close')} diff --git a/interface/app/$libraryId/Explorer/FilePath/EncryptDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/EncryptDialog.tsx deleted file mode 100644 index 9daf21a7a..000000000 --- a/interface/app/$libraryId/Explorer/FilePath/EncryptDialog.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// import { -// Algorithm, -// hashingAlgoSlugSchema, -// slugFromHashingAlgo, -// useLibraryMutation, -// useLibraryQuery -// } from '@sd/client'; -// import { Button, Dialog, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui'; -// import { CheckBox, useZodForm, z } from '@sd/ui/src/forms'; -// import { showAlertDialog } from '~/components'; -// import { usePlatform } from '~/util/Platform'; -// import { KeyListSelectOptions } from '../../KeyManager/List'; - -// interface Props extends UseDialogProps { -// location_id: number; -// path_id: number; -// } - -// const schema = z.object({ -// key: z.string(), -// encryptionAlgo: z.string(), -// hashingAlgo: hashingAlgoSlugSchema, -// metadata: z.boolean(), -// previewMedia: z.boolean(), -// outputPath: z.string() -// }); - -// export default (props: Props) => { -// const platform = usePlatform(); - -// const UpdateKey = (uuid: string) => { -// form.setValue('key', uuid); -// const hashAlg = keys.data?.find((key) => { -// return key.uuid === uuid; -// })?.hashing_algorithm; -// hashAlg && form.setValue('hashingAlgo', slugFromHashingAlgo(hashAlg)); -// }; - -// const keys = useLibraryQuery(['keys.list']); -// const mountedUuids = useLibraryQuery(['keys.listMounted'], { -// onSuccess: (data) => { -// UpdateKey(data[0] ?? ''); -// } -// }); - -// const encryptFile = useLibraryMutation('files.encryptFiles', { -// onSuccess: () => { -// showAlertDialog({ -// title: 'Success', -// value: 'The encryption job has started successfully. You may track the progress in the job overview panel.' -// }); -// }, -// onError: () => { -// showAlertDialog({ -// title: 'Error', -// value: 'The encryption job failed to start.' -// }); -// } -// }); - -// const form = useZodForm({ -// defaultValues: { encryptionAlgo: 'XChaCha20Poly1305', outputPath: '' }, -// schema -// }); - -// return ( -// -// encryptFile.mutateAsync({ -// algorithm: data.encryptionAlgo as Algorithm, -// key_uuid: data.key, -// location_id: props.location_id, -// file_path_ids: [props.path_id], -// metadata: data.metadata, -// preview_media: data.previewMedia -// }) -// )} -// dialog={useDialog(props)} -// title="Encrypt a file" -// description="Configure your encryption settings. Leave the output file blank for the default." -// loading={encryptFile.isLoading} -// ctaLabel="Encrypt" -// > -//
-//
-// Key -// -//
-//
-// Output file - -// -//
-//
- -//
-//
-// Encryption -// -//
-//
-// Hashing -// -//
-//
- -//
-//
-// Metadata -// -//
-//
-// Preview Media -// -//
-//
-//
-// ); -// }; diff --git a/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx deleted file mode 100644 index 4bd84d28e..000000000 --- a/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// import { useState } from 'react'; -// import { FilePath, useLibraryMutation, useZodForm } from '@sd/client'; -// import { Dialog, Slider, useDialog, UseDialogProps, z } from '@sd/ui'; -// import { useLocale } from '~/hooks'; - -// interface Props extends UseDialogProps { -// locationId: number; -// filePaths: FilePath[]; -// } - -// const schema = z.object({ -// passes: z.number() -// }); - -// export default (props: Props) => { -// const { t } = useLocale(); -// const eraseFile = useLibraryMutation('files.eraseFiles'); - -// const form = useZodForm({ -// schema, -// defaultValues: { -// passes: 4 -// } -// }); - -// const [passes, setPasses] = useState([4]); - -// return ( -// -// eraseFile.mutateAsync({ -// location_id: props.locationId, -// file_path_ids: props.filePaths.map((p) => p.id), -// passes: data.passes.toString() -// }) -// )} -// dialog={useDialog(props)} -// title={t('erase_a_file')} -// description={t('erase_a_file_description')} -// loading={eraseFile.isLoading} -// ctaLabel={t('erase')} -// > -//
-// {t('number_of_passes')} - -//
-//
-// { -// setPasses(val); -// form.setValue('passes', val[0] ?? 1); -// }} -// /> -//
-// {passes} -//
-//
- -// {/*

TODO: checkbox for "erase all matching files" (only if a file is selected)

*/} -//
-// ); -// }; diff --git a/interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx b/interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx new file mode 100644 index 000000000..9290ca589 --- /dev/null +++ b/interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx @@ -0,0 +1,40 @@ +import React, { Component, ReactNode } from 'react'; + +interface ErrorBarrierProps { + onError: (error: Error, info: React.ErrorInfo) => void; + children: ReactNode; +} + +interface ErrorBarrierState { + hasError: boolean; +} + +export class ErrorBarrier extends Component { + constructor(props: ErrorBarrierProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error) { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + // Call the onError function passed as a prop + this.props.onError(error, info); + // Reset the error state after calling onError + Promise.resolve().then(() => this.setState({ hasError: false })); + } + + render() { + if (this.state.hasError) { + // Render nothing since the parent component will handle the error + return null; + } + + return this.props.children; + } +} + +export default ErrorBarrier; diff --git a/interface/app/$libraryId/Explorer/FilePath/Image.tsx b/interface/app/$libraryId/Explorer/FilePath/Image.tsx index 5be914315..e7036595b 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Image.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Image.tsx @@ -1,14 +1,7 @@ import { ComponentProps, forwardRef } from 'react'; -import { useSize } from './utils'; - -export interface ImageProps extends ComponentProps<'img'> { - extension?: string; - size: ReturnType; -} - -export const Image = forwardRef( - ({ crossOrigin, size, ...props }, ref) => ( +export const Image = forwardRef>( + ({ crossOrigin, ...props }, ref) => ( { +interface LayeredFileIconProps extends Omit, 'src'> { kind: ObjectKindKey; + isDir: boolean; extension: string | null; + customIcon: IconTypes | null; } const SUPPORTED_ICONS = ['Document', 'Code', 'Text', 'Config']; @@ -17,8 +20,18 @@ const positionConfig: Record = { }; const LayeredFileIcon = forwardRef( - ({ kind, extension, ...props }, ref) => { - const iconImg = ; + ({ kind, isDir, extension, customIcon, ...props }, ref) => { + const isDark = useIsDark(); + + const src = useMemo( + () => + customIcon + ? getIconByName(customIcon, isDark) + : getIcon(kind, isDark, extension, isDir), + [customIcon, isDark, kind, extension, isDir] + ); + + const iconImg = {`${kind}; if (SUPPORTED_ICONS.includes(kind) === false) { return iconImg; diff --git a/interface/app/$libraryId/Explorer/FilePath/Original.tsx b/interface/app/$libraryId/Explorer/FilePath/Original.tsx index 451b13d35..6b319793e 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Original.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Original.tsx @@ -8,27 +8,28 @@ import { useState, type VideoHTMLAttributes } from 'react'; -import { getItemFilePath, useLibraryContext } from '@sd/client'; +import { ObjectKindKey, useLibraryContext } from '@sd/client'; import i18n from '~/app/I18n'; import { PDFViewer, TextViewer } from '~/components'; -import { useLocale } from '~/hooks'; +import { useIsDark, useLocale } from '~/hooks'; import { pdfViewerEnabled } from '~/util/pdfViewer'; import { usePlatform } from '~/util/Platform'; import { useExplorerContext } from '../Context'; import { explorerStore } from '../store'; -import { ExplorerItemData } from '../useExplorerItemData'; import { Image } from './Image'; import { useBlackBars, useSize } from './utils'; interface OriginalRendererProps { src: string; - className: string; - frameClassName: string; - itemData: ExplorerItemData; - isDark: boolean; + fileId: number | null; + locationId: number | null; + path: string | null; + className?: string; + frameClassName?: string; + kind: ObjectKindKey; + extension: string | null; childClassName?: string; - size?: number; magnification?: number; mediaControls?: boolean; frame?: boolean; @@ -37,44 +38,53 @@ interface OriginalRendererProps { blackBars?: boolean; blackBarsSize?: number; onLoad?(): void; - onError?(e: ErrorEvent | SyntheticEvent): void; } export function Original({ - itemData, - filePath, + path, + fileId, + locationId, ...props -}: Omit & { - filePath: ReturnType; -}) { - const [error, setError] = useState(false); - if (error) throw new Error('onError'); +}: Omit) { + const [error, setError] = useState(null); + if (error != null) throw error; const Renderer = useMemo(() => { - const kind = originalRendererKind(itemData); + const kind = originalRendererKind(props.kind, props.extension); return ORIGINAL_RENDERERS[kind]; - }, [itemData]); + }, [props.kind, props.extension]); if (!Renderer) throw new Error('no renderer!'); const platform = usePlatform(); const { library } = useLibraryContext(); const { parent } = useExplorerContext(); + locationId = locationId ?? (parent?.type === 'Location' ? parent.location.id : null); const src = useMemo(() => { - const locationId = - itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null); - - if (filePath && (itemData.extension !== 'pdf' || pdfViewerEnabled())) { - if ('id' in filePath && locationId) - return platform.getFileUrl(library.uuid, locationId, filePath.id); - else if ('path' in filePath) return platform.getFileUrlByPath(filePath.path); + if (props.extension !== 'pdf' || pdfViewerEnabled()) { + if (fileId != null && locationId) + return platform.getFileUrl(library.uuid, locationId, fileId); + else if (path) return platform.getFileUrlByPath(path); } - }, [itemData, filePath, library.uuid, parent, platform]); + }, [props.extension, fileId, locationId, platform, library.uuid, path]); if (src === undefined) throw new Error('no src!'); - return setError(true)} {...props} />; + return ( + + setError( + ('error' in event && event.error instanceof Error && event.error) || + new Error( + ('message' in event && event.message) || 'Filetype is not supported yet' + ) + ) + } + {...props} + /> + ); } const TEXT_RENDERER: OriginalRenderer = (props) => ( @@ -89,18 +99,20 @@ const TEXT_RENDERER: OriginalRenderer = (props) => ( props.frame && [props.frameClassName, '!bg-none p-2'] )} codeExtension={ - ((props.itemData.kind === 'Code' || props.itemData.kind === 'Config') && - props.itemData.extension) || - '' + ((props.kind === 'Code' || props.kind === 'Config') && props.extension) || '' } isSidebarPreview={props.isSidebarPreview} /> ); -type OriginalRenderer = (props: OriginalRendererProps) => JSX.Element; +type OriginalRenderer = ( + props: Omit & { + onError?(e: ErrorEvent | SyntheticEvent): void; + } +) => JSX.Element; -function originalRendererKind(itemData: ExplorerItemData) { - return itemData.extension === 'pdf' ? 'PDF' : itemData.kind; +function originalRendererKind(kind: ObjectKindKey, extension: string | null) { + return extension === 'pdf' ? 'PDF' : kind; } type OriginalRendererKind = ReturnType; @@ -135,44 +147,45 @@ const ORIGINAL_RENDERERS: { )} /> ), - Audio: (props) => ( - <> - - {props.mediaControls && ( - - )} - - ), + Audio: (props) => { + const isDark = useIsDark(); + return ( + <> + + {props.mediaControls && ( + + )} + + ); + }, Image: (props) => { const ref = useRef(null); - const size = useSize(ref); return (
{ + cover?: boolean; + blackBars?: boolean; + blackBarsSize?: number; + videoExtension?: string; +} + +const Thumbnail = memo( + forwardRef( + ( + { + blackBars, + blackBarsSize, + videoExtension: extension, + cover, + className, + style, + ...props + }, + _ref + ) => { + const ref = useRef(null); + useImperativeHandle( + _ref, + () => ref.current + ); + + const size = useSize(ref); + + const { style: blackBarsStyle } = useBlackBars(ref, size, { + size: blackBarsSize, + disabled: !blackBars + }); + + return ( + <> + + + {(cover || size.width > 80) && extension && ( +
+ {extension} +
+ )} + + ); + } + ) +); + +interface ThumbProps extends ThumbnailProps { + src?: string; + kind: ObjectKindKey; + path: string | null; + isDir: boolean; + frame: boolean; + fileId: number | null; + onLoad: () => void; + onError: (error: Error | ErrorEvent | SyntheticEvent) => void; + thumbType: ThumbType; + extension: string | null; + customIcon: IconTypes | null; + locationId: number | null; + pauseVideo: boolean; + magnification: number; + mediaControls: boolean; + frameClassName: string; + isSidebarPreview: boolean; +} + +const Thumb = memo( + forwardRef( + ( + { + src, + kind, + path, + frame, + isDir, + cover, + fileId, + thumbType, + extension, + blackBars, + className, + pauseVideo, + locationId, + customIcon, + magnification, + mediaControls, + blackBarsSize, + videoExtension, + frameClassName, + isSidebarPreview, + onLoad, + ...props + }, + _ref + ) => { + const ref = useRef(null); + useImperativeHandle( + _ref, + () => ref.current + ); + const [isLoading, setIsLoading] = useState(true); + + const handleLoad = useCallback(() => { + const img = ref.current; + setIsLoading(!(img == null || (img.naturalHeight > 0 && img.naturalWidth > 0))); + onLoad?.(); + }, [onLoad]); + + let thumb: JSX.Element | null = null; + + switch (thumbType) { + case 'original': + thumb = ( + + ); + break; + case 'thumbnail': + thumb = ( + + ); + break; + } + + return ( + <> + {}} + decoding="sync" + draggable={false} + extension={extension} + className={clsx(ThumbClasses, className, !isLoading && 'hidden')} + customIcon={customIcon} + /> + {thumb ?? null} + + ); + } + ) +); + +export interface FileThumbProps { data: ExplorerItem; loadOriginal?: boolean; size?: number; cover?: boolean; frame?: boolean; - onLoad?: (state: ThumbType) => void; - onError?: (state: ThumbType, error: Error) => void; + onLoad?: (type: ThumbType) => void; + onError?: (state: LoadState, error: Error) => void; blackBars?: boolean; blackBarsSize?: number; extension?: boolean; @@ -43,253 +259,181 @@ export interface ThumbProps { magnification?: number; } -type ThumbType = { variant: 'original' } | { variant: 'thumbnail' } | { variant: 'icon' }; -type LoadState = { - [K in 'original' | 'thumbnail' | 'icon']: 'notLoaded' | 'loaded' | 'error'; -}; +/** + * This component is used to render a thumbnail of a file or folder. + * It will automatically choose the best thumbnail to display based on the item data. + * + * .. WARNING:: + * This Component is heavely used inside the explorer, and as such it is a performance critical component. + * Be careful with the performance of the code, make sure to always memoize any objects or functions to avoid unnecessary re-renders. + * + */ +export const FileThumb = memo( + forwardRef((props, ref) => { + const frame = useFrame(); + const platform = usePlatform(); + const itemData = useExplorerItemData(props.data); + const filePath = getItemFilePath(props.data); + const { library } = useLibraryContext(); + const [loadState, setLoadState] = useState({ + icon: 'normal', + original: 'normal', + thumbnail: 'normal' + }); -export const FileThumb = forwardRef((props, ref) => { - const isDark = useIsDark(); - const platform = usePlatform(); - const frame = useFrame(); + // WARNING: This is required so QuickPreview can work properly + useEffect(() => { + setLoadState({ + icon: 'normal', + original: 'normal', + thumbnail: 'normal' + }); + }, [props.data]); - const itemData = useExplorerItemData(props.data); - const filePath = getItemFilePath(props.data); + const thumbType = useMemo((): ThumbType => { + if (loadState.original !== 'error' && props.loadOriginal) return 'original'; + if (loadState.thumbnail !== 'error' && itemData.hasLocalThumbnail) return 'thumbnail'; + return 'icon'; + }, [itemData.hasLocalThumbnail, loadState, props.loadOriginal]); - const { library } = useLibraryContext(); - - const [loadState, setLoadState] = useState({ - original: 'notLoaded', - thumbnail: 'notLoaded', - icon: 'notLoaded' - }); - - const childClassName = 'max-h-full max-w-full object-contain'; - const frameClassName = clsx(frame.className, props.frameClassName); - - const thumbType = useMemo(() => { - const thumbType = 'thumbnail'; - - if (thumbType === 'thumbnail') - if ( - loadState.thumbnail !== 'error' && - itemData.hasLocalThumbnail && - itemData.thumbnailKey - ) - return { variant: 'thumbnail' }; - - return { variant: 'icon' }; - }, [itemData, loadState]); - - const src = useMemo(() => { - switch (thumbType.variant) { - case 'original': - if (filePath && (itemData.extension !== 'pdf' || pdfViewerEnabled())) { - if ('id' in filePath && itemData.locationId) - return platform.getFileUrl(library.uuid, itemData.locationId, filePath.id); - else if ('path' in filePath) return platform.getFileUrlByPath(filePath.path); + useEffect(() => { + let timeoutId = null; + // Reload thumbnail when it gets a notification from core that it has been generated + if (thumbType === 'icon' && loadState.thumbnail === 'error') { + for (const [, thumbId] of itemData.thumbnails) { + if (thumbId == null || !explorerStore.newThumbnails.has(thumbId)) continue; + // HACK: Delay removing the new thumbnail event from store + // to avoid some weird race condition with core that prevents + // us from accessing the new thumbnail immediately after it is created + timeoutId = setTimeout(() => explorerStore.removeThumbnail(thumbId), 0); + explorerStore.removeThumbnail(thumbId); + setLoadState((state) => ({ ...state, thumbnail: 'normal' })); + break; } - break; + } - case 'thumbnail': - if (itemData.thumbnailKey) - return platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey); + return () => void (timeoutId && clearTimeout(timeoutId)); + }, [itemData.thumbnails, loadState.thumbnail, thumbType]); - break; - case 'icon': - if (itemData.customIcon) return getIconByName(itemData.customIcon as any, isDark); + const src = useMemo(() => { + switch (thumbType) { + case 'original': + if (filePath && (itemData.extension !== 'pdf' || pdfViewerEnabled())) { + if ('id' in filePath && itemData.locationId) + return platform.getFileUrl( + library.uuid, + itemData.locationId, + filePath.id + ); + else if ('path' in filePath) + return platform.getFileUrlByPath(filePath.path); + else setLoadState((state) => ({ ...state, [thumbType]: 'error' })); + } + break; - return getIcon( - // itemData.isDir || parent?.type === 'Node' ? 'Folder' : - itemData.kind, - isDark, - itemData.extension, - itemData.isDir - ); - } - }, [filePath, isDark, library.uuid, itemData, platform, thumbType]); + case 'thumbnail': { + const thumbnail = Array.from(itemData.thumbnails.keys()).find((key) => key); + if (thumbnail) return thumbnail; + else setLoadState((state) => ({ ...state, [thumbType]: 'error' })); - const onLoad = (s: 'original' | 'thumbnail' | 'icon') => { - setLoadState((state) => ({ ...state, [s]: 'loaded' })); - props.onLoad?.call(null, thumbType); - }; + break; + } + } + }, [ + filePath, + itemData.extension, + itemData.locationId, + itemData.thumbnails, + library.uuid, + platform, + thumbType + ]); - const onError = ( - s: 'original' | 'thumbnail' | 'icon', - event: ErrorEvent | SyntheticEvent - ) => { - setLoadState((state) => ({ ...state, [s]: 'error' })); + const onError = useCallback( + (event: Error | ErrorEvent | SyntheticEvent) => { + const rawError = + event instanceof Error + ? event + : ('error' in event && event.error) || + ('message' in event && event.message) || + 'Filetype is not supported yet'; - const rawError = - ('error' in event && event.error) || - ('message' in event && event.message) || - 'Filetype is not supported yet'; - - props.onError?.call( - null, - thumbType, - rawError instanceof Error ? rawError : new Error(rawError) + setLoadState((state) => { + state = { ...state, [thumbType]: 'error' }; + props.onError?.call( + null, + state, + rawError instanceof Error ? rawError : new Error(rawError) + ); + return state; + }); + }, + [props.onError, thumbType] ); - }; - const _childClassName = - typeof props.childClassName === 'function' - ? props.childClassName(thumbType) - : props.childClassName; + const onLoad = useCallback(() => { + props.onLoad?.call(null, thumbType); + }, [props.onLoad, thumbType]); - const className = clsx(childClassName, _childClassName); - - const thumbnail = (() => { - if (!src) return <>; - - switch (thumbType.variant) { - case 'thumbnail': - return ( - onLoad('thumbnail')} - onError={(e) => onError('thumbnail', e)} - decoding={props.size ? 'async' : 'sync'} - className={clsx( - props.cover - ? [ - 'min-h-full min-w-full object-cover object-center', - _childClassName - ] - : className, - props.frame && !(itemData.kind === 'Video' && props.blackBars) - ? frameClassName - : null - )} - crossOrigin="anonymous" // Here it is ok, because it is not a react attr - blackBars={props.blackBars && itemData.kind === 'Video' && !props.cover} - blackBarsSize={props.blackBarsSize} - extension={ - props.extension && itemData.extension && itemData.kind === 'Video' - ? itemData.extension - : undefined - } - /> - ); - - case 'icon': - return ( - + { + console.error('ErrorBoundary', error, info); + onError(error); + }, + [onError] + )} + > + onLoad('icon')} - onError={(e) => onError('icon', e)} - decoding={props.size ? 'async' : 'sync'} - className={className} - draggable={false} - /> - ); - default: - return <>; - } - })(); - - return ( -
- {props.loadOriginal ? ( - - onLoad('original')} - onError={(e) => onError('original', e)} - filePath={filePath} - className={className} - frameClassName={frameClassName} - itemData={itemData} - isDark={isDark} - childClassName={childClassName} - size={props.size} - magnification={props.magnification} - mediaControls={props.mediaControls} - frame={props.frame} - isSidebarPreview={props.isSidebarPreview} - pauseVideo={props.pauseVideo} blackBars={props.blackBars} + className={ + typeof props.childClassName === 'function' + ? props.childClassName(thumbType) + : props.childClassName + } + customIcon={itemData.customIcon as IconTypes | null} + locationId={itemData.locationId} + pauseVideo={props.pauseVideo ?? false} blackBarsSize={props.blackBarsSize} + mediaControls={props.mediaControls ?? false} + magnification={props.magnification ?? 1} + frameClassName={clsx(frame.className, props.frameClassName)} + videoExtension={ + props.extension && itemData.extension && itemData.kind === 'Video' + ? itemData.extension + : undefined + } + isSidebarPreview={props.isSidebarPreview ?? false} /> - - ) : ( - thumbnail - )} -
- ); -}); - -interface ThumbnailProps extends Omit { - cover?: boolean; - blackBars?: boolean; - blackBarsSize?: number; - extension?: string; -} - -const Thumbnail = forwardRef( - ({ blackBars, blackBarsSize, extension, cover, className, style, ...props }, _ref) => { - const ref = useRef(null); - useImperativeHandle( - _ref, - () => ref.current +
+
); - - const size = useSize(ref); - - const { style: blackBarsStyle } = useBlackBars(ref, size, { - size: blackBarsSize, - disabled: !blackBars - }); - - return ( - <> - - - {(cover || size.width > 80) && extension && ( -
- {extension} -
- )} - - ); - } + }) ); diff --git a/interface/app/$libraryId/Explorer/Inspector/FavoriteButton.tsx b/interface/app/$libraryId/Explorer/Inspector/FavoriteButton.tsx index 97abc0d27..71eea2117 100644 --- a/interface/app/$libraryId/Explorer/Inspector/FavoriteButton.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/FavoriteButton.tsx @@ -14,7 +14,7 @@ export default function FavoriteButton(props: Props) { setFavorite(!!props.data?.favorite); }, [props.data]); - const { mutate: fileToggleFavorite, isLoading: isFavoriteLoading } = useLibraryMutation( + const { mutate: fileToggleFavorite, isPending: isFavoriteLoading } = useLibraryMutation( 'files.setFavorite' // { // onError: () => setFavorite(!!props.data?.favorite) diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 08b3255d0..7d0226396 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -154,7 +154,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => { i === 2 && 'z-10 !h-[84%] !w-[84%] rotate-[7deg]' )} childClassName={(type) => - type.variant !== 'icon' && thumbs.length > 1 + type !== 'icon' && thumbs.length > 1 ? 'shadow-md shadow-app-shade' : undefined } diff --git a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx index ad1b0fdc5..918af62c9 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx @@ -50,7 +50,7 @@ import ExplorerContextMenu, { SharedItems } from '../ContextMenu'; import { Conditional } from '../ContextMenu/ConditionalItem'; -import { FileThumb } from '../FilePath/Thumb'; +import { FileThumb, ThumbType } from '../FilePath/Thumb'; import { SingleItemMetadata } from '../Inspector'; import { explorerStore } from '../store'; import { useExplorerViewContext } from '../View/Context'; @@ -84,19 +84,31 @@ export const QuickPreview = () => { const { open, itemIndex } = useQuickPreviewStore(); const thumb = createRef(); - const [thumbErrorToast, setThumbErrorToast] = useState(); const [showMetadata, setShowMetadata] = useState(false); const [magnification, setMagnification] = useState(1); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [newName, setNewName] = useState(null); - const [thumbnailLoading, setThumbnailLoading] = useState<'notLoaded' | 'loaded' | 'error'>( - 'notLoaded' - ); + const [thumbnailLoading, setThumbnailLoading] = useState({ + icon: 'notLoaded', + thumbnail: 'notLoaded', + original: 'notLoaded' + } as { + [K in ThumbType]: 'notLoaded' | 'loaded' | 'error'; + }); // the purpose of these refs is to prevent "jittering" when zooming with trackpads, as the deltaY value can be very high const deltaYRef = useRef(0); const lastZoomTimeRef = useRef(0); + const hasError = useMemo( + () => Object.values(thumbnailLoading).some((status) => status === 'error'), + [thumbnailLoading] + ); + const isLoaded = useMemo( + () => Object.values(thumbnailLoading).some((status) => status === 'loaded'), + [thumbnailLoading] + ); + const { t } = useLocale(); const items = useMemo(() => { @@ -122,50 +134,26 @@ export const QuickPreview = () => { const renameFile = useLibraryMutation(['files.renameFile'], { onError: () => setNewName(null), - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], { onError: () => setNewName(null), - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const changeCurrentItem = (index: number) => { if (items[index]) getQuickPreviewStore().itemIndex = index; }; - // Error toast - useEffect(() => { - if (!thumbErrorToast) return; - - let id: string | number | undefined; - toast.error( - (_id) => { - id = _id; - return thumbErrorToast; - }, - { - ref: thumb, - duration: Infinity, - onClose() { - id = undefined; - setThumbErrorToast(undefined); - } - } - ); - - return () => void toast.dismiss(id); - }, [thumb, thumbErrorToast]); - // Reset state useEffect(() => { setNewName(null); - setThumbErrorToast(undefined); setMagnification(1); + setThumbnailLoading({ icon: 'notLoaded', thumbnail: 'notLoaded', original: 'notLoaded' }); if (open || item) return; - setThumbnailLoading('notLoaded'); getQuickPreviewStore().open = false; getQuickPreviewStore().itemIndex = 0; setShowMetadata(false); @@ -344,18 +332,12 @@ export const QuickPreview = () => { )} >
- {thumbnailLoading !== 'error' && - thumbnailLoading !== 'notLoaded' && - background && ( -
- -
-
- )} + {!hasError && isLoaded && background && ( +
+ +
+
+ )}
{ - {thumbnailLoading === 'error' && ( - -
- -

- {t('quickpreview_thumbnail_error_message')} -

-
-
- )} + {thumbnailLoading.original === 'error' && + thumbnailLoading.thumbnail === 'loaded' && ( + +
+ +

+ {t( + 'quickpreview_thumbnail_error_message' + )} +

+
+
+ )} {items.length > 1 && (
@@ -616,56 +603,42 @@ export const QuickPreview = () => {
- {thumbnailLoading === 'error' ? ( - <> - - - ) : ( - { - setThumbnailLoading('loaded'); - if (type.variant === 'original') - setThumbErrorToast(undefined); - }} - onError={(type, error) => { - setThumbnailLoading('error'); - if (type.variant === 'original') - setThumbErrorToast({ - title: t('error_loading_original_file'), - body: error.message - }); - }} - loadOriginal - frameClassName="!border-0" - mediaControls - className={clsx( - thumbnailLoading === 'notLoaded' && 'hidden', - 'm-3 !w-auto flex-1 !overflow-hidden rounded', - !background && !icon && 'bg-app-box shadow' - )} - childClassName={clsx( - 'rounded', - kind === 'Text' && 'p-3', - !icon && 'h-full', - textKinds.includes(kind) && 'select-text' - )} - magnification={magnification} - /> - )} + { + setThumbnailLoading((obj) => ({ + ...obj, + [type]: 'loaded' + })); + }} + onError={(state, error) => { + console.error(error); + setThumbnailLoading((obj) => { + const newState = { ...obj }; + for (const [type, loadState] of Object.entries( + state + ) as [ThumbType, string][]) + if (loadState === 'error') newState[type] = 'error'; + + return newState; + }); + }} + loadOriginal + frameClassName="!border-0" + mediaControls + className={clsx( + !isLoaded && 'hidden', + 'm-3 !w-auto flex-1 !overflow-hidden rounded', + !background && !icon && 'bg-app-box shadow' + )} + childClassName={clsx( + 'rounded', + kind === 'Text' && 'p-3', + !icon && 'h-full', + textKinds.includes(kind) && 'select-text' + )} + magnification={magnification} + /> {explorerLayoutStore.showImageSlider && activeItem && ( diff --git a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx index e58a56787..e93f85355 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/DragSelect/index.tsx @@ -13,8 +13,16 @@ import { getElementIndex, SELECTABLE_DATA_ATTRIBUTE } from './util'; const CHROME_REGEX = /Chrome/; +type GridOpts = ReturnType>; + interface Props extends PropsWithChildren { - grid: ReturnType>; + columnCount: GridOpts['columnCount']; + gapY: GridOpts['gap']['y']; + getItem: GridOpts['getItem']; + totalColumnCount: GridOpts['totalColumnCount']; + totalCount: GridOpts['totalCount']; + totalRowCount: GridOpts['totalRowCount']; + virtualItemHeight: GridOpts['virtualItemHeight']; } export interface Drag { @@ -24,7 +32,7 @@ export interface Drag { endRow: number; } -export const DragSelect = ({ grid, children }: Props) => { +export const DragSelect = ({ children, ...props }: Props) => { const isChrome = CHROME_REGEX.test(navigator.userAgent); const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem(); @@ -62,7 +70,7 @@ export const DragSelect = ({ grid, children }: Props) => { function getGridItem(element: Element) { const index = getElementIndex(element); - return (index !== null && grid.getItem(index)) || undefined; + return (index !== null && props.getItem(index)) || undefined; } function handleScroll(e: SelectoEvents['scroll']) { @@ -176,9 +184,9 @@ export const DragSelect = ({ grid, children }: Props) => { // that are still in the DOM const elements: Element[] = []; - e.added.forEach((element) => { + for (const element of e.added) { const item = getGridItem(element); - if (!item?.data) return; + if (!item?.data) continue; // Add item to selected targets // Don't update selecto as it's already aware of it @@ -188,22 +196,22 @@ export const DragSelect = ({ grid, children }: Props) => { explorer.addSelectedItem(item.data); if (document.contains(element)) elements.push(element); - }); + } - e.removed.forEach((element) => { + for (const element of e.removed) { const item = getGridItem(element); - if (!item?.data) return; + if (!item?.data) continue; // Remove item from selected targets // Don't update selecto as it's already aware of it selectedTargets.removeSelectedTarget(String(item.id), { updateSelecto: false }); // Don't deselect item if element is unmounted by scroll - if (!document.contains(element)) return; + if (!document.contains(element)) continue; explorer.removeSelectedItem(item.data); elements.push(element); - }); + } const dragDirection = { x: inputEvent.x === e.rect.left ? 'left' : 'right', @@ -280,7 +288,7 @@ export const DragSelect = ({ grid, children }: Props) => { const addedRows = new Set(); const removedRows = new Set(); - columns.forEach((column) => { + for (const column of columns) { const { firstItem, lastItem } = columnItems[column]!; const { row: firstRow } = firstItem.item; @@ -353,7 +361,7 @@ export const DragSelect = ({ grid, children }: Props) => { // Remove row if dragged out of the last grid item // from a row that's above it - if (item.item.index === grid.totalCount - 1) { + if (item.item.index === props.totalCount - 1) { removedRows.add(item.item.row); } } @@ -372,21 +380,21 @@ export const DragSelect = ({ grid, children }: Props) => { // caches multiple rows at once, and the first one being removed if ( !isFirstRowInDrag && - firstRow === grid.totalRowCount - 2 && - firstItem.item.index + grid.totalColumnCount > grid.totalCount - 1 + firstRow === props.totalRowCount - 2 && + firstItem.item.index + props.totalColumnCount > props.totalCount - 1 ) { removedColumns.add(column); } // Return if first row equals the first/last row of the grid (depending on drag direction) // as there's no items to be selected beyond that point - if (!drag.current && (firstRow === 0 || firstRow === grid.totalRowCount - 1)) { - return; + if (!drag.current && (firstRow === 0 || firstRow === props.totalRowCount - 1)) { + continue; } // Return if column is already in drag range if (isColumnInDrag && isColumnInDragRange) { - return; + continue; } const viewTop = explorerView.ref.current?.getBoundingClientRect().top ?? 0; @@ -397,9 +405,9 @@ export const DragSelect = ({ grid, children }: Props) => { const hasEmptySpace = dragDirection.y === 'down' ? dragStart.y < itemTop : dragStart.y > itemBottom; - if (!hasEmptySpace) return; + if (!hasEmptySpace) continue; - // Get the heigh of the empty drag space between the start of the drag + // Get the height of the empty drag space between the start of the drag // and the first visible item const emptySpaceHeight = Math.abs( dragStart.y - (dragDirection.y === 'down' ? itemTop : itemBottom) @@ -407,8 +415,8 @@ export const DragSelect = ({ grid, children }: Props) => { // Check how many items we can fit into the empty space let itemsInEmptySpace = - (emptySpaceHeight - (grid.gap.y ?? 0)) / - (grid.virtualItemHeight + (grid.gap.y ?? 0)); + (emptySpaceHeight - (props.gapY ?? 0)) / + (props.virtualItemHeight + (props.gapY ?? 0)); if (itemsInEmptySpace > 1) { itemsInEmptySpace = Math.ceil(itemsInEmptySpace); @@ -416,15 +424,15 @@ export const DragSelect = ({ grid, children }: Props) => { itemsInEmptySpace = Math.round(itemsInEmptySpace); } - [...Array(itemsInEmptySpace)].forEach((_, i) => { + for (let i = 0; i < itemsInEmptySpace; i++) { i = dragDirection.y === 'down' ? itemsInEmptySpace - i : i + 1; const explorerItemIndex = firstItem.item.index + - (dragDirection.y === 'down' ? -i : i) * grid.columnCount; + (dragDirection.y === 'down' ? -i : i) * props.columnCount; - const item = grid.getItem(explorerItemIndex); - if (!item?.data) return; + const item = props.getItem(explorerItemIndex); + if (!item?.data) continue; // Set start row if not already set if (!drag.current && i === itemsInEmptySpace - 1) { @@ -438,13 +446,13 @@ export const DragSelect = ({ grid, children }: Props) => { explorer.addSelectedItem(item.data); } - return; + continue; } if (!isItemInDrag) explorer.removeSelectedItem(item.data); else explorer.addSelectedItem(item.data); - }); - }); + } + } const addedColumnsArray = [...addedColumns]; const removedColumnsArray = [...removedColumns]; diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx index aeb40ee0f..a9d01994f 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx @@ -52,9 +52,10 @@ export const GridViewItem = memo((props: GridViewItemProps) => { ); }); -const InnerDroppable = () => { +const InnerDroppable = memo(() => { const item = useGridViewItemContext(); const { isDroppable } = useExplorerDroppableContext(); + return ( <>
{ (item.selected || isDroppable) && 'bg-app-selectedItem' )} > - +
); -}; +}); -const ItemFileThumb = () => { +const ItemFileThumb = memo((props: GridViewItemProps) => { const frame = useFrame(); - - const item = useGridViewItemContext(); - const isLabel = item.data.type === 'Label'; - const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({ - data: item.data + data: props.data }); + const isLabel = props.data.type === 'Label'; + return ( ({ + style, + ...attributes, + ...listeners + }), + [style, attributes, listeners] + )} /> ); -}; +}); -const ItemMetadata = () => { +const ItemMetadata = memo(() => { const item = useGridViewItemContext(); const { isDroppable } = useExplorerDroppableContext(); const explorerLayout = useExplorerLayoutStore(); @@ -123,9 +125,9 @@ const ItemMetadata = () => { {item.data.type === 'Label' && } ); -}; +}); -const ItemTags = () => { +const ItemTags = memo(() => { const item = useGridViewItemContext(); const object = getItemObject(item.data); const filePath = getItemFilePath(item.data); @@ -150,9 +152,9 @@ const ItemTags = () => { ))}
); -}; +}); -const ItemSize = () => { +const ItemSize = memo(() => { const item = useGridViewItemContext(); const { showBytesInGridView } = useExplorerContext().useSettingsSnapshot(); const isRenaming = useSelector(explorerStore, (s) => s.isRenaming); @@ -186,9 +188,9 @@ const ItemSize = () => { {`${bytes}`}
); -}; +}); -function LabelItemCount({ data }: { data: Extract }) { +const LabelItemCount = memo(({ data }: { data: Extract }) => { const { t } = useLocale(); const count = useLibraryQuery([ @@ -202,11 +204,11 @@ function LabelItemCount({ data }: { data: Extract {t('item_with_count', { count: count.data })}
); -} +}); diff --git a/interface/app/$libraryId/Explorer/View/GridView/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/index.tsx index b5055ee49..507e5ee7d 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/index.tsx @@ -1,5 +1,5 @@ import { Grid, useGrid } from '@virtual-grid/react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback } from 'react'; import { useExplorerLayoutStore } from '@sd/client'; import { useExplorerContext } from '../../Context'; @@ -50,7 +50,15 @@ export const GridView = () => { useKeySelection(grid, { scrollToEnd: true }); return ( - + {(index) => { const item = explorer.items?.[index]; diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx index fc6ac953d..e792a19ca 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -130,47 +130,49 @@ export const ListView = memo(() => { const [backRange, frontRange] = getRangesByRow(range.start); if (backRange && frontRange) { - [...Array(backRange.sorted.end.index - backRange.sorted.start.index + 1)].forEach( - (_, i) => { - const index = backRange.sorted.start.index + i; + for (let i = backRange.sorted.start.index; i <= backRange.sorted.end.index; i++) { + const index = backRange.sorted.start.index + i; - if (index === range.start.index) return; + if (index === range.start.index) continue; - const row = rows[index]; + const row = rows[index]; - if (row) explorer.removeSelectedItem(row.original); - } - ); + if (row) explorer.removeSelectedItem(row.original); + } _ranges = _ranges.filter((_, i) => i !== backRange.index); } - [...Array(Math.abs(range.end.index - rowIndex) + (changeDirection ? 1 : 0))].forEach( - (_, i) => { - if (!range.direction || direction === range.direction) i += 1; + for ( + let i = 0; + i < Math.abs(range.end.index - rowIndex) + (changeDirection ? 1 : 0); + i++ + ) { + if (!range.direction || direction === range.direction) i += 1; - const index = range.end.index + (direction === 'down' ? i : -i); + const index = range.end.index + (direction === 'down' ? i : -i); - const row = rows[index]; + const row = rows[index]; - if (!row) return; + if (!row) continue; - const item = row.original; + const item = row.original; - if (uniqueId(item) === uniqueId(range.start.original)) return; + if (uniqueId(item) === uniqueId(range.start.original)) continue; - if ( - !range.direction || - direction === range.direction || - (changeDirection && - (range.direction === 'down' - ? index < range.start.index - : index > range.start.index)) - ) { - explorer.addSelectedItem(item); - } else explorer.removeSelectedItem(item); + if ( + !range.direction || + direction === range.direction || + (changeDirection && + (range.direction === 'down' + ? index < range.start.index + : index > range.start.index)) + ) { + explorer.addSelectedItem(item); + } else { + explorer.removeSelectedItem(item); } - ); + } let newRangeEnd = item; let removeRangeIndex: number | null = null; @@ -186,15 +188,13 @@ export const ListView = memo(() => { rowIndex ); - [...Array(removableRowsCount)].forEach((_, i) => { - i += 1; - + for (let i = 1; i <= removableRowsCount; i++) { const index = rowIndex + (direction === 'down' ? i : -i); const row = rows[index]; if (row) explorer.removeSelectedItem(row.original); - }); + } removeRangeIndex = i; break; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx b/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx index c5b1652cf..dd71f2b31 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx @@ -57,7 +57,7 @@ const ItemFileThumb = (props: Pick) => { filePath?.hidden && 'opacity-50' )} ref={setDraggableRef} - childClassName={({ variant }) => clsx(variant === 'icon' && 'size-2/4')} + childClassName={(type) => clsx(type === 'icon' && 'size-2/4')} childProps={{ style, ...attributes, diff --git a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx index 6554d6a7d..cf8f6f866 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx @@ -82,11 +82,12 @@ export const MediaView = () => { let firstRowIndex: number | undefined = undefined; let lastRowIndex: number | undefined = undefined; + const scrollOffset = rowVirtualizer.scrollOffset ?? 0; // Find first row in viewport for (let i = 0; i < virtualRows.length; i++) { const row = virtualRows[i]!; - if (row.end >= rowVirtualizer.scrollOffset) { + if (row.end >= scrollOffset) { firstRowIndex = row.index; break; } @@ -95,7 +96,7 @@ export const MediaView = () => { // Find last row in viewport for (let i = virtualRows.length - 1; i >= 0; i--) { const row = virtualRows[i]!; - if (row.start <= rowVirtualizer.scrollOffset + rowVirtualizer.scrollRect.height) { + if (row.start <= scrollOffset + (rowVirtualizer.scrollRect?.height ?? 0)) { lastRowIndex = row.index; break; } @@ -163,15 +164,16 @@ export const MediaView = () => { ); } }, [ + isSortingByDate, + orderBy, + orderDirection, explorer.items, + rowVirtualizer.scrollOffset, + rowVirtualizer.scrollRect?.height, grid.columnCount, grid.options.count, - isSortingByDate, - rowVirtualizer.scrollOffset, - rowVirtualizer.scrollRect.height, - virtualRows, - orderBy, - orderDirection + dateFormat, + virtualRows ]); useKeySelection(grid); @@ -187,7 +189,15 @@ export const MediaView = () => { > {isSortingByDate && } - + {virtualRows.map((virtualRow) => ( {columnVirtualizer.getVirtualItems().map((virtualColumn) => { diff --git a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx index b3852082e..2b53e43c8 100644 --- a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx +++ b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx @@ -58,17 +58,17 @@ export const RenamableItemText = ({ const renameFile = useLibraryMutation(['files.renameFile'], { onError: () => reset(), - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], { onError: () => reset(), - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const renameLocation = useLibraryMutation(['locations.update'], { onError: () => reset(), - onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + onSuccess: () => rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }) }); const reset = useCallback(() => { diff --git a/interface/app/$libraryId/Explorer/View/ViewItem.tsx b/interface/app/$libraryId/Explorer/View/ViewItem.tsx index 2537b854f..087c8b808 100644 --- a/interface/app/$libraryId/Explorer/View/ViewItem.tsx +++ b/interface/app/$libraryId/Explorer/View/ViewItem.tsx @@ -192,14 +192,15 @@ export const useViewItemDoubleClick = () => { } }, [ - searchParams, explorer.selectedItems, explorer.settingsStore.openOnDoubleClick, - library.uuid, - navigate, openFilePaths, - openEphemeralFiles, - updateAccessTime + updateAccessTime, + library.uuid, + t, + searchParams, + navigate, + openEphemeralFiles ] ); diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 3dcb19261..2aa5971c0 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -61,7 +61,7 @@ export default function Explorer(props: PropsWithChildren) { // I had planned to somehow fetch the Object, but its a lot more work than its worth given // id have to fetch the file_path explicitly and patch the query // for now, it seems to work a treat just invalidating the whole query - rspc.queryClient.invalidateQueries(['search.paths']); + rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }); } } }); diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index 4814f972a..bb463ab11 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -123,8 +123,19 @@ export function flattenThumbnailKey(thumbKey: ThumbKey) { export const explorerStore = proxy({ ...state, reset: (_state?: typeof state) => resetStore(explorerStore, _state || state), - addNewThumbnail: (thumbKey: ThumbKey) => { - explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey)); + addNewThumbnail: (thumbKey: ThumbKey | string) => { + thumbKey = typeof thumbKey === 'string' ? thumbKey : flattenThumbnailKey(thumbKey); + // HACK: Ensure store propagates changes + const newThumbnails = new Set(explorerStore.newThumbnails); + newThumbnails.add(thumbKey); + explorerStore.newThumbnails = newThumbnails; + }, + removeThumbnail: (thumbKey: ThumbKey | string) => { + thumbKey = typeof thumbKey === 'string' ? thumbKey : flattenThumbnailKey(thumbKey); + // HACK: Ensure store propagates changes + const newThumbnails = new Set(explorerStore.newThumbnails); + newThumbnails.delete(thumbKey); + explorerStore.newThumbnails = newThumbnails; }, resetCache: () => { explorerStore.newThumbnails.clear(); diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 97af144b8..c656362c7 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -105,7 +105,7 @@ export function useExplorerSettings({ >; data?: T | null; }) { - const [store] = useState(() => proxy(settings)); + const store = useMemo(() => proxy(settings), [settings]); const updateSettings = useDebouncedCallback((settings: ExplorerSettings, data: T) => { onSettingsChanged?.(settings, data); @@ -149,15 +149,8 @@ function useSelectedItems(items: ExplorerItem[] | null) { const itemHashesWeakMap = useRef(new WeakMap()); // Store hashes of items instead as objects are unique by reference but we - // still need to differentate between item variants - const [selectedItemHashes, setSelectedItemHashes] = useState(() => ({ - value: new Set() - })); - - const updateHashes = useCallback( - () => setSelectedItemHashes((h) => ({ ...h })), - [setSelectedItemHashes] - ); + // still need to differentiate between item variants + const [selectedItemHashes, setSelectedItemHashes] = useState(() => new Set()); const itemsMap = useMemo( () => @@ -172,7 +165,7 @@ function useSelectedItems(items: ExplorerItem[] | null) { const selectedItems = useMemo( () => - [...selectedItemHashes.value].reduce((items, hash) => { + [...selectedItemHashes].reduce((items, hash) => { const item = itemsMap.get(hash); if (item) items.add(item.data); return items; @@ -194,37 +187,37 @@ function useSelectedItems(items: ExplorerItem[] | null) { (item: ExplorerItem | ExplorerItem[]) => { const items = Array.isArray(item) ? item : [item]; - for (let i = 0; i < items.length; i++) { - selectedItemHashes.value.add(getItemUniqueId(items[i]!)); - } - - updateHashes(); + setSelectedItemHashes((oldHashes) => { + const newHashes = new Set(oldHashes); + for (const it of items) newHashes.add(getItemUniqueId(it)); + return newHashes; + }); }, - [getItemUniqueId, selectedItemHashes.value, updateHashes] + [getItemUniqueId] ), removeSelectedItem: useCallback( (item: ExplorerItem | ExplorerItem[]) => { const items = Array.isArray(item) ? item : [item]; - - for (let i = 0; i < items.length; i++) { - selectedItemHashes.value.delete(getItemUniqueId(items[i]!)); - } - - updateHashes(); + setSelectedItemHashes((oldHashes) => { + const newHashes = new Set(oldHashes); + for (const it of items) newHashes.delete(getItemUniqueId(it)); + return newHashes; + }); }, - [getItemUniqueId, selectedItemHashes.value, updateHashes] + [getItemUniqueId] ), resetSelectedItems: useCallback( (items?: ExplorerItem[]) => { - selectedItemHashes.value.clear(); - items?.forEach((item) => selectedItemHashes.value.add(getItemUniqueId(item))); - updateHashes(); + if (items) { + const newHashes = new Set(); + for (const it of items) newHashes.add(getItemUniqueId(it)); + setSelectedItemHashes(newHashes); + } else { + setSelectedItemHashes(new Set()); + } }, - [getItemUniqueId, selectedItemHashes.value, updateHashes] + [getItemUniqueId] ), - isItemSelected: useCallback( - (item: ExplorerItem) => selectedItems.has(item), - [selectedItems] - ) + isItemSelected: (item: ExplorerItem) => selectedItems.has(item) }; } diff --git a/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx b/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx index 39c23511f..b32105624 100644 --- a/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx +++ b/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx @@ -1,5 +1,5 @@ import { useDraggable, UseDraggableArguments } from '@dnd-kit/core'; -import { CSSProperties, HTMLAttributes } from 'react'; +import { CSSProperties, HTMLAttributes, useCallback, useMemo } from 'react'; import { ExplorerItem } from '@sd/client'; import { explorerStore } from './store'; @@ -11,8 +11,26 @@ export interface UseExplorerDraggableProps extends Omit { - const disabled = props.disabled || !draggableTypes.includes(props.data.type); + const disabled = useMemo( + () => props.disabled || !draggableTypes.includes(props.data.type), + [props.disabled, props.data.type] + ); const { setNodeRef, ...draggable } = useDraggable({ ...props, @@ -20,30 +38,30 @@ export const useExplorerDraggable = (props: UseExplorerDraggableProps) => { disabled: disabled }); - const onMouseDown = () => { + const onMouseDown = useCallback(() => { if (!disabled) explorerStore.drag = { type: 'touched' }; - }; + }, [disabled]); - const onMouseLeave = () => { + const onMouseLeave = useCallback(() => { if (explorerStore.drag?.type !== 'dragging') explorerStore.drag = null; - }; + }, []); - const onMouseUp = () => (explorerStore.drag = null); - - const style = { - cursor: 'default', - outline: 'none' - } satisfies CSSProperties; + const onMouseUp = useCallback(() => { + explorerStore.drag = null; + }, []); return { ...draggable, setDraggableRef: setNodeRef, - listeners: { - ...draggable.listeners, - onMouseDown, - onMouseLeave, - onMouseUp - } satisfies HTMLAttributes, - style + listeners: useMemo( + () => ({ + ...draggable.listeners, + onMouseDown, + onMouseLeave, + onMouseUp + }), + [draggable.listeners, onMouseDown, onMouseLeave, onMouseUp] + ) satisfies HTMLAttributes, + style: DRAGGABLE_STYLE }; }; diff --git a/interface/app/$libraryId/Explorer/useExplorerItemData.tsx b/interface/app/$libraryId/Explorer/useExplorerItemData.tsx index 06d2f2af9..3aff87488 100644 --- a/interface/app/$libraryId/Explorer/useExplorerItemData.tsx +++ b/interface/app/$libraryId/Explorer/useExplorerItemData.tsx @@ -1,34 +1,91 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; -import { getExplorerItemData, useSelector, type ExplorerItem } from '@sd/client'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { subscribe } from 'valtio'; +import { + compareHumanizedSizes, + getExplorerItemData, + humanizeSize, + ThumbKey, + type ExplorerItem +} from '@sd/client'; +import { usePlatform } from '~/util/Platform'; import { explorerStore, flattenThumbnailKey } from './store'; -// This is where we intercept the state of the explorer item to determine if we should rerender -// This hook is used inside every thumbnail in the explorer +/** + * This is where we intercept the state of the explorer item to determine if we should rerender + * + * .. WARNING:: + * This hook is used inside every thumbnail in the explorer. + * Be careful with the performance of the code, make sure to always memoize any objects or functions to avoid unnecessary re-renders. + * + * @param explorerItem - The explorer item to get data from + * @returns The extracted data from the explorer item + */ export function useExplorerItemData(explorerItem: ExplorerItem) { - const newThumbnail = useSelector(explorerStore, (s) => { - const thumbnailKey = - explorerItem.type === 'Label' - ? // labels have .thumbnails, plural - explorerItem.thumbnails?.[0] - : // all other explorer items have .thumbnail singular - 'thumbnail' in explorerItem && explorerItem.thumbnail; + const platform = usePlatform(); + const cachedSize = useRef | null>(null); + const [newThumbnails, setNewThumbnails] = useState>(new Map()); - return !!(thumbnailKey && s.newThumbnails.has(flattenThumbnailKey(thumbnailKey))); - }); + let thumbnails: ThumbKey | ThumbKey[] | null = null; + switch (explorerItem.type) { + case 'Label': + thumbnails = explorerItem.thumbnails; + break; + case 'Path': + case 'Object': + case 'NonIndexedPath': + thumbnails = explorerItem.thumbnail; + break; + } + + useEffect(() => { + const thumbnailKeys = thumbnails + ? Array.isArray(thumbnails) + ? thumbnails + : [thumbnails] + : []; + + const updateThumbnails = () => + setNewThumbnails((oldThumbs) => { + const thumbs = thumbnailKeys.reduce>((acc, thumbKey) => { + const url = platform.getThumbnailUrlByThumbKey(thumbKey); + const thumbId = flattenThumbnailKey(thumbKey); + acc.set(url, explorerStore.newThumbnails.has(thumbId) ? thumbId : null); + return acc; + }, new Map()); + + // Avoid unnecessary re-renders + return oldThumbs.size !== thumbs.size || + Array.from(oldThumbs.keys()).some( + (key) => oldThumbs.get(key) !== thumbs.get(key) + ) + ? thumbs + : oldThumbs; + }); + + updateThumbnails(); + + return subscribe(explorerStore, updateThumbnails); + }, [thumbnails, platform]); return useMemo(() => { - const itemData = getExplorerItemData(explorerItem); + const explorerItemData = getExplorerItemData(explorerItem); - if (!itemData.hasLocalThumbnail) { - itemData.hasLocalThumbnail = newThumbnail; + // Avoid unecessary re-renders + if ( + cachedSize.current == null || + !compareHumanizedSizes(cachedSize.current, explorerItemData.size) + ) { + cachedSize.current = explorerItemData.size; } - return itemData; - // whatever goes here, is what can cause an atomic re-render of an explorer item - // this is used for when new thumbnails are generated, and files identified - }, [explorerItem, newThumbnail]); + return { + ...explorerItemData, + size: cachedSize.current, + thumbnails: newThumbnails, + hasLocalThumbnail: explorerItemData.hasLocalThumbnail || newThumbnails.size > 0 + }; + }, [explorerItem, newThumbnails]); } export type ExplorerItemData = ReturnType; diff --git a/interface/app/$libraryId/Explorer/useExplorerPreferences.ts b/interface/app/$libraryId/Explorer/useExplorerPreferences.ts index 6af94ec7b..60d4bc3d5 100644 --- a/interface/app/$libraryId/Explorer/useExplorerPreferences.ts +++ b/interface/app/$libraryId/Explorer/useExplorerPreferences.ts @@ -53,7 +53,7 @@ export function useExplorerPreferences({ try { await updatePreferences.mutateAsync(writeSettings(settings)); - rspc.queryClient.invalidateQueries(['preferences.get']); + rspc.queryClient.invalidateQueries({ queryKey: ['preferences.get'] }); } catch (e) { alert('An error has occurred while updating your preferences.'); } diff --git a/interface/app/$libraryId/Layout/CMDK/index.tsx b/interface/app/$libraryId/Layout/CMDK/index.tsx index 1055e3708..7cd205911 100644 --- a/interface/app/$libraryId/Layout/CMDK/index.tsx +++ b/interface/app/$libraryId/Layout/CMDK/index.tsx @@ -1,6 +1,7 @@ import './CMDK.css'; import './CMDK.scss'; +import { keepPreviousData } from '@tanstack/react-query'; import clsx from 'clsx'; import { useEffect, useState } from 'react'; import CommandPalette, { filterItems, getItemIndex } from 'react-cmdk'; @@ -50,7 +51,9 @@ const CMDK = () => { const [page, setPage] = useState<'root' | 'locations' | 'tags'>('root'); const [search, setSearch] = useState(''); - const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const locationsQuery = useLibraryQuery(['locations.list'], { + placeholderData: keepPreviousData + }); const locations = locationsQuery.data; const onlineLocations = useOnlineLocations(); diff --git a/interface/app/$libraryId/Layout/CMDK/pages/CMDKLocations.tsx b/interface/app/$libraryId/Layout/CMDK/pages/CMDKLocations.tsx index f878dea0a..b0696c50b 100644 --- a/interface/app/$libraryId/Layout/CMDK/pages/CMDKLocations.tsx +++ b/interface/app/$libraryId/Layout/CMDK/pages/CMDKLocations.tsx @@ -1,3 +1,4 @@ +import { keepPreviousData } from '@tanstack/react-query'; import clsx from 'clsx'; import CommandPalette from 'react-cmdk'; import { useNavigate } from 'react-router'; @@ -5,7 +6,9 @@ import { arraysEqual, useLibraryQuery, useOnlineLocations } from '@sd/client'; import { Icon } from '~/components'; export default function CMDKLocations() { - const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const locationsQuery = useLibraryQuery(['locations.list'], { + placeholderData: keepPreviousData + }); const locations = locationsQuery.data; const onlineLocations = useOnlineLocations(); diff --git a/interface/app/$libraryId/Layout/CMDK/pages/CMDKTags.tsx b/interface/app/$libraryId/Layout/CMDK/pages/CMDKTags.tsx index a3cc2c836..7ca898691 100644 --- a/interface/app/$libraryId/Layout/CMDK/pages/CMDKTags.tsx +++ b/interface/app/$libraryId/Layout/CMDK/pages/CMDKTags.tsx @@ -1,9 +1,10 @@ +import { keepPreviousData } from '@tanstack/react-query'; import CommandPalette from 'react-cmdk'; import { useNavigate } from 'react-router'; import { useLibraryQuery, type Tag } from '@sd/client'; export default function CMDKTags() { - const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + const result = useLibraryQuery(['tags.list'], { placeholderData: keepPreviousData }); const tags = result.data || []; const navigate = useNavigate(); diff --git a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx index 21eec1971..5cb5d97e5 100644 --- a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx @@ -154,15 +154,12 @@ export default () => { title="React Query Devtools" description="Configure the React Query devtools." > - + + (debugState.reactQueryDevtools = !debugState.reactQueryDevtools) + } + /> { - queryClient.invalidateQueries(['jobs.reports']); + queryClient.invalidateQueries({ queryKey: ['jobs.reports'] }); }) ); diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx b/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx index 837dd0228..d317f4a31 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx @@ -97,7 +97,7 @@ export function JobManager() { title: t('success'), body: t('all_jobs_have_been_cleared') }); - queryClient.invalidateQueries(['jobs.reports']); + queryClient.invalidateQueries({ queryKey: ['jobs.reports'] }); } catch (error) { toast.error({ title: t('error'), diff --git a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx index 2d7dc4835..b8eb0bffc 100644 --- a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx @@ -1,5 +1,5 @@ -import { inferSubscriptionResult } from '@oscartbeaumont-sd/rspc-client'; import { Gear } from '@phosphor-icons/react'; +import { inferSubscriptionResult } from '@spacedrive/rspc-client'; import { useState } from 'react'; import { useNavigate } from 'react-router'; import { diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx index ef80dc378..703eddae0 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx @@ -1,3 +1,4 @@ +import { keepPreviousData } from '@tanstack/react-query'; import clsx from 'clsx'; import { Link, useMatch } from 'react-router-dom'; import { @@ -18,7 +19,9 @@ import { SeeMore } from '../../SidebarLayout/SeeMore'; import { ContextMenu } from './ContextMenu'; export default function Locations() { - const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const locationsQuery = useLibraryQuery(['locations.list'], { + placeholderData: keepPreviousData + }); const locations = locationsQuery.data; const onlineLocations = useOnlineLocations(); diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx index f251fc042..4131bbfe6 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx @@ -1,3 +1,4 @@ +import { keepPreviousData } from '@tanstack/react-query'; import clsx from 'clsx'; import { NavLink, useMatch } from 'react-router-dom'; import { useLibraryQuery, type Tag } from '@sd/client'; @@ -11,7 +12,7 @@ import { SeeMore } from '../../SidebarLayout/SeeMore'; import { ContextMenu } from './ContextMenu'; export default function TagsSection() { - const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + const result = useLibraryQuery(['tags.list'], { placeholderData: keepPreviousData }); const tags = result.data; const { t } = useLocale(); diff --git a/interface/app/$libraryId/Spacedrop/index.tsx b/interface/app/$libraryId/Spacedrop/index.tsx index 4c9a81342..6803a5283 100644 --- a/interface/app/$libraryId/Spacedrop/index.tsx +++ b/interface/app/$libraryId/Spacedrop/index.tsx @@ -93,7 +93,7 @@ export function Spacedrop({ triggerClose }: { triggerClose: () => void }) { }); const onDropped = (id: string, files: string[]) => { - if (doSpacedrop.isLoading) { + if (doSpacedrop.isPending) { toast.warning(t('spacedrop_already_progress')); return; } diff --git a/interface/app/$libraryId/debug/actors.tsx b/interface/app/$libraryId/debug/actors.tsx index a744583a6..cb14658c1 100644 --- a/interface/app/$libraryId/debug/actors.tsx +++ b/interface/app/$libraryId/debug/actors.tsx @@ -1,10 +1,10 @@ -import { inferSubscriptionResult } from '@oscartbeaumont-sd/rspc-client'; +import { inferSubscriptionResult } from '@spacedrive/rspc-client'; import { useMemo, useState } from 'react'; import { Procedures, useLibraryMutation, useLibrarySubscription } from '@sd/client'; import { Button } from '@sd/ui'; import { useRouteTitle } from '~/hooks/useRouteTitle'; -// @million-ignore +// million-ignore export const Component = () => { useRouteTitle('Actors'); @@ -46,10 +46,10 @@ function StartButton({ name }: { name: string }) { return ( ); } @@ -60,10 +60,10 @@ function StopButton({ name }: { name: string }) { return ( ); } diff --git a/interface/app/$libraryId/debug/cloud.tsx b/interface/app/$libraryId/debug/cloud.tsx index c3bf6ae35..fcec4339f 100644 --- a/interface/app/$libraryId/debug/cloud.tsx +++ b/interface/app/$libraryId/debug/cloud.tsx @@ -87,13 +87,13 @@ function Authenticated() { @@ -125,8 +125,8 @@ function HostedLocationsPlayground() { /> {/* TODO: Cleanup this mess + styles */} - {locations.status === 'loading' ?
Loading!
: null} - {locations.status !== 'loading' && locations.data?.length === 0 ? ( + {locations.status === 'pending' ?
Loading!
: null} + {locations.status !== 'pending' && locations.data?.length === 0 ? (
Looks like you don't have any!
) : (
@@ -137,7 +137,7 @@ function HostedLocationsPlayground() { variant="accent" size="sm" onClick={() => removeLocation.mutate(location.id)} - disabled={isLoading} + disabled={isPending} > Delete @@ -152,7 +152,7 @@ function HostedLocationsPlayground() { className="grow" value={path} onInput={(e) => setPath(e.currentTarget.value)} - disabled={isLoading} + disabled={isPending} />
diff --git a/interface/app/$libraryId/settings/client/backups.tsx b/interface/app/$libraryId/settings/client/backups.tsx index 69689b16f..0ebfe10e7 100644 --- a/interface/app/$libraryId/settings/client/backups.tsx +++ b/interface/app/$libraryId/settings/client/backups.tsx @@ -27,7 +27,7 @@ export const Component = () => { rightArea={
diff --git a/interface/app/$libraryId/settings/library/general.tsx b/interface/app/$libraryId/settings/library/general.tsx index d79b29363..a8b850923 100644 --- a/interface/app/$libraryId/settings/library/general.tsx +++ b/interface/app/$libraryId/settings/library/general.tsx @@ -109,7 +109,7 @@ export const Component = () => {
-// } -// {...form.register('masterPassword', { required: true })} -// /> - -// setShow((old) => ({ ...old, secretKey: !old.secretKey }))} -// size="icon" -// > -// -// -// } -// {...form.register('secretKey')} -// /> - -//
-// -//
-// -// ); -// }; diff --git a/interface/app/$libraryId/settings/library/keys/KeyViewerDialog.tsx b/interface/app/$libraryId/settings/library/keys/KeyViewerDialog.tsx deleted file mode 100644 index 9fe44cbac..000000000 --- a/interface/app/$libraryId/settings/library/keys/KeyViewerDialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -// import { Buffer } from 'buffer'; -// import { Clipboard } from '@phosphor-icons/react'; -// import { useState } from 'react'; -// import { slugFromHashingAlgo, useLibraryQuery } from '@sd/client'; -// import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui'; -// import { useZodForm } from '@sd/ui/src/forms'; -// import { KeyListSelectOptions } from '~/app/$libraryId/KeyManager/List'; - -// export const KeyUpdater = (props: { -// uuid: string; -// setKey: (value: string) => void; -// setEncryptionAlgo: (value: string) => void; -// setHashingAlgo: (value: string) => void; -// setContentSalt: (value: string) => void; -// }) => { -// useLibraryQuery(['keys.getKey', props.uuid], { -// onSuccess: (data) => { -// props.setKey(data); -// } -// }); - -// const keys = useLibraryQuery(['keys.list']); - -// const key = keys.data?.find((key) => key.uuid == props.uuid); - -// if (key) { -// props.setEncryptionAlgo(key?.algorithm); -// props.setHashingAlgo(slugFromHashingAlgo(key?.hashing_algorithm)); -// props.setContentSalt(Buffer.from(key.content_salt).toString('hex')); -// } - -// return <>; -// }; - -// export default (props: UseDialogProps) => { -// const keys = useLibraryQuery(['keys.list'], { -// onSuccess: (data) => { -// if (key === '' && data.length !== 0) { -// setKey(data[0]?.uuid ?? ''); -// } -// } -// }); - -// const [key, setKey] = useState(''); -// const [keyValue, setKeyValue] = useState(''); -// const [contentSalt, setContentSalt] = useState(''); -// const [encryptionAlgo, setEncryptionAlgo] = useState(''); -// const [hashingAlgo, setHashingAlgo] = useState(''); - -// return ( -// -// - -//
-//
-// Key -// -//
-//
-//
-//
-// Encryption -// -//
-//
-// Hashing -// -//
-//
-//
-//
-// Content Salt (hex) -// { -// navigator.clipboard.writeText(contentSalt); -// }} -// size="icon" -// > -// -// -// } -// /> -//
-//
-//
-//
-// Key Value -// { -// navigator.clipboard.writeText(keyValue); -// }} -// size="icon" -// > -// -// -// } -// /> -//
-//
-//
-// ); -// }; diff --git a/interface/app/$libraryId/settings/library/keys/MasterPasswordDialog.tsx b/interface/app/$libraryId/settings/library/keys/MasterPasswordDialog.tsx deleted file mode 100644 index 7b07a3135..000000000 --- a/interface/app/$libraryId/settings/library/keys/MasterPasswordDialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -// import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from '@phosphor-icons/react'; -// import { useState } from 'react'; -// import { -// Algorithm, -// HASHING_ALGOS, -// HashingAlgoSlug, -// hashingAlgoSlugSchema, -// useLibraryMutation -// } from '@sd/client'; -// import { Button, Dialog, Input, Select, SelectOption, UseDialogProps, useDialog } from '@sd/ui'; -// import { useZodForm, z } from '@sd/ui/src/forms'; -// import { PasswordMeter, showAlertDialog } from '~/components'; -// import { generatePassword } from '~/util'; - -// const schema = z.object({ -// masterPassword: z.string(), -// masterPassword2: z.string(), -// encryptionAlgo: z.string(), -// hashingAlgo: hashingAlgoSlugSchema -// }); - -// export default (props: UseDialogProps) => { -// const changeMasterPassword = useLibraryMutation('keys.changeMasterPassword', { -// onSuccess: () => { -// showAlertDialog({ -// title: 'Success', -// value: 'Your master password was changed successfully' -// }); -// }, -// onError: () => { -// // this should never really happen -// showAlertDialog({ -// title: 'Master Password Change Error', -// value: 'There was an error while changing your master password.' -// }); -// } -// }); - -// const [show, setShow] = useState({ -// masterPassword: false, -// masterPassword2: false -// }); - -// const MP1CurrentEyeIcon = show.masterPassword ? EyeSlash : Eye; -// const MP2CurrentEyeIcon = show.masterPassword2 ? EyeSlash : Eye; - -// const form = useZodForm({ -// schema, -// defaultValues: { -// encryptionAlgo: 'XChaCha20Poly1305', -// hashingAlgo: 'Argon2id-s', -// masterPassword: '', -// masterPassword2: '' -// } -// }); - -// const onSubmit = form.handleSubmit((data) => { -// if (data.masterPassword !== data.masterPassword2) { -// showAlertDialog({ -// title: 'Error', -// value: 'Passwords are not the same, please try again.' -// }); -// } else { -// const hashing_algorithm = HASHING_ALGOS[data.hashingAlgo]; - -// return changeMasterPassword.mutateAsync({ -// algorithm: data.encryptionAlgo as Algorithm, -// hashing_algorithm, -// password: data.masterPassword -// }); -// } -// }); - -// return ( -// -// -// -// -// -//
-// } -// /> - -// -// setShow((old) => ({ ...old, masterPassword2: !old.masterPassword2 })) -// } -// size="icon" -// type="button" -// > -// -// -// } -// /> - -// - -//
-//
-// Encryption -// -//
-//
-// Hashing -// -//
-//
-// -// ); -// }; diff --git a/interface/app/$libraryId/settings/library/keys/index.tsx b/interface/app/$libraryId/settings/library/keys/index.tsx deleted file mode 100644 index e0c3bc314..000000000 --- a/interface/app/$libraryId/settings/library/keys/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -// import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -// import { animated, useTransition } from '@react-spring/web'; -// import clsx from 'clsx'; -// import { Lock, Plus } from '@phosphor-icons/react'; -// import { PropsWithChildren, ReactNode, useState } from 'react'; -// import QRCode from 'react-qr-code'; -// import { useLibraryMutation, useLibraryQuery } from '@sd/client'; -// import { Button, PasswordInput, dialogManager } from '@sd/ui'; -// import { showAlertDialog } from '~/components/AlertDialog'; -// import { usePlatform } from '~/util/Platform'; -// import KeyList from '../../../KeyManager/List'; -// import KeyMounter from '../../../KeyManager/Mounter'; -// import { Heading } from '../../Layout'; -// import BackupRestoreDialog from './BackupRestoreDialog'; -// import KeyViewerDialog from './KeyViewerDialog'; -// import MasterPasswordDialog from './MasterPasswordDialog'; - -// interface Props extends DropdownMenu.MenuContentProps { -// trigger: React.ReactNode; -// transformOrigin?: string; -// disabled?: boolean; -// } - -// export const KeyMounterDropdown = ({ -// trigger, -// children, -// transformOrigin, -// className -// }: PropsWithChildren) => { -// const [open, setOpen] = useState(false); - -// const transitions = useTransition(open, { -// from: { -// opacity: 0, -// transform: `scale(0.9)`, -// transformOrigin: transformOrigin || 'top' -// }, -// enter: { opacity: 1, transform: 'scale(1)' }, -// leave: { opacity: -0.5, transform: 'scale(0.95)' }, -// config: { mass: 0.4, tension: 200, friction: 10 } -// }); - -// return ( -// -// {trigger} -// {transitions( -// (styles, show) => -// show && ( -// -// -// -// {children} -// -// -// -// ) -// )} -// -// ); -// }; - -// export const Component = () => { -// const platform = usePlatform(); -// const isUnlocked = useLibraryQuery(['keys.isUnlocked']); -// const keyringSk = useLibraryQuery(['keys.getSecretKey'], { initialData: '' }); // assume true by default, as it will often be the case. need to fix this with an rspc subscription+such -// const unlockKeyManager = useLibraryMutation('keys.unlockKeyManager', { -// onError: () => { -// showAlertDialog({ -// title: 'Unlock Error', -// value: 'The information provided to the key manager was incorrect' -// }); -// } -// }); - -// const unmountAll = useLibraryMutation('keys.unmountAll'); -// const clearMasterPassword = useLibraryMutation('keys.clearMasterPassword'); -// const backupKeystore = useLibraryMutation('keys.backupKeystore'); -// const isKeyManagerUnlocking = useLibraryQuery(['keys.isKeyManagerUnlocking']); - -// const [masterPassword, setMasterPassword] = useState(''); -// const [secretKey, setSecretKey] = useState(''); // for the unlock form -// const [viewSecretKey, setViewSecretKey] = useState(false); // for the settings page - -// const keys = useLibraryQuery(['keys.list']); - -// const [enterSkManually, setEnterSkManually] = useState(keyringSk?.data === null); - -// if (!isUnlocked?.data) { -// return ( -//
-// setMasterPassword(e.target.value)} -// autoFocus -// placeholder="Master Password" -// className="mb-2" -// /> - -// {enterSkManually && ( -// setSecretKey(e.target.value)} -// placeholder="Secret Key" -// className="mb-2" -// /> -// )} - -// -// {!enterSkManually && ( -//
-//

setEnterSkManually(true)}> -// or enter secret key manually -//

-//
-// )} -//
-// ); -// } else { -// return ( -// <> -// -// -// -// -// -// } -// > -// -// -//
-// } -// /> - -// {isUnlocked && ( -//
-// -//
-// )} - -// {keyringSk?.data && ( -// <> -// -// {!viewSecretKey && ( -//
-// -//
-// )} -// {viewSecretKey && ( -//
{ -// keyringSk.data && navigator.clipboard.writeText(keyringSk.data); -// }} -// > -// <> -// -//

{keyringSk.data}

-// -//
-// )} -// -// )} - -// -//
-// -// -//
- -// -//
-// -// -//
-// -// ); -// } -// }; - -// interface SubheadingProps { -// title: string; -// rightArea?: ReactNode; -// } - -// const Subheading = (props: SubheadingProps) => ( -//
-//
-//

{props.title}

-//
-// {props.rightArea} -//
-// ); diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index 41fd59571..3bcec7b99 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -78,7 +78,7 @@ const EditLocationForm = () => { }, onSuccess: () => { form.reset(form.getValues()); - queryClient.invalidateQueries(['locations.list']); + queryClient.invalidateQueries({ queryKey: ['locations.list'] }); } }); diff --git a/interface/app/$libraryId/settings/library/saved-searches/index.tsx b/interface/app/$libraryId/settings/library/saved-searches/index.tsx index fa10daeb3..c3a6e19f7 100644 --- a/interface/app/$libraryId/settings/library/saved-searches/index.tsx +++ b/interface/app/$libraryId/settings/library/saved-searches/index.tsx @@ -108,7 +108,7 @@ function EditForm({ savedSearch, onDelete }: { savedSearch: SavedSearch; onDelet @@ -193,10 +193,10 @@ function StartButton({ name }: { name: string }) { return ( ); } @@ -208,10 +208,10 @@ function StopButton({ name }: { name: string }) { return ( ); } diff --git a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx index 93032ecf4..60a54a93f 100644 --- a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx @@ -28,7 +28,7 @@ export function useAssignItemsToTag() { const mutation = useLibraryMutation(['tags.assign'], { onSuccess: () => { submitPlausibleEvent({ event: { type: 'tagAssign' } }); - rspc.queryClient.invalidateQueries(['search.paths']); + rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] }); } }); diff --git a/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx b/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx index 7425a0df0..075fb5280 100644 --- a/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx +++ b/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx @@ -25,7 +25,7 @@ export default function DeleteLibraryDialog(props: Props) { try { await deleteLib.mutateAsync(props.libraryUuid); - queryClient.invalidateQueries(['library.list']); + queryClient.invalidateQueries({ queryKey: ['library.list'] }); if (platform.refreshMenuBar) platform.refreshMenuBar(); diff --git a/interface/app/$libraryId/settings/resources/changelog.tsx b/interface/app/$libraryId/settings/resources/changelog.tsx index ed257162f..e6965feb9 100644 --- a/interface/app/$libraryId/settings/resources/changelog.tsx +++ b/interface/app/$libraryId/settings/resources/changelog.tsx @@ -9,9 +9,10 @@ import { Heading } from '../Layout'; export const Component = () => { const platform = usePlatform(); const isDark = useIsDark(); - const changelog = useQuery(['changelog'], () => - fetch(`${platform.landingApiOrigin}/api/releases`).then((r) => r.json()) - ); + const changelog = useQuery({ + queryKey: ['changelog'], + queryFn: () => fetch(`${platform.landingApiOrigin}/api/releases`).then((r) => r.json()) + }); const { t } = useLocale(); diff --git a/interface/app/$libraryId/settings/resources/dependencies.tsx b/interface/app/$libraryId/settings/resources/dependencies.tsx deleted file mode 100644 index 2024f9252..000000000 --- a/interface/app/$libraryId/settings/resources/dependencies.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// import { useQuery } from '@tanstack/react-query'; -// import { ScreenHeading } from '@sd/ui'; -// import { usePlatform } from '~/util/Platform'; - -// export const Component = () => { -// const frontEnd = useQuery( -// ['frontend-deps'], -// () => import('@sd/assets/deps/frontend-deps.json') -// ); -// const backEnd = useQuery(['backend-deps'], () => import('@sd/assets/deps/backend-deps.json')); -// const platform = usePlatform(); - -// return ( -//
-// Dependencies - -// {/* item has a LOT more data that we can display, i just went with the basics */} - -// Frontend Dependencies -//
-// {frontEnd.data && -// frontEnd.data?.default.map((item) => { -// return ( -// platform.openLink(item.url ?? '')}> -//
-//

-// {item.title.trimEnd().substring(0, 24) + -// (item.title.length > 24 ? '...' : '')} -//

-//
-//
-// ); -// })} -//
- -// Backend Dependencies -//
-// {backEnd.data && -// backEnd.data?.default.map((item) => { -// return ( -// platform.openLink(item.url ?? '')}> -//
-//

{item.title.trimEnd()}

-//
-//
-// ); -// })} -//
-//
-// ); -// }; diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 3216925a1..2f70f199a 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,4 +1,4 @@ -import { initRspc, wsBatchLink, type AlphaClient } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { initRspc, wsBatchLink, type AlphaClient } from '@spacedrive/rspc-client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { diff --git a/interface/app/onboarding/join-library.tsx b/interface/app/onboarding/join-library.tsx index d4bbed977..1baf7f967 100644 --- a/interface/app/onboarding/join-library.tsx +++ b/interface/app/onboarding/join-library.tsx @@ -55,7 +55,7 @@ function CloudLibraries() { {cloudLibrary.name} diff --git a/interface/components/Devtools.tsx b/interface/components/Devtools.tsx index acb72e803..952019859 100644 --- a/interface/components/Devtools.tsx +++ b/interface/components/Devtools.tsx @@ -1,4 +1,3 @@ -import { defaultContext } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useDebugState } from '@sd/client'; @@ -7,18 +6,9 @@ export const Devtools = () => { return ( <> - {debugState.reactQueryDevtools !== 'disabled' ? ( - - ) : null} + {debugState.reactQueryDevtools && ( + + )} ); }; diff --git a/interface/components/Sparkles.tsx b/interface/components/Sparkles.tsx index ec6b73ffa..2b1dc612d 100644 --- a/interface/components/Sparkles.tsx +++ b/interface/components/Sparkles.tsx @@ -38,7 +38,8 @@ type SparklesProps = { children: React.ReactNode; }; -const Sparkles = ({ color = DEFAULT_COLOR, children, ...props }: SparklesProps) => { +// million-ignore +const Sparkles = ({ color = DEFAULT_COLOR, children }: SparklesProps) => { const [sparkles, setSparkles] = useState(() => { return range(3).map(() => generateSparkle(color)); }); @@ -60,7 +61,7 @@ const Sparkles = ({ color = DEFAULT_COLOR, children, ...props }: SparklesProps) ); return ( - + {sparkles.map((sparkle) => ( { + return useSuspenseQuery({ + queryKey: ['userDirs', 'home'], + queryFn: () => { if (platform.userHomeDir) return platform.userHomeDir(); else return null; - }, - { suspense: true } - ); + } + }); } diff --git a/interface/hooks/useOperatingSystem.ts b/interface/hooks/useOperatingSystem.ts index c5797661e..acba5a8c6 100644 --- a/interface/hooks/useOperatingSystem.ts +++ b/interface/hooks/useOperatingSystem.ts @@ -17,17 +17,15 @@ export function guessOperatingSystem(): OperatingSystem { // Setting `realOs` to true will return a best guess of the underlying operating system instead of 'browser'. export function useOperatingSystem(realOs?: boolean): OperatingSystem { const platform = usePlatform(); - const { data } = useQuery( - ['_tauri', 'platform'], - async () => { + const { data } = useQuery({ + queryKey: ['_tauri', 'platform'], + queryFn: async () => { return platform.getOs ? await platform.getOs() : guessOperatingSystem(); }, - { - // Here we guess the users operating system from the user agent for the first render. - initialData: guessOperatingSystem, - enabled: platform.getOs !== undefined - } - ); + // Here we guess the users operating system from the user agent for the first render. + initialData: guessOperatingSystem, + enabled: platform.getOs !== undefined + }); return platform.platform === 'web' && !realOs ? 'browser' : data; } diff --git a/interface/package.json b/interface/package.json index c3a528139..0b3b91acf 100644 --- a/interface/package.json +++ b/interface/package.json @@ -26,10 +26,10 @@ "@sd/client": "workspace:*", "@sd/ui": "workspace:*", "@sentry/browser": "^7.74.1", - "@tanstack/react-query": "^4.36.1", - "@tanstack/react-query-devtools": "^4.36.1", - "@tanstack/react-table": "^8.10.7", - "@tanstack/react-virtual": "3.0.0-beta.66", + "@tanstack/react-query": "^5.59", + "@tanstack/react-query-devtools": "^5.59", + "@tanstack/react-table": "^8.20.5", + "@tanstack/react-virtual": "3.10.8", "@total-typescript/ts-reset": "^0.5.1", "@virtual-grid/react": "^2.0.2", "class-variance-authority": "^0.7.0", @@ -69,7 +69,7 @@ "use-debounce": "^9.0.4", "use-resize-observer": "^9.1.0", "uuid": "^9.0.1", - "valtio": "^1.11.2" + "valtio": "^2.0" }, "devDependencies": { "@sd/config": "workspace:*", diff --git a/interface/util/useTraceUpdate.tsx b/interface/util/useTraceUpdate.tsx new file mode 100644 index 000000000..c40ebc326 --- /dev/null +++ b/interface/util/useTraceUpdate.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react'; + +/** + * DO NOT DELETE THIS HOOK + * It probably isn't used in the codebase, but it's a useful debugging tool. + */ +export function useTraceUpdate(name: string, props: object | null) { + const prev = useRef<{ [key: string]: any } | null>(props); + useEffect(() => { + const { current } = prev; + if (props == null) { + console.log(`Change ${name} to null`); + } else if (current == null) { + console.log(`Change ${name} from null to`, props); + } else { + const changedProps = Object.entries(props).reduce((ps: any, [k, v]) => { + if (current[k] !== v) { + ps[k] = [current[k], v]; + } + return ps; + }, {}); + if (Object.keys(changedProps).length > 0) { + console.log(`Changed ${name}:`, changedProps); + } + } + prev.current = props; + }); +} diff --git a/package.json b/package.json index 80924fac7..393d7f33c 100644 --- a/package.json +++ b/package.json @@ -73,5 +73,5 @@ "eslintConfig": { "root": true }, - "packageManager": "pnpm@9.9.0" + "packageManager": "pnpm@9.12.1" } diff --git a/packages/client/package.json b/packages/client/package.json index 68f7f1a07..f2db671f6 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,11 +11,11 @@ "typecheck": "tsc -b" }, "dependencies": { - "@oscartbeaumont-sd/rspc-client": "github:spacedriveapp/rspc#path:packages/client&bc882f4724", - "@oscartbeaumont-sd/rspc-react": "github:spacedriveapp/rspc#path:packages/react&bc882f4724", + "@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495", + "@spacedrive/rspc-react": "github:spacedriveapp/rspc#path:packages/react&6a77167495", "@solid-primitives/deep": "^0.2.4", - "@tanstack/react-query": "^4.36.1", - "@tanstack/solid-query": "^5.17.9", + "@tanstack/react-query": "^5.59", + "@tanstack/solid-query": "^5.59", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -23,7 +23,7 @@ "plausible-tracker": "^0.3.8", "react-hook-form": "^7.47.0", "solid-js": "^1.8.8", - "zod": "~3.22.4" + "zod": "^3.23" }, "devDependencies": { "@sd/config": "workspace:*", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 2935c0951..bc4c2fe8c 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually. +// This file was generated by [rspc](https://github.com/spacedriveapp/rspc). Do not edit this file manually. export type Procedures = { queries: diff --git a/packages/client/src/explorer/index.ts b/packages/client/src/explorer/index.ts index 34dcf4dce..9703b13af 100644 --- a/packages/client/src/explorer/index.ts +++ b/packages/client/src/explorer/index.ts @@ -1,5 +1,4 @@ export * from './useExplorerInfiniteQuery'; -export * from './usePathsInfiniteQuery'; export * from './usePathsOffsetInfiniteQuery'; export * from './usePathsExplorerQuery'; export * from './useObjectsInfiniteQuery'; diff --git a/packages/client/src/explorer/useExplorerInfiniteQuery.ts b/packages/client/src/explorer/useExplorerInfiniteQuery.ts index 1b3c25ce5..80b4c5767 100644 --- a/packages/client/src/explorer/useExplorerInfiniteQuery.ts +++ b/packages/client/src/explorer/useExplorerInfiniteQuery.ts @@ -6,5 +6,4 @@ import { Ordering } from './index'; export type UseExplorerInfiniteQueryArgs = { arg: TArg; order: TOrder | null; - onSuccess?: () => void; -} & Pick>, 'enabled' | 'suspense'>; +}; diff --git a/packages/client/src/explorer/useExplorerQuery.ts b/packages/client/src/explorer/useExplorerQuery.ts index b41c9d247..ad7630b5d 100644 --- a/packages/client/src/explorer/useExplorerQuery.ts +++ b/packages/client/src/explorer/useExplorerQuery.ts @@ -1,13 +1,16 @@ -import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query'; +import { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { SearchData } from '../core'; export function useExplorerQuery( - query: UseInfiniteQueryResult>, + query: UseInfiniteQueryResult>>, count: UseQueryResult ) { - const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? null, [query.data]); + const items = useMemo( + () => query.data?.pages.flatMap((data) => data.items) ?? null, + [query.data] + ); const loadMore = useCallback(() => { if (query.hasNextPage && !query.isFetchingNextPage) { diff --git a/packages/client/src/explorer/useObjectsInfiniteQuery.ts b/packages/client/src/explorer/useObjectsInfiniteQuery.ts index 6e758e1ba..c8680a2b8 100644 --- a/packages/client/src/explorer/useObjectsInfiniteQuery.ts +++ b/packages/client/src/explorer/useObjectsInfiniteQuery.ts @@ -8,8 +8,7 @@ import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; export function useObjectsInfiniteQuery({ arg, - order, - ...args + order }: UseExplorerInfiniteQueryArgs) { const { library } = useLibraryContext(); const ctx = useRspcLibraryContext(); @@ -21,25 +20,23 @@ export function useObjectsInfiniteQuery({ const query = useInfiniteQuery({ queryKey: ['search.objects', { library_id: library.uuid, arg }] as const, queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { - const cItem: Extract = pageParam; - let orderAndPagination: (typeof arg)['orderAndPagination']; - if (!cItem) { + if (!pageParam || pageParam.type !== 'Object') { if (order) orderAndPagination = { orderOnly: order }; } else { let cursor: ObjectCursor | undefined; if (!order) cursor = 'none'; - else if (cItem) { + else if (pageParam) { switch (order.field) { case 'kind': { - const data = cItem.item.kind; + const data = pageParam.item.kind; if (data !== null) cursor = { kind: { order: order.value, data } }; break; } case 'dateAccessed': { - const data = cItem.item.date_accessed; + const data = pageParam.item.date_accessed; if (data !== null) cursor = { dateAccessed: { order: order.value, data } }; break; @@ -47,18 +44,18 @@ export function useObjectsInfiniteQuery({ } } - if (cursor) orderAndPagination = { cursor: { cursor, id: cItem.item.id } }; + if (cursor) orderAndPagination = { cursor: { cursor, id: pageParam.item.id } }; } arg.orderAndPagination = orderAndPagination; return ctx.client.query(['search.objects', arg]); }, + initialPageParam: undefined as ExplorerItem | undefined, getNextPageParam: (lastPage) => { if (lastPage.items.length < arg.take) return undefined; else return lastPage.items[arg.take - 1]; - }, - ...args + } }); return query; diff --git a/packages/client/src/explorer/useObjectsOffsetInfiniteQuery.ts b/packages/client/src/explorer/useObjectsOffsetInfiniteQuery.ts index 7bb20c9e4..3580bcf67 100644 --- a/packages/client/src/explorer/useObjectsOffsetInfiniteQuery.ts +++ b/packages/client/src/explorer/useObjectsOffsetInfiniteQuery.ts @@ -8,8 +8,7 @@ import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; export function useObjectsOffsetInfiniteQuery({ arg, - order, - ...args + order }: UseExplorerInfiniteQueryArgs) { const { library } = useLibraryContext(); const ctx = useRspcLibraryContext(); @@ -40,10 +39,10 @@ export function useObjectsOffsetInfiniteQuery({ return { ...result, offset: pageParam, arg }; }, + initialPageParam: 0, getNextPageParam: ({ items, offset, arg }) => { if (items.length >= arg.take) return (offset ?? 0) + 1; - }, - ...args + } }); return query; diff --git a/packages/client/src/explorer/usePathsExplorerQuery.ts b/packages/client/src/explorer/usePathsExplorerQuery.ts index f64f56504..4894fbc74 100644 --- a/packages/client/src/explorer/usePathsExplorerQuery.ts +++ b/packages/client/src/explorer/usePathsExplorerQuery.ts @@ -8,8 +8,6 @@ export function usePathsExplorerQuery(props: { order: FilePathOrder | null; enabled?: boolean; suspense?: boolean; - /** This callback will fire any time the query successfully fetches new data. (NOTE: This will be removed on the next major version (react-query)) */ - onSuccess?: () => void; }) { const query = usePathsOffsetInfiniteQuery(props); diff --git a/packages/client/src/explorer/usePathsInfiniteQuery.ts b/packages/client/src/explorer/usePathsInfiniteQuery.ts deleted file mode 100644 index c28a44b8a..000000000 --- a/packages/client/src/explorer/usePathsInfiniteQuery.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; - -import { - ExplorerItem, - FilePathCursorVariant, - FilePathObjectCursor, - FilePathOrder, - FilePathSearchArgs -} from '../core'; -import { useLibraryContext } from '../hooks'; -import { useRspcLibraryContext } from '../rspc'; -import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; - -export function usePathsInfiniteQuery({ - arg, - order, - onSuccess, - ...args -}: UseExplorerInfiniteQueryArgs) { - const { library } = useLibraryContext(); - const ctx = useRspcLibraryContext(); - - if (order) { - arg.orderAndPagination = { orderOnly: order }; - if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take; - } - - const query = useInfiniteQuery({ - queryKey: ['search.paths', { library_id: library.uuid, arg }] as const, - queryFn: async ({ pageParam, queryKey: [_, { arg }] }) => { - const cItem: Extract = pageParam; - - let orderAndPagination: (typeof arg)['orderAndPagination']; - - if (!cItem) { - if (order) orderAndPagination = { orderOnly: order }; - } else { - let variant: FilePathCursorVariant | undefined; - - if (!order) variant = 'none'; - else if (cItem) { - switch (order.field) { - case 'name': { - const data = cItem.item.name; - if (data !== null) - variant = { - name: { order: order.value, data } - }; - break; - } - case 'sizeInBytes': { - variant = { sizeInBytes: order.value }; - break; - } - case 'dateCreated': { - const data = cItem.item.date_created; - if (data !== null) - variant = { - dateCreated: { order: order.value, data } - }; - break; - } - case 'dateModified': { - const data = cItem.item.date_modified; - if (data !== null) - variant = { - dateModified: { order: order.value, data } - }; - break; - } - case 'dateIndexed': { - const data = cItem.item.date_indexed; - if (data !== null) - variant = { - dateIndexed: { order: order.value, data } - }; - break; - } - case 'object': { - const object = cItem.item.object; - if (!object) break; - - let objectCursor: FilePathObjectCursor | undefined; - - switch (order.value.field) { - case 'dateAccessed': { - const data = object.date_accessed; - if (data !== null) - objectCursor = { - dateAccessed: { order: order.value.value, data } - }; - break; - } - case 'kind': { - const data = object.kind; - if (data !== null) - objectCursor = { - kind: { order: order.value.value, data } - }; - break; - } - } - - if (objectCursor) variant = { object: objectCursor }; - - break; - } - } - } - - if (cItem.item.is_dir === null) throw new Error(); - - if (variant) - orderAndPagination = { - cursor: { cursor: { variant, isDir: cItem.item.is_dir }, id: cItem.item.id } - }; - } - - arg.orderAndPagination = orderAndPagination; - - const result = await ctx.client.query(['search.paths', arg]); - return result; - }, - getNextPageParam: (lastPage) => { - if (arg.take === null || arg.take === undefined) return undefined; - if (lastPage.items.length < arg.take) return undefined; - else return lastPage.items[arg.take - 1]; - }, - onSuccess, - ...args - }); - - return query; -} diff --git a/packages/client/src/explorer/usePathsOffsetInfiniteQuery.ts b/packages/client/src/explorer/usePathsOffsetInfiniteQuery.ts index 52880b489..95d6fba3b 100644 --- a/packages/client/src/explorer/usePathsOffsetInfiniteQuery.ts +++ b/packages/client/src/explorer/usePathsOffsetInfiniteQuery.ts @@ -7,9 +7,7 @@ import { UseExplorerInfiniteQueryArgs } from './useExplorerInfiniteQuery'; export function usePathsOffsetInfiniteQuery({ arg, - order, - onSuccess, - ...args + order }: UseExplorerInfiniteQueryArgs) { const take = arg.take ?? 100; @@ -49,11 +47,10 @@ export function usePathsOffsetInfiniteQuery({ return { ...result, offset: pageParam, arg }; }, + initialPageParam: 0, getNextPageParam: ({ items, offset, arg }) => { if (items.length >= arg.take) return (offset ?? 0) + 1; - }, - onSuccess, - ...args + } }); return query; diff --git a/packages/client/src/hooks/useClientContext.tsx b/packages/client/src/hooks/useClientContext.tsx index 26c870c76..f66825a82 100644 --- a/packages/client/src/hooks/useClientContext.tsx +++ b/packages/client/src/hooks/useClientContext.tsx @@ -1,5 +1,6 @@ -import { AlphaClient } from '@oscartbeaumont-sd/rspc-client/src/v2'; -import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; +import { AlphaClient } from '@spacedrive/rspc-client'; +import { keepPreviousData } from '@tanstack/react-query'; +import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react'; import { LibraryConfigWrapped, Procedures } from '../core'; import { valtioPersist } from '../lib'; @@ -9,8 +10,8 @@ import { useBridgeQuery } from '../rspc'; const libraryCacheLocalStorageKey = 'sd-library-list3'; // number is because the format of this underwent breaking changes export const useCachedLibraries = () => { - const result = useBridgeQuery(['library.list'], { - keepPreviousData: true, + const query = useBridgeQuery(['library.list'], { + placeholderData: keepPreviousData, initialData: () => { const cachedData = localStorage.getItem(libraryCacheLocalStorageKey); @@ -24,14 +25,15 @@ export const useCachedLibraries = () => { } return undefined; - }, - onSuccess: (data) => { - if (data.length > 0) - localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data)); } }); - return result; + useEffect(() => { + if ((query.data?.length ?? 0) > 0) + localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(query.data)); + }, [query.data]); + + return query; }; export async function getCachedLibraries(client: AlphaClient) { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4a96e699f..dc0c5d41a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,4 @@ -import { Link } from '@oscartbeaumont-sd/rspc-client/src/v2'; +import { Link } from '@spacedrive/rspc-client'; declare global { // eslint-disable-next-line diff --git a/packages/client/src/lib/humanizeSize.ts b/packages/client/src/lib/humanizeSize.ts index 45a2cd167..654fe000f 100644 --- a/packages/client/src/lib/humanizeSize.ts +++ b/packages/client/src/lib/humanizeSize.ts @@ -146,3 +146,10 @@ export const humanizeSize = ( } }; }; + +export const compareHumanizedSizes = ( + size1: ReturnType, + size2: ReturnType +): boolean => { + return size1.bytes === size2.bytes && size1.unit === size2.unit && size1.value === size2.value; +}; diff --git a/packages/client/src/rspc-cursed.ts b/packages/client/src/rspc-cursed.ts index 774dcfcfb..ff1c026ef 100644 --- a/packages/client/src/rspc-cursed.ts +++ b/packages/client/src/rspc-cursed.ts @@ -1,5 +1,5 @@ -import { _inferProcedureHandlerInput, inferProcedureResult } from '@oscartbeaumont-sd/rspc-client'; -import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { _inferProcedureHandlerInput, inferProcedureResult } from '@spacedrive/rspc-client'; +import { UseQueryOptions, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; import { useRef } from 'react'; import { Procedures } from './core'; @@ -26,19 +26,20 @@ export function useUnsafeStreamedQuery< TData = inferProcedureResult >( keyAndInput: [K, ..._inferProcedureHandlerInput], - opts: UseQueryOptions & { + opts: Omit, 'queryKey'> & { onBatch(item: TData): void; } -): UseQueryResult & { streaming: TData[] } { +): UseSuspenseQueryResult & { streaming: TData[] } { const data = useRef([]); const rspc = useRspcContext(); // TODO: The normalised cache might cleanup nodes for this query before it's finished streaming. We need a global mutex on the cleanup routine. - const query = useQuery({ + const query = useSuspenseQuery({ + ...opts, queryKey: keyAndInput, queryFn: ({ signal }) => - new Promise((resolve) => { + new Promise((resolve) => { permits += 1; try { @@ -48,7 +49,7 @@ export function useUnsafeStreamedQuery< if (item === null || item === undefined) return; if (typeof item === 'object' && '__stream_complete' in item) { - resolve(data.current as any); + resolve(data.current); return; } @@ -60,8 +61,7 @@ export function useUnsafeStreamedQuery< } finally { permits -= 1; } - }), - ...opts + }) }); return { diff --git a/packages/client/src/rspc.tsx b/packages/client/src/rspc.tsx index 40ec57782..579eacd7f 100644 --- a/packages/client/src/rspc.tsx +++ b/packages/client/src/rspc.tsx @@ -1,6 +1,5 @@ -import { ProcedureDef } from '@oscartbeaumont-sd/rspc-client'; -import { AlphaRSPCError, initRspc } from '@oscartbeaumont-sd/rspc-client/src/v2'; -import { Context, createReactQueryHooks } from '@oscartbeaumont-sd/rspc-react/src/v2'; +import { initRspc, ProcedureDef, RSPCError } from '@spacedrive/rspc-client'; +import { Context, createReactQueryHooks } from '@spacedrive/rspc-react/src/v2'; import { QueryClient } from '@tanstack/react-query'; import { createContext, PropsWithChildren, useContext } from 'react'; import { match, P } from 'ts-pattern'; @@ -102,7 +101,7 @@ export function useInvalidateQuery() { for (const op of ops) { match(op) .with({ type: 'single', data: P.select() }, (op) => { - let key: any[] = [op.key]; + let key: unknown[] = [op.key]; if (op.arg !== null) { key = key.concat(op.arg); } @@ -110,7 +109,7 @@ export function useInvalidateQuery() { if (op.result !== null) { context.queryClient.setQueryData(key, op.result); } else { - context.queryClient.invalidateQueries(key); + context.queryClient.invalidateQueries({ queryKey: key }); } }) .with({ type: 'all' }, (op) => { @@ -124,6 +123,6 @@ export function useInvalidateQuery() { // TODO: Remove/fix this when rspc typesafe errors are working export function extractInfoRSPCError(error: unknown) { - if (!(error instanceof AlphaRSPCError)) return null; + if (!(error instanceof RSPCError)) return null; return error; } diff --git a/packages/client/src/solid/index.ts b/packages/client/src/solid/index.ts index dad153583..3a7aaf513 100644 --- a/packages/client/src/solid/index.ts +++ b/packages/client/src/solid/index.ts @@ -2,7 +2,6 @@ export * from './createPersistedMutable'; export * from './react'; export * from './solid.solid'; export * from './useObserver'; -export * from './useUniversalQuery'; export * from './useSolidStore'; export { InteropProviderReact } from './portals'; export { createSharedContext } from './context'; diff --git a/packages/client/src/solid/solid.solid.tsx b/packages/client/src/solid/solid.solid.tsx index 8036b6e07..dfe24b907 100644 --- a/packages/client/src/solid/solid.solid.tsx +++ b/packages/client/src/solid/solid.solid.tsx @@ -1,5 +1,4 @@ /** @jsxImportSource solid-js */ - import { trackDeep } from '@solid-primitives/deep'; import { createElement, StrictMode, type FunctionComponent } from 'react'; import { createPortal } from 'react-dom'; diff --git a/packages/client/src/solid/useUniversalQuery.ts b/packages/client/src/solid/useUniversalQuery.ts deleted file mode 100644 index 2e9435448..000000000 --- a/packages/client/src/solid/useUniversalQuery.ts +++ /dev/null @@ -1,14 +0,0 @@ -// import { useQuery } from '@tanstack/react-query'; -// import { createQuery } from '@tanstack/solid-query'; - -// import { insideReactRender } from './internal'; - -// export function useUniversalQuery() { -// if (insideReactRender()) { -// useQuery(); -// } else { -// createQuery(); -// } -// } - -export {}; // TODO diff --git a/packages/client/src/stores/auth.ts b/packages/client/src/stores/auth.ts index d261fc845..a494854c0 100644 --- a/packages/client/src/stores/auth.ts +++ b/packages/client/src/stores/auth.ts @@ -1,4 +1,4 @@ -import { RSPCError } from '@oscartbeaumont-sd/rspc-client'; +import { RSPCError } from '@spacedrive/rspc-client'; import { createMutable } from 'solid-js/store'; import { nonLibraryClient } from '../rspc'; diff --git a/packages/client/src/stores/debugState.ts b/packages/client/src/stores/debugState.ts index 23fd751f4..7bfc80855 100644 --- a/packages/client/src/stores/debugState.ts +++ b/packages/client/src/stores/debugState.ts @@ -6,7 +6,7 @@ import { createPersistedMutable, useSolidStore } from '../solid'; export interface DebugState { enabled: boolean; rspcLogger: boolean; - reactQueryDevtools: 'enabled' | 'disabled' | 'invisible'; + reactQueryDevtools: boolean; shareFullTelemetry: boolean; // used for sending telemetry even if the app is in debug mode telemetryLogging: boolean; } @@ -16,7 +16,7 @@ export const debugState = createPersistedMutable( createMutable({ enabled: globalThis.isDev, rspcLogger: false, - reactQueryDevtools: globalThis.isDev ? 'invisible' : 'enabled', + reactQueryDevtools: false, shareFullTelemetry: false, telemetryLogging: false }) diff --git a/packages/config/vite/narrowSolidPlugin.ts b/packages/config/vite/narrowSolidPlugin.ts index 425970ff5..1791ff0fd 100644 --- a/packages/config/vite/narrowSolidPlugin.ts +++ b/packages/config/vite/narrowSolidPlugin.ts @@ -13,10 +13,14 @@ export interface NarrowSolidPluginOptions extends Partial { export function narrowSolidPlugin({ include, exclude, ...rest }: NarrowSolidPluginOptions = {}) { const plugin = solidPlugin(rest); - const originalConfig = plugin.config!.bind(plugin); + const originalConfig = + typeof plugin.config == 'function' + ? (plugin.config.bind(plugin) as typeof plugin.config) + : plugin.config; const filter = createFilter(include, exclude); plugin.config = (...args) => { - const baseConfig = originalConfig(...args); + const baseConfig = + typeof originalConfig == 'function' ? originalConfig?.(...args) : originalConfig; return { ...baseConfig, esbuild: { diff --git a/packages/ui/package.json b/packages/ui/package.json index fc2a83e9b..9cdeef6fd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,7 +44,7 @@ "react-router-dom": "=6.20.1", "sonner": "^1.0.3", "use-debounce": "^9.0.4", - "zod": "~3.22.4" + "zod": "^3.23" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d975ad8f58f93b878dd6d3a87712a6509a5575aa..12c81db051136b40249728f97db4e5afbb05285c 100644 GIT binary patch delta 4170 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 Ro#XQTy{FEz6T^Pq{{mCd8HWG> diff --git a/scripts/list-dup-deps.sh b/scripts/list-dup-deps.sh deleted file mode 100755 index 97c4023ff..000000000 --- a/scripts/list-dup-deps.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -_root="$(CDPATH='' cd "$(dirname "$0")" && pwd -P)" - -grep -Po '^\s+"[\w-]+\s+\d(\.\d+)*[^"]*"' "${_root}/../Cargo.lock" \ - | xargs printf '%s\n' \ - | sort -u -k 1b,2V diff --git a/scripts/preprep.mjs b/scripts/preprep.mjs index 7ffa7a06a..7e0799e03 100755 --- a/scripts/preprep.mjs +++ b/scripts/preprep.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node - import * as fs from 'node:fs/promises' import * as path from 'node:path' import { env, exit, umask } from 'node:process' @@ -9,11 +8,11 @@ import { extractTo } from 'archive-wasm/src/fs.mjs' import * as _mustache from 'mustache' import { parse as parseTOML } from 'smol-toml' -import { getConst, NATIVE_DEPS_URL, NATIVE_DEPS_ASSETS } from './utils/consts.mjs' +import { getConst, NATIVE_DEPS_ASSETS, NATIVE_DEPS_URL } from './utils/consts.mjs' import { get } from './utils/fetch.mjs' import { getMachineId } from './utils/machineId.mjs' import { getRustTargetList } from './utils/rustup.mjs' -import { symlinkSharedLibsMacOS, symlinkSharedLibsLinux } from './utils/shared.mjs' +import { symlinkSharedLibsLinux, symlinkSharedLibsMacOS } from './utils/shared.mjs' import { spinTask } from './utils/spinner.mjs' import { which } from './utils/which.mjs' diff --git a/scripts/tauri.mjs b/scripts/tauri.mjs index 0eed0d73f..46e5f5640 100755 --- a/scripts/tauri.mjs +++ b/scripts/tauri.mjs @@ -1,8 +1,7 @@ #!/usr/bin/env node - import * as fs from 'node:fs/promises' import * as path from 'node:path' -import { env, exit, umask, platform } from 'node:process' +import { env, exit, platform, umask } from 'node:process' import { setTimeout } from 'node:timers/promises' import { fileURLToPath } from 'node:url' diff --git a/scripts/utils/fetch.mjs b/scripts/utils/fetch.mjs index cf8cd6c41..5907e418e 100644 --- a/scripts/utils/fetch.mjs +++ b/scripts/utils/fetch.mjs @@ -4,7 +4,7 @@ import { env } from 'node:process' import { fileURLToPath } from 'node:url' import { getSystemProxy } from 'os-proxy-config' -import { fetch, Headers, Agent, ProxyAgent } from 'undici' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' const CONNECT_TIMEOUT = 5 * 60 * 1000 const __debug = env.NODE_ENV === 'debug' From 59ee4c908f8c022e5a73834be6a09ee391eaf800 Mon Sep 17 00:00:00 2001 From: Matthew Yung <117509016+myung03@users.noreply.github.com> Date: Thu, 24 Oct 2024 00:59:54 -0700 Subject: [PATCH 2/3] [ENG-1881] Update docs (#2739) product documentation --- docs/product/guides/command-palette.mdx | 20 +++++++++++ docs/product/guides/media-conversion.mdx | 46 ++++++++++++++++++++++++ docs/product/guides/media-view.mdx | 20 +++++++++++ docs/product/guides/quick-preview.mdx | 4 +-- docs/product/guides/search.mdx | 25 +++++++++++++ docs/product/guides/spacedrop.mdx | 12 +++++++ docs/product/guides/spaces.mdx | 46 +++++++++++++++++++++++- docs/product/guides/tags.mdx | 2 +- 8 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/product/guides/command-palette.mdx b/docs/product/guides/command-palette.mdx index 4fbdcf621..c85507811 100644 --- a/docs/product/guides/command-palette.mdx +++ b/docs/product/guides/command-palette.mdx @@ -3,3 +3,23 @@ index: 100 --- # Command Palette + +The Command Palette is a powerful tool designed to streamline navigation and provide quick access to key features in Spacedrive. It allows you to search, navigate, and execute commands without needing to leave your current workflow. + +## How to Use the Command Palette + +- **Open the Command Palette:** + Press `CMD + K` (on macOS) or `CTRL + K` (on Windows/Linux) to instantly bring up the command palette. + +- **Search Functionality:** + You can search for specific files, folders, or any other items in your library. This includes documents, media, and metadata, allowing for fast and efficient access to your entire library. + +- **Navigate Through Your Library:** + Easily move between different sections of your workspace by using the palette to jump to various parts of your project, such as folders, files, or even specific locations within those files. + +- **Adding Locations or Creating Tags:** + The Command Palette allows you to quickly add new storage locations or create custom tags for better organization of your content. This feature enables easy tagging and sorting, making your files easier to find and categorize. + +--- + +The Command Palette is designed to enhance your workflow by making it easier to access, organize, and navigate your files and content. Keep an eye out for future updates, as we continue to enhance the Command Palette with new features and improved functionality such as an AI assistant to make your workflow even more seamless. diff --git a/docs/product/guides/media-conversion.mdx b/docs/product/guides/media-conversion.mdx index 60ed90841..031f3b66f 100644 --- a/docs/product/guides/media-conversion.mdx +++ b/docs/product/guides/media-conversion.mdx @@ -9,3 +9,49 @@ index: 100 title="WIP" text="This feature is not available yet, please check our [roadmap](/roadmap)." /> + +Spacedrive makes it easy to manage your media files with powerful built-in conversion capabilities. Whether you need to change file formats, compress media for efficient storage, or ensure compatibility across different devices, Spacedrive's Media Conversion tools are here to help. + +## Key Features of Media Conversion + +- **Effortless Format Conversion:** + Spacedrive allows you to convert media files, including images, videos, and audio, to a variety of popular formats. This helps ensure your files are accessible and usable across different devices and platforms. + +- **Supported File Types:** + You can convert between a wide range of file types, such as: + + - Image formats: JPEG, PNG, SVG, WEBP, GIF, and more. + - Video formats: MP4, AVI, MOV, MKV, and others. + - Audio formats: MP3, WAV, AAC, FLAC, and more. + +- **Batch Processing:** + Convert multiple media files at once with batch processing, saving you time when working with large libraries of media. Simply select the files you need to convert, choose your output format, and let Spacedrive handle the rest. + +- **Compression for Optimal Storage:** + Media files can take up significant storage space, especially high-resolution images and videos. Spacedrive offers options for compressing files during conversion to reduce file size without sacrificing quality. This is particularly useful when managing decentralized storage. + +- **Maintaining Metadata Integrity:** + During the conversion process, Spacedrive preserves important file metadata such as creation date, tags, and descriptions. This ensures that your organizational structure and file information remain intact even after conversion. + +- **Seamless Integration with Decentralized Storage:** + Media conversion in Spacedrive is designed to work seamlessly within the decentralized file management environment. Whether your files are stored locally or across distributed storage nodes, you can easily convert and manage your media without limitations. + +## How to Convert Media Files in Spacedrive + +1. **Locate Your Media Files:** + Use the search or navigation features to find the media files you want to convert in your Spacedrive library. + +2. **Open the Media Conversion Tool:** + Right-click on the file(s) and select "Convert" from the context menu, or use the Command Palette (`Command + K` / `Ctrl + K`) to search for the conversion command. + +3. **Choose Your Target Format:** + Select the desired output format for your file. Options will vary depending on whether you're converting images, videos, or audio files. + +4. **Optional Settings:** + Adjust additional options such as compression levels or resolution for video files. You can also choose whether to retain metadata or strip it for privacy. + +5. **Start the Conversion:** + Click "Convert" to begin the process. You can monitor the conversion progress in the job manager, and batch conversions will queue for processing. + +6. **Access Converted Files:** + Once the conversion is complete, the new file will be available in your library alongside the original, unless you choose to replace it. diff --git a/docs/product/guides/media-view.mdx b/docs/product/guides/media-view.mdx index fa1befe0d..2330a6577 100644 --- a/docs/product/guides/media-view.mdx +++ b/docs/product/guides/media-view.mdx @@ -3,3 +3,23 @@ index: 100 --- # Media View + +The Media View feature in Spacedrive provides a streamlined way to browse and interact with your media files. It offers a visually engaging experience, allowing you to quickly view thumbnails, preview media, and navigate through your collection with ease. + +## How to Open Media View + +To open the Media View, simply navigate to any folder in Spacedrive that contains media files. This can include images, videos, or audio files. + +## Key Features of Media View + +- **Automatic Thumbnails:** + Spacedrive automatically generates thumbnails for all your media files, offering a quick and clear visual reference. Thumbnails make it easier to locate specific files, especially when browsing large folders. + +- **Seamless Media Browsing:** + Easily view all your media files at a glance. The Media View displays your files in a grid, providing an organized overview of images, videos, and audio tracks. You can scroll through the folder to browse and quickly identify the media you're looking for. + +- **Quick Previews:** + Press `Spacebar` on any media file will open the quick preview menu, allowing you to instantly view or play the file without leaving the current folder or opening a separate application. + +- **Zoom and Layout Options:** + Press the filter button on the top right to customize how your media files are displayed, including item size, double click action, advanced explorer options, and sort rules. diff --git a/docs/product/guides/quick-preview.mdx b/docs/product/guides/quick-preview.mdx index c7df1741c..be642aa78 100644 --- a/docs/product/guides/quick-preview.mdx +++ b/docs/product/guides/quick-preview.mdx @@ -6,8 +6,8 @@ index: 10