mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
Merge remote-tracking branch 'origin/main' into eng-1828-migration-to-new-cloud-api-system
This commit is contained in:
4
.github/actions/setup-pnpm/action.yml
vendored
4
.github/actions/setup-pnpm/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/actions/setup-system/action.yml
vendored
2
.github/actions/setup-system/action.yml
vendored
@@ -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'
|
||||
|
||||
@@ -35,3 +35,5 @@ package*.json
|
||||
|
||||
# Dont format locales json
|
||||
interface/locales
|
||||
|
||||
scripts/utils/.tmp/*
|
||||
|
||||
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
62
Cargo.toml
62
Cargo.toml
@@ -20,38 +20,38 @@ rust-version = "1.81"
|
||||
|
||||
[workspace.dependencies]
|
||||
# First party dependencies
|
||||
sd-cloud-schema = { git = "https://github.com/spacedriveapp/cloud-services-schema", rev = "c7c184c4e1" }
|
||||
sd-cloud-schema = { git = "https://github.com/spacedriveapp/cloud-services-schema", rev = "54622014c9" }
|
||||
|
||||
# Third party dependencies used by one or more of our crates
|
||||
async-channel = "2.3"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.83"
|
||||
axum = "0.6.20" # Update blocked by hyper
|
||||
axum = "0.7.7"
|
||||
axum-extra = "0.9.4"
|
||||
base64 = "0.22.1"
|
||||
blake3 = "1.5.4"
|
||||
bytes = "1.7.1" # Update blocked by hyper
|
||||
bytes = "1.7.1" # Update blocked by hyper
|
||||
chrono = "0.4.38"
|
||||
ed25519-dalek = "2.1"
|
||||
flume = "0.11.0"
|
||||
futures = "0.3.30"
|
||||
futures = "0.3.31"
|
||||
futures-concurrency = "7.6"
|
||||
globset = "0.4.15"
|
||||
http = "0.2" # Update blocked by axum
|
||||
hyper = "0.14" # Update blocked due to API breaking changes
|
||||
image = "0.24.9" # Update blocked due to https://github.com/image-rs/image/issues/2230
|
||||
http = "1.1"
|
||||
hyper = "1.5"
|
||||
image = "0.25.4"
|
||||
itertools = "0.13.0"
|
||||
lending-stream = "1.0"
|
||||
libc = "0.2.159"
|
||||
mimalloc = "0.1.43"
|
||||
normpath = "1.2"
|
||||
normpath = "1.3"
|
||||
pin-project-lite = "0.2.14"
|
||||
rand = "0.9.0-alpha.2"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.11", default-features = false } # Update blocked by hyper
|
||||
reqwest = { version = "0.12.8", default-features = false }
|
||||
rmp = "0.8.14"
|
||||
rmp-serde = "1.3"
|
||||
rmpv = { version = "1.3", features = ["with-serde"] }
|
||||
rspc = "0.1.4" # Update blocked by custom patch below
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
specta = "=2.0.0-rc.20"
|
||||
@@ -65,46 +65,48 @@ tokio-util = "0.7.12"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-test = "0.2.5"
|
||||
uhlc = "0.8.0" # Must follow version used by specta
|
||||
uuid = "1.10" # Must follow version used by specta
|
||||
webp = "0.2.6" # Update blocked by image
|
||||
uhlc = "0.8.0" # Must follow version used by specta
|
||||
uuid = "1.10" # Must follow version used by specta
|
||||
webp = "0.3.0"
|
||||
zeroize = "1.8"
|
||||
|
||||
[workspace.dependencies.rspc]
|
||||
git = "https://github.com/spacedriveapp/rspc.git"
|
||||
rev = "6a77167495"
|
||||
|
||||
[workspace.dependencies.prisma-client-rust]
|
||||
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"
|
||||
|
||||
[workspace.dependencies.prisma-client-rust-sdk]
|
||||
default-features = false
|
||||
features = ["sqlite"]
|
||||
git = "https://github.com/brendonovich/prisma-client-rust"
|
||||
rev = "4f9ef9d38c"
|
||||
git = "https://github.com/spacedriveapp/prisma-client-rust"
|
||||
rev = "b22ad7dc7d"
|
||||
|
||||
# Proper IOS Support
|
||||
[patch.crates-io.if-watch]
|
||||
git = "https://github.com/spacedriveapp/if-watch.git"
|
||||
rev = "a92c17d3f8"
|
||||
|
||||
# We use our own version of rspc
|
||||
[patch.crates-io.rspc]
|
||||
git = "https://github.com/spacedriveapp/rspc.git"
|
||||
rev = "bc882f4724"
|
||||
|
||||
# Add `Control::open_stream_with_addrs`
|
||||
[patch.crates-io.libp2p]
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p.git"
|
||||
rev = "a005656df7"
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p"
|
||||
rev = "1024411ffa"
|
||||
[patch.crates-io.libp2p-core]
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p.git"
|
||||
rev = "a005656df7"
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p"
|
||||
rev = "1024411ffa"
|
||||
[patch.crates-io.libp2p-identity]
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p"
|
||||
rev = "1024411ffa"
|
||||
[patch.crates-io.libp2p-swarm]
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p.git"
|
||||
rev = "a005656df7"
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p"
|
||||
rev = "1024411ffa"
|
||||
[patch.crates-io.libp2p-stream]
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p.git"
|
||||
rev = "a005656df7"
|
||||
git = "https://github.com/spacedriveapp/rust-libp2p"
|
||||
rev = "1024411ffa"
|
||||
|
||||
[profile.dev]
|
||||
# Make compilation faster on macOS
|
||||
|
||||
@@ -12,19 +12,19 @@
|
||||
"lint": "eslint src --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oscartbeaumont-sd/rspc-client": "github:spacedriveapp/rspc#path:packages/client&bc882f4724",
|
||||
"@oscartbeaumont-sd/rspc-tauri": "github:spacedriveapp/rspc#path:packages/tauri&bc882f4724",
|
||||
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
|
||||
"@spacedrive/rspc-tauri": "github:spacedriveapp/rspc#path:packages/tauri&6a77167495",
|
||||
"@remix-run/router": "=1.13.1",
|
||||
"@sd/client": "workspace:*",
|
||||
"@sd/interface": "workspace:*",
|
||||
"@sd/ui": "workspace:*",
|
||||
"@t3-oss/env-core": "^0.7.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tauri-apps/api": "=2.0.1",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0",
|
||||
"@tauri-apps/plugin-http": "2.0.0",
|
||||
"@tanstack/react-query": "^5.59",
|
||||
"@tauri-apps/api": "=2.0.3",
|
||||
"@tauri-apps/plugin-dialog": "2.0.1",
|
||||
"@tauri-apps/plugin-http": "2.0.1",
|
||||
"@tauri-apps/plugin-os": "2.0.0",
|
||||
"@tauri-apps/plugin-shell": "2.0.0",
|
||||
"@tauri-apps/plugin-shell": "2.0.1",
|
||||
"consistent-hash": "^1.2.2",
|
||||
"immer": "^10.0.3",
|
||||
"react": "^18.2.0",
|
||||
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@sd/config": "workspace:*",
|
||||
"@sentry/vite-plugin": "^2.16.0",
|
||||
"@tauri-apps/cli": "2.0.1",
|
||||
"@tauri-apps/cli": "2.0.4",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"sass": "^1.72.0",
|
||||
|
||||
@@ -16,7 +16,8 @@ sd-fda = { path = "../../../crates/fda" }
|
||||
sd-prisma = { path = "../../../crates/prisma" }
|
||||
|
||||
# Workspace dependencies
|
||||
axum = { workspace = true, features = ["headers", "query"] }
|
||||
axum = { workspace = true, features = ["query"] }
|
||||
axum-extra = { workspace = true, features = ["typed-header"] }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
hyper = { workspace = true }
|
||||
@@ -38,10 +39,10 @@ opener = { version = "0.7.1", features = ["reveal"], def
|
||||
specta-typescript = "=0.0.7"
|
||||
tauri-plugin-clipboard-manager = "=2.0.1"
|
||||
tauri-plugin-deep-link = "=2.0.1"
|
||||
tauri-plugin-dialog = "=2.0.1"
|
||||
tauri-plugin-http = "=2.0.1"
|
||||
tauri-plugin-dialog = "=2.0.3"
|
||||
tauri-plugin-http = "=2.0.3"
|
||||
tauri-plugin-os = "=2.0.1"
|
||||
tauri-plugin-shell = "=2.0.1"
|
||||
tauri-plugin-shell = "=2.0.2"
|
||||
tauri-plugin-updater = "=2.0.2"
|
||||
|
||||
# memory allocator
|
||||
@@ -49,12 +50,12 @@ mimalloc = { workspace = true }
|
||||
|
||||
[dependencies.tauri]
|
||||
features = ["linux-libxdo", "macos-private-api", "native-tls-vendored", "unstable"]
|
||||
version = "=2.0.1"
|
||||
version = "=2.0.6"
|
||||
|
||||
[dependencies.tauri-specta]
|
||||
features = ["derive", "typescript"]
|
||||
git = "https://github.com/spacedriveapp/tauri-specta"
|
||||
rev = "1baf68be47"
|
||||
rev = "8c85d40eb9"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Spacedrive Sub-crates
|
||||
@@ -76,7 +77,7 @@ sd-desktop-windows = { path = "../crates/windows" }
|
||||
|
||||
[build-dependencies]
|
||||
# Specific Desktop dependencies
|
||||
tauri-build = "=2.0.1"
|
||||
tauri-build = "=2.0.2"
|
||||
|
||||
[features]
|
||||
ai-models = ["sd-core/ai"]
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use std::{
|
||||
net::Ipv4Addr,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use std::{net::Ipv4Addr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State, TypedHeader},
|
||||
headers::authorization::{Authorization, Bearer},
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
http::{Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
RequestPartsExt,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::authorization::{Authorization, Bearer},
|
||||
TypedHeader,
|
||||
};
|
||||
use http::Method;
|
||||
use hyper::server::{accept::Accept, conn::AddrIncoming};
|
||||
use rand::{distr::Alphanumeric, Rng};
|
||||
use sd_core::{custom_uri, Node, NodeError};
|
||||
use serde::Deserialize;
|
||||
@@ -66,29 +64,14 @@ pub async fn sd_server_plugin<R: Runtime>(
|
||||
.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<R: Runtime>(
|
||||
});
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<String>,
|
||||
}
|
||||
|
||||
async fn auth_middleware<B>(
|
||||
async fn auth_middleware(
|
||||
Query(query): Query<QueryParams>,
|
||||
State(auth_token): State<String>,
|
||||
request: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<Response, StatusCode>
|
||||
where
|
||||
B: Send,
|
||||
{
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
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 = <AddrIncoming as Accept>::Conn;
|
||||
type Error = <AddrIncoming as Accept>::Error;
|
||||
|
||||
fn poll_accept(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"three": "^0.161.0",
|
||||
"tsparticles": "^3.3.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zod": "~3.22.4"
|
||||
"zod": "^3.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^13.5.6",
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
|
||||
# Spacedrive Sub-crates
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
sd-core = { default-features = false, features = [
|
||||
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@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",
|
||||
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&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",
|
||||
@@ -31,7 +31,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",
|
||||
|
||||
@@ -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<ModalRef>(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 (
|
||||
|
||||
@@ -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<ModalRef>(null);
|
||||
|
||||
const result = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
const result = useLibraryQuery(['locations.list'], { placeholderData: keepPreviousData });
|
||||
const locations = result.data || [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<SearchData<ExplorerItem>>;
|
||||
query: UseInfiniteQueryResult<InfiniteData<SearchData<ExplorerItem>>>;
|
||||
count?: number;
|
||||
empty?: never;
|
||||
isEmpty?: never;
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
disabled={isLoading}
|
||||
disabled={isPending}
|
||||
onPress={() => toggleFavorite({ id: props.data.id, favorite: !favorite })}
|
||||
style={props.style}
|
||||
>
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ const AddTagModal = forwardRef<ModalRef, unknown>((_, 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ const CreateLibraryModal = forwardRef<ModalRef, unknown>((_, ref) => {
|
||||
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
|
||||
const { mutate: createLibrary, isPending: createLibLoading } = useBridgeMutation(
|
||||
'library.create',
|
||||
{
|
||||
onSuccess: (lib) => {
|
||||
|
||||
@@ -93,7 +93,7 @@ const CloudLibraryCard = ({ modalRef, navigation }: Props) => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
// disabled={joinLibrary.isLoading}
|
||||
// disabled={joinLibrary.isPending}
|
||||
onPress={async () => {
|
||||
// const library = await joinLibrary.mutateAsync(data.uuid);
|
||||
|
||||
@@ -121,7 +121,7 @@ const CloudLibraryCard = ({ modalRef, navigation }: Props) => {
|
||||
}}
|
||||
>
|
||||
<Text style={tw`text-sm font-medium text-white`}>
|
||||
{/* {joinLibrary.isLoading && joinLibrary.variables === data.uuid
|
||||
{/* {joinLibrary.isPending && joinLibrary.variables === data.uuid
|
||||
? 'Joining...'
|
||||
: 'Join'} */}
|
||||
THIS FILE NEEDS TO BE UPDATED TO USE THE NEW LIBRARY SYSTEM IN THE FUTURE
|
||||
|
||||
@@ -47,7 +47,7 @@ const ImportModal = forwardRef<ModalRef, unknown>((_, ref) => {
|
||||
toast.success('Location added successfully');
|
||||
},
|
||||
onSettled: () => {
|
||||
rspc.queryClient.invalidateQueries(['locations.list']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['locations.list'] });
|
||||
modalRef.current?.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,14 +15,14 @@ const DeleteLibraryModal = ({ trigger, onSubmit, libraryUuid }: Props) => {
|
||||
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const { mutate: deleteLibrary, isLoading: deleteLibLoading } = useBridgeMutation(
|
||||
const { mutate: deleteLibrary, isPending: deleteLibLoading } = useBridgeMutation(
|
||||
'library.delete',
|
||||
{
|
||||
onMutate: () => {
|
||||
console.log('Deleting library');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['library.list']);
|
||||
queryClient.invalidateQueries({ queryKey: ['library.list'] });
|
||||
onSubmit?.();
|
||||
submitPlausibleEvent({ event: { type: 'libraryDelete' } });
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ const DeleteLocationModal = ({ trigger, onSubmit, locationId, triggerStyle }: Pr
|
||||
const rspc = useRspcLibraryContext();
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const { mutate: deleteLoc, isLoading: deleteLocLoading } = useLibraryMutation(
|
||||
const { mutate: deleteLoc, isPending: deleteLocLoading } = useLibraryMutation(
|
||||
'locations.delete',
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -30,7 +30,7 @@ const DeleteLocationModal = ({ trigger, onSubmit, locationId, triggerStyle }: Pr
|
||||
},
|
||||
onSettled: () => {
|
||||
modalRef.current?.close();
|
||||
rspc.queryClient.invalidateQueries(['locations.list']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['locations.list'] });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -15,11 +15,11 @@ const DeleteTagModal = ({ trigger, onSubmit, tagId, triggerStyle }: Props) => {
|
||||
const rspc = useRspcLibraryContext();
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const { mutate: deleteTag, isLoading: deleteTagLoading } = useLibraryMutation('tags.delete', {
|
||||
const { mutate: deleteTag, isPending: deleteTagLoading } = useLibraryMutation('tags.delete', {
|
||||
onSuccess: () => {
|
||||
submitPlausibleEvent({ event: { type: 'tagDelete' } });
|
||||
onSubmit?.();
|
||||
rspc.queryClient.invalidateQueries(['tags.list']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['tags.list'] });
|
||||
toast.success('Tag deleted successfully');
|
||||
},
|
||||
onSettled: () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ActionsModal = () => {
|
||||
// Open
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime', {
|
||||
onSuccess: () => {
|
||||
rspc.queryClient.invalidateQueries(['search.paths']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] });
|
||||
}
|
||||
});
|
||||
const queriedFullPath = useLibraryQuery(['files.getPath', filePath?.id ?? -1], {
|
||||
@@ -88,7 +88,7 @@ export const ActionsModal = () => {
|
||||
|
||||
const deleteFile = useLibraryMutation('files.deleteFiles', {
|
||||
onSuccess: () => {
|
||||
rspc.queryClient.invalidateQueries(['search.paths']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] });
|
||||
modalRef.current?.dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const RenameModal = forwardRef<ModalRef>((_, ref) => {
|
||||
const renameFile = useLibraryMutation(['files.renameFile'], {
|
||||
onSuccess: () => {
|
||||
modalRef.current?.dismiss();
|
||||
rspc.queryClient.invalidateQueries(['search.paths']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['search.paths'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to rename object');
|
||||
|
||||
@@ -35,7 +35,7 @@ const CreateTagModal = forwardRef<ModalRef, unknown>((_, ref) => {
|
||||
setTagColor(ToastDefautlColor);
|
||||
setShowPicker(false);
|
||||
|
||||
rspc.queryClient.invalidateQueries(['tags.list']);
|
||||
rspc.queryClient.invalidateQueries({ queryKey: ['tags.list'] });
|
||||
|
||||
toast.success('Tag created successfully');
|
||||
submitPlausibleEvent({ event: { type: 'tagCreate' } });
|
||||
|
||||
@@ -23,7 +23,7 @@ const UpdateTagModal = forwardRef<ModalRef, Props>((props, ref) => {
|
||||
const [tagColor, setTagColor] = useState(props.tag.color!);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const { mutate: updateTag, isLoading } = useLibraryMutation('tags.update', {
|
||||
const { mutate: updateTag, isPending } = useLibraryMutation('tags.update', {
|
||||
onMutate: () => {
|
||||
console.log('Updating tag');
|
||||
},
|
||||
@@ -31,7 +31,7 @@ const UpdateTagModal = forwardRef<ModalRef, Props>((props, ref) => {
|
||||
// Reset form
|
||||
setShowPicker(false);
|
||||
|
||||
queryClient.invalidateQueries(['tags.list']);
|
||||
queryClient.invalidateQueries({ queryKey: ['tags.list'] });
|
||||
|
||||
props.onSubmit?.();
|
||||
},
|
||||
@@ -85,7 +85,7 @@ const UpdateTagModal = forwardRef<ModalRef, Props>((props, ref) => {
|
||||
variant="accent"
|
||||
onPress={() => updateTag({ id: props.tag.id, color: tagColor, name: tagName })}
|
||||
style={tw`mt-6`}
|
||||
disabled={tagName.length === 0 || tagColor.length === 0 || isLoading}
|
||||
disabled={tagName.length === 0 || tagColor.length === 0 || isPending}
|
||||
>
|
||||
<Text style={tw`text-sm font-medium text-white`}>Save</Text>
|
||||
</Button>
|
||||
|
||||
@@ -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';
|
||||
@@ -17,7 +17,7 @@ import StatCard from './StatCard';
|
||||
|
||||
interface Props {
|
||||
node: NodeState | undefined;
|
||||
stats: UseQueryResult<StatisticsResponse, AlphaRSPCError>;
|
||||
stats: UseQueryResult<StatisticsResponse, RSPCError>;
|
||||
}
|
||||
|
||||
export function hardwareModelToIcon(hardwareModel: HardwareModel) {
|
||||
|
||||
@@ -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<StatisticsResponse, AlphaRSPCError>;
|
||||
stats: UseQueryResult<StatisticsResponse, RSPCError>;
|
||||
}
|
||||
|
||||
const OverviewStats = ({ stats }: Props) => {
|
||||
|
||||
@@ -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 (
|
||||
<MotiPressable
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { SearchFilterArgs, useLibraryQuery } from '@sd/client';
|
||||
import { Filters, getSearchStore, SearchFilters, useSearchStore } from '~/stores/searchStore';
|
||||
@@ -14,7 +15,7 @@ export function useFiltersSearch(search: string) {
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
const locations = useLibraryQuery(['locations.list'], {
|
||||
keepPreviousData: true
|
||||
placeholderData: keepPreviousData
|
||||
});
|
||||
|
||||
const filterFactory = (key: SearchFilters, value: Filters[keyof Filters]) => {
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
|
||||
|
||||
@@ -52,10 +52,8 @@ const BackfillWaiting = () => {
|
||||
const syncEnabled = useLibraryQuery(['sync.enabled']);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await enableSync.mutateAsync(null);
|
||||
})();
|
||||
}, []);
|
||||
enableSync.mutate(null);
|
||||
}, [enableSync]);
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 items-center justify-center bg-black`}>
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/src/v2';
|
||||
import { RSPCError } from '@spacedrive/rspc-client';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { UseMutationResult } from '@tanstack/react-query';
|
||||
@@ -116,7 +116,7 @@ async function signInClicked(
|
||||
email: string,
|
||||
password: string,
|
||||
navigator: SettingsStackScreenProps<'AccountProfile'>['navigation'],
|
||||
cloudBootstrap: UseMutationResult<null, AlphaRSPCError, [string, string], unknown>, // Cloud bootstrap mutation
|
||||
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>, // Cloud bootstrap mutation
|
||||
updateUserStore: ReturnType<typeof getUserStore>
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@ const EditLocationSettingsScreen = ({
|
||||
onError: (e) => console.log({ e }),
|
||||
onSuccess: () => {
|
||||
form.reset(form.getValues());
|
||||
queryClient.invalidateQueries(['locations.list']);
|
||||
queryClient.invalidateQueries({ queryKey: ['locations.list'] });
|
||||
toast.success('Location updated!');
|
||||
// TODO: navigate back & reset input focus!
|
||||
}
|
||||
@@ -90,19 +90,19 @@ const EditLocationSettingsScreen = ({
|
||||
});
|
||||
}, [form, navigation, onSubmit]);
|
||||
|
||||
useLibraryQuery(['locations.getWithRules', id], {
|
||||
onSuccess: (data) => {
|
||||
if (data && !form.formState.isDirty)
|
||||
form.reset({
|
||||
displayName: data.name,
|
||||
localPath: data.path,
|
||||
indexer_rules_ids: data.indexer_rules.map((i) => i.id.toString()),
|
||||
generatePreviewMedia: data.generate_preview_media,
|
||||
syncPreviewMedia: data.sync_preview_media,
|
||||
hidden: data.hidden
|
||||
});
|
||||
}
|
||||
});
|
||||
const query = useLibraryQuery(['locations.getWithRules', id]);
|
||||
useEffect(() => {
|
||||
const data = query.data;
|
||||
if (data && !form.formState.isDirty)
|
||||
form.reset({
|
||||
displayName: data.name,
|
||||
localPath: data.path,
|
||||
indexer_rules_ids: data.indexer_rules.map((i) => i.id.toString()),
|
||||
generatePreviewMedia: data.generate_preview_media,
|
||||
syncPreviewMedia: data.sync_preview_media,
|
||||
hidden: data.hidden
|
||||
});
|
||||
}, [form, query.data]);
|
||||
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, SecStr>,
|
||||
}
|
||||
|
||||
async fn basic_auth<B>(
|
||||
State(state): State<AppState>,
|
||||
request: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Response {
|
||||
async fn basic_auth(State(state): State<AppState>, request: Request<Body>, next: Next) -> Response {
|
||||
let request = if !state.auth.is_empty() {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
|
||||
@@ -163,10 +162,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") {
|
||||
@@ -176,11 +172,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(),
|
||||
}
|
||||
}),
|
||||
@@ -189,10 +185,7 @@ async fn main() {
|
||||
"/*id",
|
||||
get(
|
||||
|axum::extract::Path(path): axum::extract::Path<String>| 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('/');
|
||||
@@ -206,7 +199,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()
|
||||
@@ -215,11 +208,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(),
|
||||
},
|
||||
}
|
||||
@@ -242,8 +235,7 @@ async fn main() {
|
||||
let mut addr = "[::]:8080".parse::<SocketAddr>().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!");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -68,7 +68,7 @@ rmp-serde = { workspace = true }
|
||||
rmpv = { workspace = true }
|
||||
rspc = { workspace = true, features = ["alpha", "axum", "chrono", "unstable", "uuid"] }
|
||||
sd-cloud-schema = { 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"] }
|
||||
@@ -88,15 +88,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"
|
||||
|
||||
@@ -47,7 +47,7 @@ quinn = { package = "iroh-quinn", version = "0.11" }
|
||||
reqwest = { version = "0.12", features = ["json", "native-tls-vendored", "stream"] }
|
||||
reqwest-middleware = { version = "0.3", features = ["json"] }
|
||||
reqwest-retry = "0.6"
|
||||
rustls = { version = "=0.23.13", default-features = false, features = ["brotli", "ring", "std"] }
|
||||
rustls = { version = "=0.23.15", default-features = false, features = ["brotli", "ring", "std"] }
|
||||
rustls-platform-verifier = "0.3.3"
|
||||
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<T> {
|
||||
#[pin]
|
||||
reader: ReaderStream<T>,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsyncReadBody<T>
|
||||
where
|
||||
T: AsyncRead,
|
||||
{
|
||||
pub(crate) fn with_capacity_limited(
|
||||
read: T,
|
||||
capacity: usize,
|
||||
max_read_bytes: u64,
|
||||
) -> AsyncReadBody<Take<T>> {
|
||||
AsyncReadBody {
|
||||
reader: ReaderStream::with_capacity(read.take(max_read_bytes), capacity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Body for AsyncReadBody<T>
|
||||
where
|
||||
T: AsyncRead,
|
||||
{
|
||||
type Data = Bytes;
|
||||
type Error = io::Error;
|
||||
|
||||
fn poll_data(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Self::Data, Self::Error>>> {
|
||||
self.project().reader.poll_next(cx)
|
||||
}
|
||||
|
||||
fn poll_trailers(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Result<Option<HeaderMap>, Self::Error>> {
|
||||
Poll::Ready(Ok(None))
|
||||
}
|
||||
}
|
||||
@@ -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<P2P>,
|
||||
identity: RemoteIdentity,
|
||||
mut request: Request<Body>,
|
||||
) -> Response<UnsyncBoxBody<bytes::Bytes, axum::Error>> {
|
||||
) -> Response<Body> {
|
||||
let request_upgrade_header = request.headers().get(header::UPGRADE).cloned();
|
||||
let maybe_client_upgrade = request.extensions_mut().remove::<OnUpgrade>();
|
||||
|
||||
@@ -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<Library>), Response<BoxBody>> {
|
||||
) -> Result<(CacheValue, Arc<Library>), Response<Body>> {
|
||||
let library_id = Uuid::from_str(&lib_id).map_err(bad_request)?;
|
||||
let location_id = loc_id.parse::<location::id::Type>().map_err(bad_request)?;
|
||||
let file_path_id = path_id
|
||||
@@ -245,7 +247,7 @@ pub fn base_router() -> Router<LocalState> {
|
||||
} 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<LocalState> {
|
||||
} 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<LocalState> {
|
||||
|
||||
// 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<LocalState> {
|
||||
} 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<String, Response<BoxBody>> {
|
||||
) -> Result<String, Response<Body>> {
|
||||
let ext = ext.to_lowercase();
|
||||
let mime_type = match ext.as_str() {
|
||||
// AAC audio
|
||||
|
||||
@@ -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<Metadata>,
|
||||
req: request::Parts,
|
||||
mut resp: InfallibleResponse,
|
||||
) -> Result<Response<BoxBody>, Response<BoxBody>> {
|
||||
) -> Result<Response<Body>, Response<Body>> {
|
||||
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))))
|
||||
}
|
||||
|
||||
@@ -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<BoxBody> {
|
||||
pub(crate) fn bad_request(e: impl Debug) -> http::Response<Body> {
|
||||
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<BoxBody> {
|
||||
pub(crate) fn not_found(e: impl Debug) -> http::Response<Body> {
|
||||
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<BoxBody> {
|
||||
pub(crate) fn internal_server_error(e: impl Debug) -> http::Response<Body> {
|
||||
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<BoxBody> {
|
||||
pub(crate) fn not_implemented(e: impl Debug) -> http::Response<Body> {
|
||||
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<B>(req: Request<B>, next: Next<B>) -> Response<BoxBody> {
|
||||
pub(crate) async fn cors_middleware(req: Request<Body>, next: Next) -> Response<Body> {
|
||||
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<B>(req: Request<B>, next: Next<B>) -> 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!");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<P2P>,
|
||||
identity: RemoteIdentity,
|
||||
request: http::Request<axum::body::Body>,
|
||||
) -> Result<Response<Body>, Box<dyn Error>> {
|
||||
) -> Result<Response<Incoming>, Box<dyn Error>> {
|
||||
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<Incoming>| service.clone().call(request));
|
||||
|
||||
http1::Builder::new()
|
||||
.keep_alive(true)
|
||||
.serve_connection(TokioIo::new(stream), hyper_service)
|
||||
.with_upgrades()
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ rand = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "sync"] }
|
||||
zeroize = { workspace = true, features = ["aarch64", "derive"] }
|
||||
zeroize = { workspace = true, features = ["derive"] }
|
||||
|
||||
# External dependencies
|
||||
aead = { version = "0.6.0-rc.0", default-features = false, features = ["stream"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,15 +35,15 @@ zeroize = { workspace = true, features = ["derive"] }
|
||||
dns-lookup = "2.0"
|
||||
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"
|
||||
|
||||
[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"] }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Uuid, Db>,
|
||||
pub prisma: PrismaClient,
|
||||
}
|
||||
|
||||
type Router = rspc::Router<Arc<Mutex<Ctx>>>;
|
||||
|
||||
fn to_map(v: &impl serde::Serialize) -> serde_json::Map<String, Value> {
|
||||
match to_value(v).unwrap() {
|
||||
Value::Object(m) => m,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new() -> RouterBuilder<Arc<Mutex<Ctx>>> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>())
|
||||
})
|
||||
})
|
||||
.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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
array.sort_by(|a, b| a.id.partial_cmp(&b.id).unwrap());
|
||||
|
||||
Ok(array)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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::<HeaderValue>().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::<SocketAddr>().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!");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
2
crates/sync/example/web/.gitignore
vendored
2
crates/sync/example/web/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `dist` folder.<br>
|
||||
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.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Solid App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
// <div className="flex h-screen w-screen flex-row divide-x divide-gray-300">
|
||||
// <div className="flex flex-col space-y-2 p-2">
|
||||
// <div className="space-x-2">
|
||||
// <button
|
||||
// className={ButtonStyles}
|
||||
// onClick={() => createDb.mutate('pullOperations')}
|
||||
// >
|
||||
// Add Database
|
||||
// </button>
|
||||
// <button
|
||||
// className={ButtonStyles}
|
||||
// onClick={() => removeDbs.mutate('pullOperations')}
|
||||
// >
|
||||
// Remove Databases
|
||||
// </button>
|
||||
// <button
|
||||
// className={ButtonStyles}
|
||||
// onClick={() => testCreate.mutate('testCreate')}
|
||||
// >
|
||||
// Test Create
|
||||
// </button>
|
||||
// </div>
|
||||
// <ul className="w-full">
|
||||
// {Object.entries(tests).map(([key, test]) => (
|
||||
// <li key={key}>
|
||||
// <button className="bg-green-300 p-2" onClick={() => test.run()}>
|
||||
// {test.name}
|
||||
// </button>
|
||||
// </li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// </div>
|
||||
// <div className="flex-1">
|
||||
// <ul className="flex flex-row flex-wrap gap-2 p-2">
|
||||
// {dbs.data?.map((id) => (
|
||||
// <Suspense fallback={null} key={id}>
|
||||
// <DatabaseView id={id} />
|
||||
// </Suspense>
|
||||
// ))}
|
||||
// </ul>
|
||||
// </div>
|
||||
// <div className="flex w-96 flex-col items-stretch p-2">
|
||||
// <h1 className="text-center text-2xl font-bold">All Operations</h1>
|
||||
// <ul className="space-y-2">
|
||||
// {operations.data?.map((op) => (
|
||||
// <li key={op.id} className="rounded-md bg-indigo-200 p-2">
|
||||
// <p className="truncate">ID: {op.id}</p>
|
||||
// <p className="truncate">Timestamp: {op.timestamp.toString()}</p>
|
||||
// <p className="truncate">Node: {op.node}</p>
|
||||
// </li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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 (
|
||||
// <div className="min-w-[32rem] flex-1 overflow-hidden rounded-md bg-indigo-300">
|
||||
// <div className="mx-2 flex flex-row items-center justify-between">
|
||||
// <h1 className="p-2 text-xl font-medium">{props.id}</h1>
|
||||
// <button className={ButtonStyles} onClick={() => pullOperations.mutate(props.id)}>
|
||||
// Pull Operations
|
||||
// </button>
|
||||
// </div>
|
||||
// <div>
|
||||
// <nav className="space-x-2">
|
||||
// {TABS.map((tab) => (
|
||||
// <button
|
||||
// key={tab}
|
||||
// className={clsx('px-2 py-1', tab === currentTab && 'bg-indigo-400')}
|
||||
// onClick={() => setCurrentTab(tab)}
|
||||
// >
|
||||
// {tab}
|
||||
// </button>
|
||||
// ))}
|
||||
// </nav>
|
||||
// <Suspense>
|
||||
// {currentTab === 'File Paths' && <FilePathList db={props.id} />}
|
||||
// {currentTab === 'Operations' && <OperationList db={props.id} />}
|
||||
// </Suspense>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// function FilePathList(props: { db: string }) {
|
||||
// const createFilePath = rspc.useMutation('file_path.create');
|
||||
// const filePaths = rspc.useQuery(['file_path.list', props.db]);
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// {filePaths.data && (
|
||||
// <ul className="font-mono">
|
||||
// {filePaths.data
|
||||
// .sort((a, b) => a.id.localeCompare(b.id))
|
||||
// .map((path) => (
|
||||
// <li key={path.id}>{JSON.stringify(path)}</li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// )}
|
||||
// <button className="text-center" onClick={() => createFilePath.mutate(props.db)}>
|
||||
// Create
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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 (
|
||||
// <div>
|
||||
// {messages.data && (
|
||||
// <table className="border-separate border-spacing-x-4 font-mono">
|
||||
// {messages.data
|
||||
// .sort((a, b) => Number(a.timestamp - b.timestamp))
|
||||
// .map((message) => (
|
||||
// <tr key={message.id}>
|
||||
// <td className="border border-transparent">{message.id}</td>
|
||||
// <td className="border border-transparent">
|
||||
// {new Date(
|
||||
// Number(message.timestamp) / 10000000
|
||||
// ).toLocaleTimeString()}
|
||||
// </td>
|
||||
// <td className="border border-transparent">
|
||||
// {messageType(message.typ)}
|
||||
// </td>
|
||||
// </tr>
|
||||
// ))}
|
||||
// </table>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// const ButtonStyles = 'bg-blue-500 text-white px-2 py-1 rounded-md';
|
||||
|
||||
export {};
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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(
|
||||
// <rspc.Provider client={rspcClient} queryClient={queryClient}>
|
||||
// <Suspense fallback={null}>
|
||||
// <App />
|
||||
// </Suspense>
|
||||
// </rspc.Provider>
|
||||
// );
|
||||
|
||||
export {};
|
||||
@@ -1,47 +0,0 @@
|
||||
// import { queryClient, rspcClient } from './utils/rspc';
|
||||
|
||||
// function test(fn: () => Promise<void>) {
|
||||
// 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 {};
|
||||
80
crates/sync/example/web/src/utils/bindings.ts
generated
80
crates/sync/example/web/src/utils/bindings.ts
generated
@@ -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<string, Tag> }
|
||||
| { key: 'dbs'; input: string; result: Array<string> }
|
||||
| { key: 'file_path.list'; input: string; result: Array<FilePath> }
|
||||
| { key: 'message.list'; input: string; result: Array<CRDTOperation> }
|
||||
| { key: 'operations'; input: string; result: Array<CRDTOperation> };
|
||||
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<OwnedOperationItem>;
|
||||
}
|
||||
|
||||
export type OwnedOperationData =
|
||||
| { Create: Record<string, any> }
|
||||
| { Update: Record<string, any> }
|
||||
| '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<string, any> } | 'Atomic';
|
||||
|
||||
export type SharedOperationData =
|
||||
| { Create: SharedOperationCreateData }
|
||||
| { Update: { field: string; value: any } }
|
||||
| 'Delete';
|
||||
|
||||
export interface Tag {
|
||||
color: Color;
|
||||
name: string;
|
||||
}
|
||||
@@ -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<Procedures>();
|
||||
|
||||
// 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 {};
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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<OpenWithApplication[], null>) => {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -224,7 +224,7 @@ const SpacedropNodes = () => {
|
||||
<Menu.Item
|
||||
key={id}
|
||||
label={peer.metadata.name}
|
||||
disabled={spacedrop.isLoading}
|
||||
disabled={spacedrop.isPending}
|
||||
onClick={async () => {
|
||||
spacedrop.mutateAsync({
|
||||
identity: id,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
// <Dialog
|
||||
// form={form}
|
||||
// dialog={useDialog(props)}
|
||||
// onSubmit={form.handleSubmit((data) =>
|
||||
// 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"
|
||||
// >
|
||||
// <div className="space-y-2 py-2">
|
||||
// <h2 className="text-xs font-bold">Key Type</h2>
|
||||
// <RadioGroup
|
||||
// value={form.watch('type')}
|
||||
// onChange={(e: 'key' | 'password') => form.setValue('type', e)}
|
||||
// className="mt-2 flex flex-row gap-2"
|
||||
// >
|
||||
// <RadioGroup.Option disabled={!hasMountedKeys} value="key">
|
||||
// {({ checked }) => (
|
||||
// <Button
|
||||
// type="button"
|
||||
// disabled={!hasMountedKeys}
|
||||
// size="sm"
|
||||
// variant={checked ? 'accent' : 'gray'}
|
||||
// >
|
||||
// Key Manager
|
||||
// </Button>
|
||||
// )}
|
||||
// </RadioGroup.Option>
|
||||
// <RadioGroup.Option value="password">
|
||||
// {({ checked }) => (
|
||||
// <Button type="button" size="sm" variant={checked ? 'accent' : 'gray'}>
|
||||
// Password
|
||||
// </Button>
|
||||
// )}
|
||||
// </RadioGroup.Option>
|
||||
// </RadioGroup>
|
||||
|
||||
// {form.watch('type') === 'key' && (
|
||||
// <div className="flex flex-row items-center">
|
||||
// <Switch
|
||||
// className="bg-app-selected"
|
||||
// size="sm"
|
||||
// name=""
|
||||
// checked={form.watch('mountAssociatedKey')}
|
||||
// onCheckedChange={(e) => form.setValue('mountAssociatedKey', e)}
|
||||
// />
|
||||
// <span className="ml-3 mt-0.5 text-xs font-medium">
|
||||
// Automatically mount key
|
||||
// </span>
|
||||
// <Tooltip label="The key linked with the file will be automatically mounted">
|
||||
// <Info className="ml-1.5 mt-0.5 h-4 w-4 text-ink-faint" />
|
||||
// </Tooltip>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {form.watch('type') === 'password' && (
|
||||
// <>
|
||||
// <PasswordInput
|
||||
// placeholder="Password"
|
||||
// {...form.register('password', { required: true })}
|
||||
// />
|
||||
|
||||
// <div className="flex flex-row items-center">
|
||||
// <Switch
|
||||
// className="bg-app-selected"
|
||||
// size="sm"
|
||||
// {...form.register('saveToKeyManager')}
|
||||
// />
|
||||
// <span className="ml-3 mt-0.5 text-xs font-medium">
|
||||
// Save to Key Manager
|
||||
// </span>
|
||||
// <Tooltip label="This key will be saved to the key manager">
|
||||
// <Info className="ml-1.5 mt-0.5 h-4 w-4 text-ink-faint" />
|
||||
// </Tooltip>
|
||||
// </div>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// <h2 className="text-xs font-bold">Output file</h2>
|
||||
// <Button
|
||||
// size="sm"
|
||||
// variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
|
||||
// className="h-[23px] text-xs leading-3"
|
||||
// type="button"
|
||||
// onClick={() => {
|
||||
// // if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
|
||||
// if (!platform.saveFilePickerDialog) {
|
||||
// // TODO: Support opening locations on web
|
||||
// showAlertDialog({
|
||||
// title: 'Error',
|
||||
// value: "System dialogs aren't supported on this platform."
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// platform.saveFilePickerDialog().then((result) => {
|
||||
// if (result) form.setValue('outputPath', result as string);
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// Select
|
||||
// </Button>
|
||||
// </div>
|
||||
// </Dialog>
|
||||
// );
|
||||
// };
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 (
|
||||
// <Dialog
|
||||
// form={form}
|
||||
// onSubmit={form.handleSubmit((data) =>
|
||||
// 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"
|
||||
// >
|
||||
// <div className="mb-3 mt-4 grid w-full grid-cols-2 gap-4">
|
||||
// <div className="flex flex-col">
|
||||
// <span className="text-xs font-bold">Key</span>
|
||||
// <Select
|
||||
// className="mt-2"
|
||||
// value={form.watch('key')}
|
||||
// onChange={(e) => {
|
||||
// UpdateKey(e);
|
||||
// }}
|
||||
// >
|
||||
// {mountedUuids.data && <KeyListSelectOptions keys={mountedUuids.data} />}
|
||||
// </Select>
|
||||
// </div>
|
||||
// <div className="flex flex-col">
|
||||
// <span className="text-xs font-bold">Output file</span>
|
||||
|
||||
// <Button
|
||||
// size="sm"
|
||||
// variant={form.watch('outputPath') !== '' ? 'accent' : 'gray'}
|
||||
// className="mt-2 h-[23px] text-xs leading-3"
|
||||
// type="button"
|
||||
// onClick={() => {
|
||||
// // if we allow the user to encrypt multiple files simultaneously, this should become a directory instead
|
||||
// if (!platform.saveFilePickerDialog) {
|
||||
// // TODO: Support opening locations on web
|
||||
// showAlertDialog({
|
||||
// title: 'Error',
|
||||
// description: '',
|
||||
// value: "System dialogs aren't supported on this platform.",
|
||||
// inputBox: false
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// platform.saveFilePickerDialog().then((result) => {
|
||||
// if (result) form.setValue('outputPath', result as string);
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// Select
|
||||
// </Button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mb-3 mt-4 grid w-full grid-cols-2 gap-4">
|
||||
// <div className="flex flex-col">
|
||||
// <span className="text-xs font-bold">Encryption</span>
|
||||
// <Select
|
||||
// className="mt-2"
|
||||
// value={form.watch('encryptionAlgo')}
|
||||
// onChange={(e) => form.setValue('encryptionAlgo', e)}
|
||||
// >
|
||||
// <SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
|
||||
// <SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
|
||||
// </Select>
|
||||
// </div>
|
||||
// <div className="flex flex-col">
|
||||
// <span className="text-xs font-bold">Hashing</span>
|
||||
// <Select
|
||||
// className="mt-2 text-gray-400/80"
|
||||
// onChange={() => {}}
|
||||
// value={form.watch('hashingAlgo')}
|
||||
// disabled
|
||||
// >
|
||||
// <SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
|
||||
// <SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
|
||||
// <SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
|
||||
// <SelectOption value="BalloonBlake3-s">
|
||||
// BLAKE3-Balloon (standard)
|
||||
// </SelectOption>
|
||||
// <SelectOption value="BalloonBlake3-h">
|
||||
// BLAKE3-Balloon (hardened)
|
||||
// </SelectOption>
|
||||
// <SelectOption value="BalloonBlake3-p">
|
||||
// BLAKE3-Balloon (paranoid)
|
||||
// </SelectOption>
|
||||
// </Select>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mb-3 mt-4 grid w-full grid-cols-2 gap-4">
|
||||
// <div className="flex">
|
||||
// <span className="ml-0.5 mr-3 mt-0.5 text-sm font-bold">Metadata</span>
|
||||
// <CheckBox {...form.register('metadata')} />
|
||||
// </div>
|
||||
// <div className="flex">
|
||||
// <span className="ml-0.5 mr-3 mt-0.5 text-sm font-bold">Preview Media</span>
|
||||
// <CheckBox {...form.register('previewMedia')} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </Dialog>
|
||||
// );
|
||||
// };
|
||||
@@ -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 (
|
||||
// <Dialog
|
||||
// form={form}
|
||||
// onSubmit={form.handleSubmit((data) =>
|
||||
// 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')}
|
||||
// >
|
||||
// <div className="mt-2 flex flex-col">
|
||||
// <span className="text-xs font-bold">{t('number_of_passes')}</span>
|
||||
|
||||
// <div className="flex flex-row space-x-2">
|
||||
// <div className="relative mt-2 flex grow">
|
||||
// <Slider
|
||||
// value={passes}
|
||||
// max={16}
|
||||
// min={1}
|
||||
// step={1}
|
||||
// defaultValue={[4]}
|
||||
// onValueChange={(val) => {
|
||||
// setPasses(val);
|
||||
// form.setValue('passes', val[0] ?? 1);
|
||||
// }}
|
||||
// />
|
||||
// </div>
|
||||
// <span className="mt-2.5 text-sm font-medium">{passes}</span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* <p>TODO: checkbox for "erase all matching files" (only if a file is selected)</p> */}
|
||||
// </Dialog>
|
||||
// );
|
||||
// };
|
||||
40
interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx
Normal file
40
interface/app/$libraryId/Explorer/FilePath/ErrorBarrier.tsx
Normal file
@@ -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<ErrorBarrierProps, ErrorBarrierState> {
|
||||
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;
|
||||
@@ -1,14 +1,7 @@
|
||||
import { ComponentProps, forwardRef } from 'react';
|
||||
|
||||
import { useSize } from './utils';
|
||||
|
||||
export interface ImageProps extends ComponentProps<'img'> {
|
||||
extension?: string;
|
||||
size: ReturnType<typeof useSize>;
|
||||
}
|
||||
|
||||
export const Image = forwardRef<HTMLImageElement, ImageProps>(
|
||||
({ crossOrigin, size, ...props }, ref) => (
|
||||
export const Image = forwardRef<HTMLImageElement, ComponentProps<'img'>>(
|
||||
({ crossOrigin, ...props }, ref) => (
|
||||
<img
|
||||
// Order matter for crossOrigin attr
|
||||
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getLayeredIcon } from '@sd/assets/util';
|
||||
import { getIcon, getIconByName, getLayeredIcon, IconTypes } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, Suspense, type ImgHTMLAttributes } from 'react';
|
||||
import { forwardRef, Suspense, useMemo, type ImgHTMLAttributes } from 'react';
|
||||
import { type ObjectKindKey } from '@sd/client';
|
||||
import { useIsDark } from '~/hooks';
|
||||
|
||||
interface LayeredFileIconProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
interface LayeredFileIconProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, '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<string, string> = {
|
||||
};
|
||||
|
||||
const LayeredFileIcon = forwardRef<HTMLImageElement, LayeredFileIconProps>(
|
||||
({ kind, extension, ...props }, ref) => {
|
||||
const iconImg = <img ref={ref} {...props} />;
|
||||
({ 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 = <img ref={ref} src={src} {...props} alt={`${kind} icon`} />;
|
||||
|
||||
if (SUPPORTED_ICONS.includes(kind) === false) {
|
||||
return iconImg;
|
||||
|
||||
@@ -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<Element, Event>): void;
|
||||
}
|
||||
|
||||
export function Original({
|
||||
itemData,
|
||||
filePath,
|
||||
path,
|
||||
fileId,
|
||||
locationId,
|
||||
...props
|
||||
}: Omit<OriginalRendererProps, 'src'> & {
|
||||
filePath: ReturnType<typeof getItemFilePath>;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
if (error) throw new Error('onError');
|
||||
}: Omit<OriginalRendererProps, 'src'>) {
|
||||
const [error, setError] = useState<Error | null>(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 <Renderer src={src} itemData={itemData} onError={() => setError(true)} {...props} />;
|
||||
return (
|
||||
<Renderer
|
||||
src={src}
|
||||
onError={(event) =>
|
||||
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<OriginalRendererProps, 'fileId' | 'locationId' | 'path'> & {
|
||||
onError?(e: ErrorEvent | SyntheticEvent<Element, Event>): 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<typeof originalRendererKind>;
|
||||
@@ -135,44 +147,45 @@ const ORIGINAL_RENDERERS: {
|
||||
)}
|
||||
/>
|
||||
),
|
||||
Audio: (props) => (
|
||||
<>
|
||||
<img
|
||||
src={getIcon(iconNames.Audio, props.isDark, props.itemData.extension)}
|
||||
onLoad={props.onLoad}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
className={props.childClassName}
|
||||
draggable={false}
|
||||
/>
|
||||
{props.mediaControls && (
|
||||
<audio
|
||||
// Order matter for crossOrigin attr
|
||||
crossOrigin="anonymous"
|
||||
src={props.src}
|
||||
onError={props.onError}
|
||||
controls
|
||||
autoPlay
|
||||
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
|
||||
>
|
||||
<p>{i18n.t('audio_preview_not_supported')}</p>
|
||||
</audio>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
Audio: (props) => {
|
||||
const isDark = useIsDark();
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={getIcon(iconNames.Audio, isDark, props.extension)}
|
||||
onLoad={props.onLoad}
|
||||
decoding="sync"
|
||||
className={props.childClassName}
|
||||
draggable={false}
|
||||
/>
|
||||
{props.mediaControls && (
|
||||
<audio
|
||||
// Order matter for crossOrigin attr
|
||||
crossOrigin="anonymous"
|
||||
src={props.src}
|
||||
onError={props.onError}
|
||||
controls
|
||||
autoPlay
|
||||
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
|
||||
>
|
||||
<p>{i18n.t('audio_preview_not_supported')}</p>
|
||||
</audio>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
Image: (props) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const size = useSize(ref);
|
||||
|
||||
return (
|
||||
<div className="custom-scroll quick-preview-images-scroll flex size-full justify-center transition-all">
|
||||
<Image
|
||||
ref={ref}
|
||||
src={props.src}
|
||||
size={size}
|
||||
style={{ transform: `scale(${props.magnification})` }}
|
||||
onLoad={props.onLoad}
|
||||
onError={props.onError}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
decoding="async"
|
||||
className={clsx(
|
||||
props.className,
|
||||
props.frameClassName,
|
||||
|
||||
@@ -1,35 +1,251 @@
|
||||
import { getIcon, getIconByName } from '@sd/assets/util';
|
||||
import { IconTypes } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
ComponentProps,
|
||||
ErrorInfo,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
memo,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { getItemFilePath, useLibraryContext, type ExplorerItem } from '@sd/client';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import { getItemFilePath, ObjectKindKey, useLibraryContext, type ExplorerItem } from '@sd/client';
|
||||
import { pdfViewerEnabled } from '~/util/pdfViewer';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { explorerStore } from '../store';
|
||||
import { useExplorerItemData } from '../useExplorerItemData';
|
||||
import { Image, ImageProps } from './Image';
|
||||
import { ErrorBarrier } from './ErrorBarrier';
|
||||
import { Image } from './Image';
|
||||
import LayeredFileIcon from './LayeredFileIcon';
|
||||
import { Original } from './Original';
|
||||
import { useFrame } from './useFrame';
|
||||
import { useBlackBars, useSize } from './utils';
|
||||
|
||||
export interface ThumbProps {
|
||||
export type ThumbType = 'original' | 'thumbnail' | 'icon';
|
||||
|
||||
type LoadState = {
|
||||
[K in ThumbType]: 'normal' | 'error';
|
||||
};
|
||||
|
||||
const ThumbClasses = 'max-h-full max-w-full object-contain';
|
||||
|
||||
interface ThumbnailProps extends ComponentProps<'img'> {
|
||||
cover?: boolean;
|
||||
blackBars?: boolean;
|
||||
blackBarsSize?: number;
|
||||
videoExtension?: string;
|
||||
}
|
||||
|
||||
const Thumbnail = memo(
|
||||
forwardRef<HTMLImageElement, ThumbnailProps>(
|
||||
(
|
||||
{
|
||||
blackBars,
|
||||
blackBarsSize,
|
||||
videoExtension: extension,
|
||||
cover,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
_ref
|
||||
) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(
|
||||
_ref,
|
||||
() => ref.current
|
||||
);
|
||||
|
||||
const size = useSize(ref);
|
||||
|
||||
const { style: blackBarsStyle } = useBlackBars(ref, size, {
|
||||
size: blackBarsSize,
|
||||
disabled: !blackBars
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
{...props}
|
||||
className={clsx(className, blackBars && size.width === 0 && 'invisible')}
|
||||
style={{ ...style, ...blackBarsStyle }}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
{(cover || size.width > 80) && extension && (
|
||||
<div
|
||||
style={{
|
||||
...(!cover && {
|
||||
marginTop: Math.floor(size.height / 2) - 2,
|
||||
marginLeft: Math.floor(size.width / 2) - 2
|
||||
})
|
||||
}}
|
||||
className={clsx(
|
||||
'pointer-events-none absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
|
||||
cover
|
||||
? 'bottom-1 right-1'
|
||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full'
|
||||
)}
|
||||
>
|
||||
{extension}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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<Element, Event>) => 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<HTMLImageElement, ThumbProps>(
|
||||
(
|
||||
{
|
||||
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<HTMLImageElement>(null);
|
||||
useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(
|
||||
_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 = (
|
||||
<Original
|
||||
path={path}
|
||||
kind={kind}
|
||||
frame={frame}
|
||||
fileId={fileId}
|
||||
onLoad={handleLoad}
|
||||
extension={extension}
|
||||
blackBars={blackBars}
|
||||
className={clsx(ThumbClasses, className, isLoading && 'hidden')}
|
||||
locationId={locationId}
|
||||
pauseVideo={pauseVideo}
|
||||
blackBarsSize={blackBarsSize}
|
||||
magnification={magnification}
|
||||
mediaControls={mediaControls}
|
||||
frameClassName={frameClassName}
|
||||
childClassName={className}
|
||||
isSidebarPreview={isSidebarPreview}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'thumbnail':
|
||||
thumb = (
|
||||
<Thumbnail
|
||||
{...props}
|
||||
ref={ref}
|
||||
src={src}
|
||||
cover={cover}
|
||||
onLoad={handleLoad}
|
||||
decoding="async"
|
||||
className={clsx(
|
||||
cover
|
||||
? [
|
||||
'min-h-full min-w-full object-cover object-center',
|
||||
className
|
||||
]
|
||||
: [ThumbClasses, className],
|
||||
frame && !(kind === 'Video' && blackBars) ? frameClassName : null,
|
||||
isLoading && 'hidden'
|
||||
)}
|
||||
blackBars={blackBars && kind === 'Video' && !cover}
|
||||
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
|
||||
blackBarsSize={blackBarsSize}
|
||||
videoExtension={videoExtension}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayeredFileIcon
|
||||
{...props}
|
||||
ref={thumb == null ? ref : null}
|
||||
kind={kind}
|
||||
isDir={isDir}
|
||||
onLoad={thumb == null ? onLoad : () => {}}
|
||||
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<HTMLImageElement, FileThumbProps>((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<LoadState>({
|
||||
icon: 'normal',
|
||||
original: 'normal',
|
||||
thumbnail: 'normal'
|
||||
});
|
||||
|
||||
export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((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<LoadState>({
|
||||
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<ThumbType>(() => {
|
||||
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<Element, Event>
|
||||
) => {
|
||||
setLoadState((state) => ({ ...state, [s]: 'error' }));
|
||||
const onError = useCallback(
|
||||
(event: Error | ErrorEvent | SyntheticEvent<Element, Event>) => {
|
||||
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 (
|
||||
<Thumbnail
|
||||
{...props.childProps}
|
||||
ref={ref}
|
||||
src={src}
|
||||
cover={props.cover}
|
||||
onLoad={() => 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 (
|
||||
<LayeredFileIcon
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...(props.size
|
||||
? { maxWidth: props.size, width: props.size, height: props.size }
|
||||
: {})
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex shrink-0 items-center justify-center',
|
||||
!props.size && 'size-full',
|
||||
props.cover && 'overflow-hidden',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<ErrorBarrier
|
||||
onError={useCallback(
|
||||
(error: Error, info: ErrorInfo) => {
|
||||
console.error('ErrorBoundary', error, info);
|
||||
onError(error);
|
||||
},
|
||||
[onError]
|
||||
)}
|
||||
>
|
||||
<Thumb
|
||||
{...props.childProps}
|
||||
ref={ref}
|
||||
src={src}
|
||||
kind={itemData.kind}
|
||||
path={filePath && 'path' in filePath ? filePath.path : null}
|
||||
frame={props.frame ?? false}
|
||||
cover={props.cover}
|
||||
isDir={itemData.isDir}
|
||||
fileId={filePath && 'id' in filePath ? filePath.id : null}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
thumbType={thumbType}
|
||||
extension={itemData.extension}
|
||||
onLoad={() => onLoad('icon')}
|
||||
onError={(e) => onError('icon', e)}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
className={className}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={thumbType.variant}
|
||||
style={{
|
||||
...(props.size
|
||||
? { maxWidth: props.size, width: props.size, height: props.size }
|
||||
: {})
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex shrink-0 items-center justify-center',
|
||||
// !loaded && 'invisible',
|
||||
!props.size && 'size-full',
|
||||
props.cover && 'overflow-hidden',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.loadOriginal ? (
|
||||
<ErrorBoundary fallback={thumbnail}>
|
||||
<Original
|
||||
onLoad={() => 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}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
thumbnail
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ThumbnailProps extends Omit<ImageProps, 'blackBarsStyle' | 'size'> {
|
||||
cover?: boolean;
|
||||
blackBars?: boolean;
|
||||
blackBarsSize?: number;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
const Thumbnail = forwardRef<HTMLImageElement, ThumbnailProps>(
|
||||
({ blackBars, blackBarsSize, extension, cover, className, style, ...props }, _ref) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(
|
||||
_ref,
|
||||
() => ref.current
|
||||
</ErrorBarrier>
|
||||
</div>
|
||||
);
|
||||
|
||||
const size = useSize(ref);
|
||||
|
||||
const { style: blackBarsStyle } = useBlackBars(ref, size, {
|
||||
size: blackBarsSize,
|
||||
disabled: !blackBars
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
{...props}
|
||||
className={clsx(className, blackBars && size.width === 0 && 'invisible')}
|
||||
style={{ ...style, ...blackBarsStyle }}
|
||||
size={size}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
{(cover || size.width > 80) && extension && (
|
||||
<div
|
||||
style={{
|
||||
...(!cover && {
|
||||
marginTop: Math.floor(size.height / 2) - 2,
|
||||
marginLeft: Math.floor(size.width / 2) - 2
|
||||
})
|
||||
}}
|
||||
className={clsx(
|
||||
'pointer-events-none absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
|
||||
cover
|
||||
? 'bottom-1 right-1'
|
||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full'
|
||||
)}
|
||||
>
|
||||
{extension}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>();
|
||||
const [thumbErrorToast, setThumbErrorToast] = useState<ToastMessage>();
|
||||
const [showMetadata, setShowMetadata] = useState<boolean>(false);
|
||||
const [magnification, setMagnification] = useState<number>(1);
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
const [newName, setNewName] = useState<string | null>(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 = () => {
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-1 flex-col justify-between overflow-hidden bg-app/80 backdrop-blur">
|
||||
{thumbnailLoading !== 'error' &&
|
||||
thumbnailLoading !== 'notLoaded' &&
|
||||
background && (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<FileThumb
|
||||
data={item}
|
||||
cover
|
||||
childClassName="scale-125"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-3xl" />
|
||||
</div>
|
||||
)}
|
||||
{!hasError && isLoaded && background && (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<FileThumb data={item} cover childClassName="scale-125" />
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-3xl" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'z-50 flex items-center p-2',
|
||||
@@ -371,20 +353,25 @@ export const QuickPreview = () => {
|
||||
</Dialog.Close>
|
||||
</Tooltip>
|
||||
|
||||
{thumbnailLoading === 'error' && (
|
||||
<Tooltip label={t('quickpreview_thumbnail_error_tip')}>
|
||||
<div className="ml-1 flex items-center gap-1 rounded-md border border-white/5 bg-app-lightBox/30 p-1.5 backdrop-blur-md">
|
||||
<WarningCircle
|
||||
className="text-red-500"
|
||||
weight="fill"
|
||||
size={14}
|
||||
/>
|
||||
<p className="text-xs text-ink">
|
||||
{t('quickpreview_thumbnail_error_message')}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{thumbnailLoading.original === 'error' &&
|
||||
thumbnailLoading.thumbnail === 'loaded' && (
|
||||
<Tooltip
|
||||
label={t('quickpreview_thumbnail_error_tip')}
|
||||
>
|
||||
<div className="ml-1 flex items-center gap-1 rounded-md border border-white/5 bg-app-lightBox/30 p-1.5 backdrop-blur-md">
|
||||
<WarningCircle
|
||||
className="text-red-500"
|
||||
weight="fill"
|
||||
size={14}
|
||||
/>
|
||||
<p className="text-xs text-ink">
|
||||
{t(
|
||||
'quickpreview_thumbnail_error_message'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{items.length > 1 && (
|
||||
<div className="ml-2 flex">
|
||||
@@ -616,56 +603,42 @@ export const QuickPreview = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thumbnailLoading === 'error' ? (
|
||||
<>
|
||||
<FileThumb
|
||||
data={item}
|
||||
frameClassName="!border-0"
|
||||
className={clsx(
|
||||
'mx-auto my-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'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<FileThumb
|
||||
data={item}
|
||||
onLoad={(type) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<FileThumb
|
||||
data={item}
|
||||
onLoad={(type) => {
|
||||
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 && (
|
||||
<ImageSlider activeItem={activeItem} />
|
||||
|
||||
@@ -13,8 +13,16 @@ import { getElementIndex, SELECTABLE_DATA_ATTRIBUTE } from './util';
|
||||
|
||||
const CHROME_REGEX = /Chrome/;
|
||||
|
||||
type GridOpts = ReturnType<typeof useGrid<string, ExplorerItem | undefined>>;
|
||||
|
||||
interface Props extends PropsWithChildren {
|
||||
grid: ReturnType<typeof useGrid<string, ExplorerItem | undefined>>;
|
||||
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<number>();
|
||||
const removedRows = new Set<number>();
|
||||
|
||||
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];
|
||||
|
||||
@@ -52,9 +52,10 @@ export const GridViewItem = memo((props: GridViewItemProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
const InnerDroppable = () => {
|
||||
const InnerDroppable = memo(() => {
|
||||
const item = useGridViewItemContext();
|
||||
const { isDroppable } = useExplorerDroppableContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -63,45 +64,46 @@ const InnerDroppable = () => {
|
||||
(item.selected || isDroppable) && 'bg-app-selectedItem'
|
||||
)}
|
||||
>
|
||||
<ItemFileThumb />
|
||||
<ItemFileThumb {...item} />
|
||||
</div>
|
||||
<ItemMetadata />
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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 (
|
||||
<FileThumb
|
||||
data={item.data}
|
||||
data={props.data}
|
||||
frame={!isLabel}
|
||||
cover={isLabel}
|
||||
blackBars
|
||||
extension
|
||||
className={clsx(
|
||||
isLabel ? [frame.className, '!size-[90%] !rounded-md'] : 'px-2 py-1',
|
||||
item.cut && 'opacity-60'
|
||||
props.cut && 'opacity-60'
|
||||
)}
|
||||
ref={setDraggableRef}
|
||||
childProps={{
|
||||
style,
|
||||
...attributes,
|
||||
...listeners
|
||||
}}
|
||||
childProps={useMemo(
|
||||
() => ({
|
||||
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' && <LabelItemCount data={item.data} />}
|
||||
</ExplorerDraggable>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const ItemTags = () => {
|
||||
const ItemTags = memo(() => {
|
||||
const item = useGridViewItemContext();
|
||||
const object = getItemObject(item.data);
|
||||
const filePath = getItemFilePath(item.data);
|
||||
@@ -150,9 +152,9 @@ const ItemTags = () => {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
function LabelItemCount({ data }: { data: Extract<ExplorerItem, { type: 'Label' }> }) {
|
||||
const LabelItemCount = memo(({ data }: { data: Extract<ExplorerItem, { type: 'Label' }> }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const count = useLibraryQuery([
|
||||
@@ -202,11 +204,11 @@ function LabelItemCount({ data }: { data: Extract<ExplorerItem, { type: 'Label'
|
||||
}
|
||||
]);
|
||||
|
||||
if (count.data === undefined) return;
|
||||
if (count.data === undefined) return null;
|
||||
|
||||
return (
|
||||
<div className="truncate rounded-md px-1.5 py-px text-center text-tiny text-ink-dull">
|
||||
{t('item_with_count', { count: count.data })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<DragSelect grid={grid}>
|
||||
<DragSelect
|
||||
columnCount={grid.columnCount}
|
||||
gapY={grid.gap.y}
|
||||
getItem={grid.getItem}
|
||||
totalColumnCount={grid.totalColumnCount}
|
||||
totalCount={grid.totalCount}
|
||||
totalRowCount={grid.totalRowCount}
|
||||
virtualItemHeight={grid.virtualItemHeight}
|
||||
>
|
||||
<Grid grid={grid}>
|
||||
{(index) => {
|
||||
const item = explorer.items?.[index];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user