mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-04 13:26:00 -04:00
Merge remote-tracking branch 'origin/main' into 0.5.0-dev
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
@@ -22,31 +22,31 @@ rust-version = "1.81"
|
||||
# Third party dependencies used by one or more of our crates
|
||||
async-channel = "2.3"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.80"
|
||||
axum = "0.6.20" # Update blocked by hyper
|
||||
async-trait = "0.1.83"
|
||||
axum = "0.7.7"
|
||||
axum-extra = "0.9.4"
|
||||
base64 = "0.22.1"
|
||||
blake3 = "1.5"
|
||||
chrono = "0.4.38"
|
||||
ed25519-dalek = "2.1"
|
||||
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.24.9" # Update blocked due to https://github.com/image-rs/image/issues/2230
|
||||
itertools = "0.13.0"
|
||||
lending-stream = "1.0"
|
||||
libc = "0.2"
|
||||
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
|
||||
regex = "1"
|
||||
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"
|
||||
@@ -60,45 +60,47 @@ 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.2.6" # Update blocked by image
|
||||
|
||||
[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,15 +12,15 @@
|
||||
"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",
|
||||
"@tanstack/react-query": "^5.59",
|
||||
"@tauri-apps/api": "=2.0.2",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0",
|
||||
"@tauri-apps/plugin-os": "2.0.0",
|
||||
"@tauri-apps/plugin-shell": "2.0.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 }
|
||||
@@ -36,9 +37,9 @@ uuid = { workspace = true, features = ["serde"] }
|
||||
# WARNING: Do NOT enable default features, as that vendors dbus (see below)
|
||||
opener = { version = "0.7.1", features = ["reveal"], default-features = false }
|
||||
specta-typescript = "=0.0.7"
|
||||
tauri-plugin-dialog = "=2.0.1"
|
||||
tauri-plugin-dialog = "=2.0.2"
|
||||
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
|
||||
@@ -46,12 +47,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
|
||||
@@ -73,7 +74,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 = [
|
||||
|
||||
@@ -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,8 +21,8 @@
|
||||
"@dr.pogodin/react-native-fs": "^2.24.1",
|
||||
"@gorhom/bottom-sheet": "^4.6.1",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@oscartbeaumont-sd/rspc-client": "github:spacedriveapp/rspc#path:packages/client&bc882f4724",
|
||||
"@oscartbeaumont-sd/rspc-react": "github:spacedriveapp/rspc#path:packages/react&bc882f4724",
|
||||
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
|
||||
"@spacedrive/rspc-react": "github:spacedriveapp/rspc#path:packages/react&6a77167495",
|
||||
"@react-native-async-storage/async-storage": "~1.23.1",
|
||||
"@react-native-masked-view/masked-view": "^0.3.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.19",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@sd/assets": "workspace:*",
|
||||
"@sd/client": "workspace:*",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query": "^5.59",
|
||||
"babel-preset-solid": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"dayjs": "^1.11.10",
|
||||
@@ -74,8 +74,8 @@
|
||||
"twrnc": "^4.1.0",
|
||||
"use-count-up": "^3.0.1",
|
||||
"use-debounce": "^9.0.4",
|
||||
"valtio": "^1.11.2",
|
||||
"zod": "~3.22.4"
|
||||
"valtio": "^2.0",
|
||||
"zod": "^3.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -100,7 +100,7 @@ const CloudLibraryCard = ({ data, modalRef, navigation }: Props) => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={joinLibrary.isLoading}
|
||||
disabled={joinLibrary.isPending}
|
||||
onPress={async () => {
|
||||
const library = await joinLibrary.mutateAsync(data.uuid);
|
||||
|
||||
@@ -128,7 +128,7 @@ const CloudLibraryCard = ({ data, 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'}
|
||||
</Text>
|
||||
|
||||
@@ -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';
|
||||
@@ -16,7 +16,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;
|
||||
|
||||
@@ -111,10 +111,10 @@ const Authenticated = () => {
|
||||
<Button
|
||||
variant={'accent'}
|
||||
style={tw`mx-auto mt-4 max-w-[82%]`}
|
||||
disabled={createLibrary.isLoading}
|
||||
disabled={createLibrary.isPending}
|
||||
onPress={async () => await createLibrary.mutateAsync(null)}
|
||||
>
|
||||
{createLibrary.isLoading ? (
|
||||
{createLibrary.isPending ? (
|
||||
<Text style={tw`text-ink`}>Connecting library...</Text>
|
||||
) : (
|
||||
<Text style={tw`font-medium text-ink`}>Connect library</Text>
|
||||
|
||||
@@ -40,7 +40,7 @@ const Library = ({ cloudLibrary }: LibraryProps) => {
|
||||
<Text style={tw`text-ink`}>{cloudLibrary?.name}</Text>
|
||||
</InfoBox>
|
||||
<Button
|
||||
disabled={syncLibrary.isLoading || thisInstance !== undefined}
|
||||
disabled={syncLibrary.isPending || thisInstance !== undefined}
|
||||
variant="gray"
|
||||
onPress={() => syncLibrary.mutate(null)}
|
||||
style={tw`mt-2 flex-row gap-1 py-2`}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { inferSubscriptionResult } from '@oscartbeaumont-sd/rspc-client';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { inferSubscriptionResult } from '@spacedrive/rspc-client';
|
||||
import { MotiView } from 'moti';
|
||||
import { Circle } from 'phosphor-react-native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
@@ -131,11 +131,11 @@ function StartButton({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
disabled={startActor.isLoading}
|
||||
disabled={startActor.isPending}
|
||||
onPress={() => startActor.mutate(name)}
|
||||
>
|
||||
<Text style={tw`text-xs font-medium text-ink`}>
|
||||
{startActor.isLoading ? 'Starting' : 'Start'}
|
||||
{startActor.isPending ? 'Starting' : 'Start'}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
@@ -147,11 +147,11 @@ function StopButton({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
disabled={stopActor.isLoading}
|
||||
disabled={stopActor.isPending}
|
||||
onPress={() => stopActor.mutate(name)}
|
||||
>
|
||||
<Text style={tw`text-xs font-medium text-ink`}>
|
||||
{stopActor.isLoading ? 'Stopping' : 'Stop'}
|
||||
{stopActor.isPending ? 'Stopping' : 'Stop'}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -175,10 +174,7 @@ async fn main() {
|
||||
.route(
|
||||
"/",
|
||||
get(|| async move {
|
||||
use axum::{
|
||||
body::{self, Full},
|
||||
response::Response,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{header, HeaderValue, StatusCode};
|
||||
|
||||
match ASSETS_DIR.get_file("index.html") {
|
||||
@@ -188,11 +184,11 @@ async fn main() {
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str("text/html").unwrap(),
|
||||
)
|
||||
.body(body::boxed(Full::from(file.contents())))
|
||||
.body(Body::from(file.contents()))
|
||||
.unwrap(),
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(body::boxed(axum::body::Empty::new()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
}
|
||||
}),
|
||||
@@ -201,10 +197,7 @@ async fn main() {
|
||||
"/*id",
|
||||
get(
|
||||
|axum::extract::Path(path): axum::extract::Path<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('/');
|
||||
@@ -218,7 +211,7 @@ async fn main() {
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.body(body::boxed(Full::from(file.contents())))
|
||||
.body(Body::from(file.contents()))
|
||||
.unwrap(),
|
||||
None => match ASSETS_DIR.get_file("index.html") {
|
||||
Some(file) => Response::builder()
|
||||
@@ -227,11 +220,11 @@ async fn main() {
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str("text/html").unwrap(),
|
||||
)
|
||||
.body(body::boxed(Full::from(file.contents())))
|
||||
.body(Body::from(file.contents()))
|
||||
.unwrap(),
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(body::boxed(Empty::new()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
},
|
||||
}
|
||||
@@ -254,8 +247,7 @@ async fn main() {
|
||||
let mut addr = "[::]:8080".parse::<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 = [
|
||||
|
||||
@@ -64,7 +64,7 @@ reqwest = { workspace = true, features = ["json", "native-tls-vendor
|
||||
rmp-serde = { workspace = true }
|
||||
rmpv = { workspace = true }
|
||||
rspc = { workspace = true, features = ["alpha", "axum", "chrono", "unstable", "uuid"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde = { workspace = true, features = ["derive", "rc"] }
|
||||
serde_json = { workspace = true }
|
||||
specta = { workspace = true }
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
@@ -85,15 +85,16 @@ ctor = "0.2.8"
|
||||
directories = "5.0"
|
||||
flate2 = "1.0"
|
||||
hostname = "0.4.0"
|
||||
http-body = "0.4.6" # Update blocked by http
|
||||
http-body = "1.0"
|
||||
http-range = "0.1.5"
|
||||
int-enum = "0.5" # Update blocked due to API breaking changes
|
||||
hyper-util = { version = "0.1.9", features = ["tokio"] }
|
||||
int-enum = "0.5" # Update blocked due to API breaking changes
|
||||
mini-moka = "0.10.3"
|
||||
serde-hashkey = "0.4.5"
|
||||
serde_repr = "0.1.19"
|
||||
serde_with = "3.8"
|
||||
slotmap = "1.0"
|
||||
sysinfo = "0.29.11" # Update blocked due to API breaking changes
|
||||
sysinfo = "0.29.11" # Update blocked due to API breaking changes
|
||||
tar = "0.4.41"
|
||||
tower-service = "0.3.2"
|
||||
tracing-appender = "0.2.3"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,11 +31,11 @@ uuid = { workspace = true, features = ["serde"] }
|
||||
|
||||
# Specific P2P dependencies
|
||||
dns-lookup = "2.0"
|
||||
flume = "=0.11.0" # Must match version used by `mdns-sd`
|
||||
flume = "=0.11.1" # Must match version used by `mdns-sd`
|
||||
hash_map_diff = "0.2.0"
|
||||
if-watch = { version = "=3.2.0", features = ["tokio"] } # Override features used by libp2p-quic
|
||||
libp2p-stream = "=0.1.0-alpha" # Update blocked due to custom patch
|
||||
mdns-sd = "0.11.1"
|
||||
libp2p-stream = "=0.2.0-alpha" # Update blocked due to custom patch
|
||||
mdns-sd = "0.11.5"
|
||||
rand_core = "0.6.4"
|
||||
stable-vec = "0.4.1"
|
||||
sync_wrapper = "1.0"
|
||||
@@ -43,7 +43,7 @@ zeroize = { version = "1.8", features = ["derive"] }
|
||||
|
||||
[dependencies.libp2p]
|
||||
features = ["autonat", "dcutr", "macros", "noise", "quic", "relay", "serde", "tokio", "yamux"]
|
||||
version = "=0.53.2" # Update blocked due to custom patch
|
||||
version = "=0.54.1" # Update blocked due to custom patch
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -3,3 +3,23 @@ index: 100
|
||||
---
|
||||
|
||||
# Command Palette
|
||||
|
||||
The Command Palette is a powerful tool designed to streamline navigation and provide quick access to key features in Spacedrive. It allows you to search, navigate, and execute commands without needing to leave your current workflow.
|
||||
|
||||
## How to Use the Command Palette
|
||||
|
||||
- **Open the Command Palette:**
|
||||
Press `CMD + K` (on macOS) or `CTRL + K` (on Windows/Linux) to instantly bring up the command palette.
|
||||
|
||||
- **Search Functionality:**
|
||||
You can search for specific files, folders, or any other items in your library. This includes documents, media, and metadata, allowing for fast and efficient access to your entire library.
|
||||
|
||||
- **Navigate Through Your Library:**
|
||||
Easily move between different sections of your workspace by using the palette to jump to various parts of your project, such as folders, files, or even specific locations within those files.
|
||||
|
||||
- **Adding Locations or Creating Tags:**
|
||||
The Command Palette allows you to quickly add new storage locations or create custom tags for better organization of your content. This feature enables easy tagging and sorting, making your files easier to find and categorize.
|
||||
|
||||
---
|
||||
|
||||
The Command Palette is designed to enhance your workflow by making it easier to access, organize, and navigate your files and content. Keep an eye out for future updates, as we continue to enhance the Command Palette with new features and improved functionality such as an AI assistant to make your workflow even more seamless.
|
||||
|
||||
@@ -9,3 +9,49 @@ index: 100
|
||||
title="WIP"
|
||||
text="This feature is not available yet, please check our [roadmap](/roadmap)."
|
||||
/>
|
||||
|
||||
Spacedrive makes it easy to manage your media files with powerful built-in conversion capabilities. Whether you need to change file formats, compress media for efficient storage, or ensure compatibility across different devices, Spacedrive's Media Conversion tools are here to help.
|
||||
|
||||
## Key Features of Media Conversion
|
||||
|
||||
- **Effortless Format Conversion:**
|
||||
Spacedrive allows you to convert media files, including images, videos, and audio, to a variety of popular formats. This helps ensure your files are accessible and usable across different devices and platforms.
|
||||
|
||||
- **Supported File Types:**
|
||||
You can convert between a wide range of file types, such as:
|
||||
|
||||
- Image formats: JPEG, PNG, SVG, WEBP, GIF, and more.
|
||||
- Video formats: MP4, AVI, MOV, MKV, and others.
|
||||
- Audio formats: MP3, WAV, AAC, FLAC, and more.
|
||||
|
||||
- **Batch Processing:**
|
||||
Convert multiple media files at once with batch processing, saving you time when working with large libraries of media. Simply select the files you need to convert, choose your output format, and let Spacedrive handle the rest.
|
||||
|
||||
- **Compression for Optimal Storage:**
|
||||
Media files can take up significant storage space, especially high-resolution images and videos. Spacedrive offers options for compressing files during conversion to reduce file size without sacrificing quality. This is particularly useful when managing decentralized storage.
|
||||
|
||||
- **Maintaining Metadata Integrity:**
|
||||
During the conversion process, Spacedrive preserves important file metadata such as creation date, tags, and descriptions. This ensures that your organizational structure and file information remain intact even after conversion.
|
||||
|
||||
- **Seamless Integration with Decentralized Storage:**
|
||||
Media conversion in Spacedrive is designed to work seamlessly within the decentralized file management environment. Whether your files are stored locally or across distributed storage nodes, you can easily convert and manage your media without limitations.
|
||||
|
||||
## How to Convert Media Files in Spacedrive
|
||||
|
||||
1. **Locate Your Media Files:**
|
||||
Use the search or navigation features to find the media files you want to convert in your Spacedrive library.
|
||||
|
||||
2. **Open the Media Conversion Tool:**
|
||||
Right-click on the file(s) and select "Convert" from the context menu, or use the Command Palette (`Command + K` / `Ctrl + K`) to search for the conversion command.
|
||||
|
||||
3. **Choose Your Target Format:**
|
||||
Select the desired output format for your file. Options will vary depending on whether you're converting images, videos, or audio files.
|
||||
|
||||
4. **Optional Settings:**
|
||||
Adjust additional options such as compression levels or resolution for video files. You can also choose whether to retain metadata or strip it for privacy.
|
||||
|
||||
5. **Start the Conversion:**
|
||||
Click "Convert" to begin the process. You can monitor the conversion progress in the job manager, and batch conversions will queue for processing.
|
||||
|
||||
6. **Access Converted Files:**
|
||||
Once the conversion is complete, the new file will be available in your library alongside the original, unless you choose to replace it.
|
||||
|
||||
@@ -3,3 +3,23 @@ index: 100
|
||||
---
|
||||
|
||||
# Media View
|
||||
|
||||
The Media View feature in Spacedrive provides a streamlined way to browse and interact with your media files. It offers a visually engaging experience, allowing you to quickly view thumbnails, preview media, and navigate through your collection with ease.
|
||||
|
||||
## How to Open Media View
|
||||
|
||||
To open the Media View, simply navigate to any folder in Spacedrive that contains media files. This can include images, videos, or audio files.
|
||||
|
||||
## Key Features of Media View
|
||||
|
||||
- **Automatic Thumbnails:**
|
||||
Spacedrive automatically generates thumbnails for all your media files, offering a quick and clear visual reference. Thumbnails make it easier to locate specific files, especially when browsing large folders.
|
||||
|
||||
- **Seamless Media Browsing:**
|
||||
Easily view all your media files at a glance. The Media View displays your files in a grid, providing an organized overview of images, videos, and audio tracks. You can scroll through the folder to browse and quickly identify the media you're looking for.
|
||||
|
||||
- **Quick Previews:**
|
||||
Press `Spacebar` on any media file will open the quick preview menu, allowing you to instantly view or play the file without leaving the current folder or opening a separate application.
|
||||
|
||||
- **Zoom and Layout Options:**
|
||||
Press the filter button on the top right to customize how your media files are displayed, including item size, double click action, advanced explorer options, and sort rules.
|
||||
|
||||
@@ -6,8 +6,8 @@ index: 10
|
||||
|
||||
<Video url="/videos/Spacedrive_QuickPreview.webm" />
|
||||
|
||||
Quick Preview allows you to quickly preview a file in Spacedrive, it supports a wide and growing range of formats.
|
||||
Quick Preview allows you to "quickly preview" a file in Spacedrive. It supports a wide and growing range of formats, providing an easy way to explore your files and data.
|
||||
|
||||
It can be accessed by pressing the spacebar while selected on a file, as per native behaviour for macOS.
|
||||
|
||||
Alternatively you can access it from the context menu.
|
||||
Alternatively, you can access it from the context menu.
|
||||
|
||||
@@ -3,3 +3,28 @@ index: 100
|
||||
---
|
||||
|
||||
# Search
|
||||
|
||||
The **Search** feature in Spacedrive makes it easy to find files across your entire library. Whether you're looking for a specific file by name or using advanced filters, Search is designed to help you locate your files quickly and efficiently.
|
||||
|
||||
## How to Use Search
|
||||
|
||||
- **Shortcut Key**: Press `CMD + F` (or `CTRL + F` on Windows/Linux) to access the search bar in Locations where search is available.
|
||||
- **Access Through Explorer**: You also can use Search by typing in the top bar text area in the Explorer or Overview.
|
||||
|
||||
## Search by File Name
|
||||
|
||||
Search works primarily by matching file names. Just start typing in the search bar, and results will appear as you type.
|
||||
|
||||
## Apply Filters
|
||||
|
||||
To refine your search, click the **Add Filter** button. Available filters include:
|
||||
|
||||
- **Location**: Search within specific folders or drives.
|
||||
- **Tags**: Narrow down results by tags you've applied to files.
|
||||
- **Kind**: Filter by file type (e.g., document, image, video).
|
||||
- **Name**: Search by exact or partial file name.
|
||||
- **Extension**: Filter by file extension (e.g., `.jpg`, `.pdf`).
|
||||
- **Hidden**: Show or hide hidden files in search results.
|
||||
- **Favourite**: Only display files marked as favourites.
|
||||
|
||||
Using multiple filters together allows you to fine-tune your search and quickly locate even the most specific files.
|
||||
|
||||
@@ -3,3 +3,15 @@ index: 100
|
||||
---
|
||||
|
||||
# Spacedrop
|
||||
|
||||
Spacedrop is a file-sharing feature designed for Spacedrive users. It allows you to instantly send files and folders to others within your network, directly from your Spacedrive interface. Spacedrop ensures fast, secure transfers without the need for cloud uploads or email attachments.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Instant Sharing**: Transfer files directly to nearby Spacedrive users connected on the same network quickly without having to waiting for uploads or email attachments
|
||||
- **All File Types**: Share any selected file/folder on your library
|
||||
- **Privacy**: Only the recipient can access the shared file, ensuring secure transfers.
|
||||
|
||||
## How to Use Spacedrop
|
||||
|
||||
Simply press the planet icon in the top right of the Explorer to access Spacedrop. Select a node and the file(s) you want to Spacedrop and share your files instantly between devices.
|
||||
|
||||
@@ -10,4 +10,48 @@ index: 100
|
||||
text="This feature is not available yet, please check our [roadmap](/roadmap)."
|
||||
/>
|
||||
|
||||
Spaces are virtual folders, they can contain files from various locations.
|
||||
**Spaces** are virtual folders designed to foster collaboration and organization within Spacedrive. They allow you to share, curate, and customize a collection of files from various locations, making it easy to collaborate with others or manage your content just the way you want.
|
||||
|
||||
### What are Spaces?
|
||||
|
||||
Spaces are virtual folders designed to give you control over how you organize, share, and collaborate with others. Unlike traditional folders, a Space can contain files from various locations within your library. Whether it's documents, media files, or entire folders, you can curate your Space the way you want, without moving or duplicating your content.
|
||||
|
||||
### Build Your Space, Your Way
|
||||
|
||||
Creating a Space is simple, but what you can do with it is powerful:
|
||||
|
||||
- **Pin Key Folders**: Start by pinning important folders to the top of your Space for easy access. These could be work files, personal projects, or shared media collections—whatever matters most to you.
|
||||
- **Curate Content**: Populate your Space with files from any part of your Spacedrive library. You can mix and match files, dragging them in from different storage locations, or organizing them however you prefer.
|
||||
|
||||
### Sharing and Collaboration Made Easy
|
||||
|
||||
A big focus of Spaces is **community** and **collaboration**. Whether you’re sharing your Space with teammates, friends, or collaborators, you can easily control access:
|
||||
|
||||
- **Invite Collaborators**: With just a few clicks, you can share your Space with others, giving them the ability to view, edit, or add their own content.
|
||||
- **Set Permissions**: Need to control who can edit what? You have full control over permissions, allowing you to manage who can make changes and who can simply view the files.
|
||||
|
||||
### A Space for Every Purpose
|
||||
|
||||
Spaces are incredibly versatile. Here are just a few ways you can use them:
|
||||
|
||||
- **Team Projects**: Create a collaborative Space for team members to contribute files, share ideas, and work together. Pin important folders or files for quick reference.
|
||||
- **Personal Organization**: Use Spaces to organize your personal files into meaningful categories, like photography projects, design inspiration, or family photos.
|
||||
- **Community Resource Sharing**: Spaces make it easy to create a shared resource hub where others can access important documents, media, or references.
|
||||
|
||||
### Stay Organized with Flexibility
|
||||
|
||||
Because Spaces pull from various parts of your library, you don’t need to move files around to keep things organized. Simply pin what you need, and your files will be available within the Space, ready for you and your collaborators to access.
|
||||
|
||||
Want to add more structure? Use drag-and-drop functionality to arrange files or folders within the Space. You’re in full control of how everything is displayed, giving you the freedom to build a Space that matches your workflow.
|
||||
|
||||
### Privacy and Security
|
||||
|
||||
Sharing doesn’t mean compromising on security. With Spaces, you can decide who has access to your content. You can:
|
||||
|
||||
- Make a Space private, accessible only to you.
|
||||
- Share it with specific individuals or groups.
|
||||
- Set read-only or full-edit permissions, ensuring that your files are protected while still enabling collaboration.
|
||||
|
||||
---
|
||||
|
||||
**Spaces** are built to grow and evolve with you. Whether you're organizing personal files, collaborating with a team, or building a community around shared resources, Spaces give you the tools to do it your way. Jump in, create your first Space, and start collaborating today!
|
||||
|
||||
@@ -17,7 +17,7 @@ Tags can be added from the context menu - right click a file and select "Assign
|
||||
|
||||
Alternatively, the [Inspector]() which can be accessed from both the Explorer and Quick Preview has the ability to view and manage tags.
|
||||
|
||||
We are working on a "tag assign mode" which will show relevent tags on a hotbar at the bottom of the Explorer. Each tag will be assigned a number, selecting files and pressing the number will assign the given tag to all selected files.
|
||||
To enter tag assign mode, press the Tag icon in the Explorer and assign number keys to each of your tags. Press the corresponding number key to assign tags to files in the Explorer with ease.
|
||||
|
||||
### Managing tags
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user