mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-24 08:22:10 -04:00
QuickPreview Component (Needs test on MacOS) (#665)
* Add QuickPreview Component - Improve the handling of Range requests - Implement logic to answer HEAD and OPTIONS methods - Handle CORS pre-flight requests - Expand accepted file types - Improve error handling of invalid Range requests * Fix linter errors - Add `use std::cmp::min` to custom_uri (Required on MacOS & Windows) - Improve logic for retrieving file information in QuickPreview.tsx * More linter errors * Simplify `QuickPreview` by extracting the logic for choosing the file preview tag to a `FilePreview` component - Fix the typo in `QuickPreview` props name - Remove the unused `handleMedia` ref - Move the remaining `QuickPreview` logic to the `transitions` callback - Simplify the `cors` return type in `custom_uri.rs` * Refactor range handling in `handle_file` function - Move range handling logic to the initialization of the `range` variable - Replace `if let` with `match` to reduce code duplication - Don't export FilePreview - Export QuickPreviewProps * Fix typo in `RangeNotSatisfiable` error message - Remove redundant variables * Fixing cas_id generation on watcher Some improvements on watcher file creation * Rust fmt --------- Co-authored-by: Ericson Soares <ericson.ds999@gmail.com> Co-authored-by: Jamie Pine <ijamespine@me.com>
This commit is contained in:
committed by
GitHub
parent
2c533c7599
commit
3304e8f6ce
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -21,7 +21,6 @@ percent-encoding = "2.2.0"
|
||||
http = "0.2.8"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
server = { path = "../../server" }
|
||||
axum = { version = "0.6.4", features = ["headers", "query"] }
|
||||
rand = "0.8.5"
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use crate::{location::file_path_helper::MaterializedPath, prisma::file_path, Node};
|
||||
|
||||
use std::{
|
||||
cmp::min,
|
||||
io,
|
||||
mem::take,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use std::cmp::min;
|
||||
|
||||
use http_range::HttpRange;
|
||||
use httpz::{
|
||||
http::{Method, Response, StatusCode},
|
||||
http::{response::Builder, Method, Response, StatusCode},
|
||||
Endpoint, GenericEndpoint, HttpEndpoint, Request,
|
||||
};
|
||||
use mini_moka::sync::Cache;
|
||||
@@ -18,7 +21,7 @@ use once_cell::sync::Lazy;
|
||||
use prisma_client_rust::QueryError;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, File},
|
||||
fs::File,
|
||||
io::{AsyncReadExt, AsyncSeekExt, SeekFrom},
|
||||
};
|
||||
use tracing::error;
|
||||
@@ -44,19 +47,58 @@ async fn handler(node: Arc<Node>, req: Request) -> Result<Response<Vec<u8>>, Han
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match path.first() {
|
||||
Some(&"thumbnail") => handle_thumbnail(&node, &path).await,
|
||||
Some(&"thumbnail") => handle_thumbnail(&node, &path, &req).await,
|
||||
Some(&"file") => handle_file(&node, &path, &req).await,
|
||||
_ => Err(HandleCustomUriError::BadRequest("Invalid operation!")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_file(mut file: File, length: u64, start: Option<u64>) -> io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::with_capacity(length as usize);
|
||||
if let Some(start) = start {
|
||||
file.seek(SeekFrom::Start(start)).await?;
|
||||
file.take(length).read_to_end(&mut buf).await?;
|
||||
} else {
|
||||
file.read_to_end(&mut buf).await?;
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn cors(
|
||||
method: &Method,
|
||||
builder: &mut Builder,
|
||||
) -> Option<Result<Response<Vec<u8>>, httpz::http::Error>> {
|
||||
*builder = take(builder).header("Access-Control-Allow-Origin", "*");
|
||||
if method == Method::OPTIONS {
|
||||
Some(
|
||||
take(builder)
|
||||
.header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "*")
|
||||
.header("Access-Control-Max-Age", "86400")
|
||||
.status(StatusCode::OK)
|
||||
.body(vec![]),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thumbnail(
|
||||
node: &Node,
|
||||
path: &[&str],
|
||||
req: &Request,
|
||||
) -> Result<Response<Vec<u8>>, HandleCustomUriError> {
|
||||
let method = req.method();
|
||||
let mut builder = Response::builder();
|
||||
if let Some(response) = cors(method, &mut builder) {
|
||||
return Ok(response?);
|
||||
}
|
||||
|
||||
let file_cas_id = path
|
||||
.get(1)
|
||||
.ok_or_else(|| HandleCustomUriError::BadRequest("Invalid number of parameters!"))?;
|
||||
|
||||
let filename = node
|
||||
.config
|
||||
.data_directory()
|
||||
@@ -64,7 +106,7 @@ async fn handle_thumbnail(
|
||||
.join(file_cas_id)
|
||||
.with_extension("webp");
|
||||
|
||||
let buf = fs::read(&filename).await.map_err(|err| {
|
||||
let file = File::open(filename).await.map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
HandleCustomUriError::NotFound("file")
|
||||
} else {
|
||||
@@ -72,10 +114,17 @@ async fn handle_thumbnail(
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
let content_lenght = file.metadata().await?.len();
|
||||
|
||||
Ok(builder
|
||||
.header("Content-Type", "image/webp")
|
||||
.header("Content-Length", content_lenght)
|
||||
.status(StatusCode::OK)
|
||||
.body(buf)?)
|
||||
.body(if method == Method::HEAD {
|
||||
vec![]
|
||||
} else {
|
||||
read_file(file, content_lenght, None).await?
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn handle_file(
|
||||
@@ -83,6 +132,12 @@ async fn handle_file(
|
||||
path: &[&str],
|
||||
req: &Request,
|
||||
) -> Result<Response<Vec<u8>>, HandleCustomUriError> {
|
||||
let method = req.method();
|
||||
let mut builder = Response::builder();
|
||||
if let Some(response) = cors(method, &mut builder) {
|
||||
return Ok(response?);
|
||||
}
|
||||
|
||||
let library_id = path
|
||||
.get(1)
|
||||
.and_then(|id| Uuid::from_str(id).ok())
|
||||
@@ -136,7 +191,7 @@ async fn handle_file(
|
||||
lru_entry
|
||||
};
|
||||
|
||||
let mut file = File::open(file_path_materialized_path)
|
||||
let file = File::open(file_path_materialized_path)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
@@ -146,21 +201,63 @@ async fn handle_file(
|
||||
}
|
||||
})?;
|
||||
|
||||
let metadata = file.metadata().await?;
|
||||
|
||||
// TODO: This should be determined from magic bytes when the file is indexed and stored it in the DB on the file path
|
||||
let (mime_type, is_video) = match extension.as_str() {
|
||||
"mp4" => ("video/mp4", true),
|
||||
"webm" => ("video/webm", true),
|
||||
"mkv" => ("video/x-matroska", true),
|
||||
"avi" => ("video/x-msvideo", true),
|
||||
"mov" => ("video/quicktime", true),
|
||||
"png" => ("image/png", false),
|
||||
"jpg" => ("image/jpeg", false),
|
||||
"jpeg" => ("image/jpeg", false),
|
||||
"gif" => ("image/gif", false),
|
||||
"webp" => ("image/webp", false),
|
||||
"svg" => ("image/svg+xml", false),
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
||||
let mime_type = match extension.as_str() {
|
||||
// AAC audio
|
||||
"aac" => "audio/aac",
|
||||
// Musical Instrument Digital Interface (MIDI)
|
||||
"mid" | "midi" => "audio/midi, audio/x-midi",
|
||||
// MP3 audio
|
||||
"mp3" => "audio/mpeg",
|
||||
// MP4 audio
|
||||
"m4a" => "audio/mp4",
|
||||
// OGG audio
|
||||
"oga" => "audio/ogg",
|
||||
// Opus audio
|
||||
"opus" => "audio/opus",
|
||||
// Waveform Audio Format
|
||||
"wav" => "audio/wav",
|
||||
// WEBM audio
|
||||
"weba" => "audio/webm",
|
||||
// AVI: Audio Video Interleave
|
||||
"avi" => "video/x-msvideo",
|
||||
// MP4 video
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
// MPEG Video
|
||||
"mpeg" => "video/mpeg",
|
||||
// OGG video
|
||||
"ogv" => "video/ogg",
|
||||
// MPEG transport stream
|
||||
"ts" => "video/mp2t",
|
||||
// WEBM video
|
||||
"webm" => "video/webm",
|
||||
// 3GPP audio/video container (TODO: audio/3gpp if it doesn't contain video)
|
||||
"3gp" => "video/3gpp",
|
||||
// 3GPP2 audio/video container (TODO: audio/3gpp2 if it doesn't contain video)
|
||||
"3g2" => "video/3gpp2",
|
||||
// Quicktime movies
|
||||
"mov" => "video/quicktime",
|
||||
// AVIF image
|
||||
"avif" => "image/avif",
|
||||
// Windows OS/2 Bitmap Graphics
|
||||
"bmp" => "image/bmp",
|
||||
// Graphics Interchange Format (GIF)
|
||||
"gif" => "image/gif",
|
||||
// Icon format
|
||||
"ico" => "image/vnd.microsoft.icon",
|
||||
// JPEG images
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
// Portable Network Graphics
|
||||
"png" => "image/png",
|
||||
// Scalable Vector Graphics (SVG)
|
||||
"svg" => "image/svg+xml",
|
||||
// Tagged Image File Format (TIFF)
|
||||
"tif" | "tiff" => "image/tiff",
|
||||
// WEBP image
|
||||
"webp" => "image/webp",
|
||||
// PDF document
|
||||
"pdf" => "application/pdf",
|
||||
_ => {
|
||||
return Err(HandleCustomUriError::BadRequest(
|
||||
"TODO: This filetype is not supported because of the missing mime type!",
|
||||
@@ -168,84 +265,93 @@ async fn handle_file(
|
||||
}
|
||||
};
|
||||
|
||||
if is_video {
|
||||
let mut response = Response::builder();
|
||||
let mut status_code = 200;
|
||||
let mut content_lenght = file.metadata().await?.len();
|
||||
// GET is the only method for which range handling is defined, according to the spec
|
||||
// https://httpwg.org/specs/rfc9110.html#field.range
|
||||
let range = if method == Method::GET {
|
||||
if let Some(range) = req.headers().get("range") {
|
||||
range
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|range| HttpRange::parse(range, content_lenght).ok())
|
||||
.ok_or_else(|| {
|
||||
HandleCustomUriError::RangeNotSatisfiable("Error decoding range header!")
|
||||
})
|
||||
.and_then(|range| {
|
||||
// Let's support only 1 range for now
|
||||
if range.len() > 1 {
|
||||
Err(HandleCustomUriError::RangeNotSatisfiable(
|
||||
"Multiple ranges are not supported!",
|
||||
))
|
||||
} else {
|
||||
Ok(range.first().cloned())
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// if the webview sent a range header, we need to send a 206 in return
|
||||
let buf = if let Some(range) = req.headers().get("range") {
|
||||
let mut buf = Vec::new();
|
||||
let file_size = metadata.len();
|
||||
let range = HttpRange::parse(
|
||||
range
|
||||
.to_str()
|
||||
.map_err(|_| HandleCustomUriError::BadRequest("Error passing range header!"))?,
|
||||
file_size,
|
||||
)
|
||||
.map_err(|_| HandleCustomUriError::BadRequest("Error passing range!"))?;
|
||||
// let support only 1 range for now
|
||||
let first_range = range.first();
|
||||
if let Some(range) = first_range {
|
||||
let mut real_length = range.length;
|
||||
let mut status_code = 200;
|
||||
let buf = match range {
|
||||
Some(range) => {
|
||||
let file_size = content_lenght;
|
||||
content_lenght = range.length;
|
||||
|
||||
// prevent max_length;
|
||||
// specially on webview2
|
||||
if range.length > file_size / 3 {
|
||||
// max size sent (400kb / request)
|
||||
// as it's local file system we can afford to read more often
|
||||
real_length = min(file_size - range.start, 1024 * 400);
|
||||
}
|
||||
|
||||
// last byte we are reading, the length of the range include the last byte
|
||||
// who should be skipped on the header
|
||||
let last_byte = range.start + real_length - 1;
|
||||
status_code = 206;
|
||||
|
||||
// Only macOS and Windows are supported, if you set headers in linux they are ignored
|
||||
response = response
|
||||
.header("Connection", "Keep-Alive")
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-Length", real_length)
|
||||
.header(
|
||||
"Content-Range",
|
||||
format!("bytes {}-{}/{}", range.start, last_byte, file_size),
|
||||
);
|
||||
|
||||
// FIXME: Add ETag support (caching on the webview)
|
||||
|
||||
file.seek(SeekFrom::Start(range.start)).await?;
|
||||
file.take(real_length).read_to_end(&mut buf).await?;
|
||||
} else {
|
||||
file.read_to_end(&mut buf).await?;
|
||||
// TODO: For some reason webkit2gtk doesn't like this at all.
|
||||
// It causes it to only stream random pieces of any given audio file.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
// prevent max_length;
|
||||
// specially on webview2
|
||||
if range.length > file_size / 3 {
|
||||
// max size sent (400kb / request)
|
||||
// as it's local file system we can afford to read more often
|
||||
content_lenght = min(file_size - range.start, 1024 * 400);
|
||||
}
|
||||
|
||||
buf
|
||||
} else {
|
||||
// Linux is mega cringe and doesn't support streaming so we just load the whole file into memory and return it
|
||||
let mut buf = Vec::with_capacity(metadata.len() as usize);
|
||||
file.read_to_end(&mut buf).await?;
|
||||
buf
|
||||
};
|
||||
// last byte we are reading, the length of the range include the last byte
|
||||
// who should be skipped on the header
|
||||
let last_byte = range.start + content_lenght - 1;
|
||||
|
||||
Ok(response
|
||||
.header("Content-type", mime_type)
|
||||
.status(status_code)
|
||||
.body(buf)?)
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(metadata.len() as usize);
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok(Response::builder()
|
||||
.header("Content-Type", mime_type)
|
||||
.status(StatusCode::OK)
|
||||
.body(buf)?)
|
||||
}
|
||||
// if the webview sent a range header, we need to send a 206 in return
|
||||
status_code = 206;
|
||||
|
||||
// macOS and Windows supports audio and video, linux only supports audio
|
||||
builder = builder
|
||||
.header("Connection", "Keep-Alive")
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header(
|
||||
"Content-Range",
|
||||
format!("bytes {}-{}/{}", range.start, last_byte, file_size),
|
||||
);
|
||||
|
||||
// FIXME: Add ETag support (caching on the webview)
|
||||
|
||||
read_file(file, content_lenght, Some(range.start)).await?
|
||||
}
|
||||
_ if method == Method::HEAD => vec![],
|
||||
_ => read_file(file, content_lenght, None).await?,
|
||||
};
|
||||
|
||||
Ok(builder
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-type", mime_type)
|
||||
.header("Content-Length", content_lenght)
|
||||
.status(status_code)
|
||||
.body(buf)?)
|
||||
}
|
||||
|
||||
pub fn create_custom_uri_endpoint(node: Arc<Node>) -> Endpoint<impl HttpEndpoint> {
|
||||
GenericEndpoint::new("/*any", [Method::GET, Method::POST], move |req: Request| {
|
||||
let node = node.clone();
|
||||
async move { handler(node, req).await.unwrap_or_else(Into::into) }
|
||||
})
|
||||
GenericEndpoint::new(
|
||||
"/*any",
|
||||
[Method::HEAD, Method::OPTIONS, Method::GET, Method::POST],
|
||||
move |req: Request| {
|
||||
let node = node.clone();
|
||||
async move { handler(node, req).await.unwrap_or_else(Into::into) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -258,6 +364,8 @@ pub enum HandleCustomUriError {
|
||||
QueryError(#[from] QueryError),
|
||||
#[error("{0}")]
|
||||
BadRequest(&'static str),
|
||||
#[error("Range is not valid: {0}")]
|
||||
RangeNotSatisfiable(&'static str),
|
||||
#[error("resource '{0}' not found")]
|
||||
NotFound(&'static str),
|
||||
}
|
||||
@@ -291,6 +399,12 @@ impl From<HandleCustomUriError> for Response<Vec<u8>> {
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(msg.as_bytes().to_vec())
|
||||
}
|
||||
HandleCustomUriError::RangeNotSatisfiable(msg) => {
|
||||
error!("Invalid Range header in request: {}", msg);
|
||||
builder
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.body(msg.as_bytes().to_vec())
|
||||
}
|
||||
HandleCustomUriError::NotFound(resource) => builder.status(StatusCode::NOT_FOUND).body(
|
||||
format!("Resource '{resource}' not found")
|
||||
.as_bytes()
|
||||
|
||||
@@ -317,6 +317,7 @@ impl LastFilePathIdManager {
|
||||
extension,
|
||||
}: MaterializedPath<'_>,
|
||||
parent_id: Option<i32>,
|
||||
cas_id: Option<String>,
|
||||
inode: u64,
|
||||
device: u64,
|
||||
) -> Result<file_path::Data, FilePathError> {
|
||||
@@ -343,6 +344,7 @@ impl LastFilePathIdManager {
|
||||
let next_id = *last_id_ref + 1;
|
||||
|
||||
let params = [
|
||||
("cas_id", json!(cas_id)),
|
||||
("materialized_path", json!(materialized_path)),
|
||||
("name", json!(name)),
|
||||
("extension", json!(extension)),
|
||||
@@ -368,7 +370,7 @@ impl LastFilePathIdManager {
|
||||
|
||||
let created_path = sync
|
||||
.write_op(
|
||||
&db,
|
||||
db,
|
||||
sync.unique_shared_create(
|
||||
sync::file_path::SyncId {
|
||||
location: sync::location::SyncId {
|
||||
@@ -387,6 +389,7 @@ impl LastFilePathIdManager {
|
||||
inode.to_le_bytes().into(),
|
||||
device.to_le_bytes().into(),
|
||||
vec![
|
||||
file_path::cas_id::set(cas_id),
|
||||
file_path::parent_id::set(parent_id),
|
||||
file_path::is_dir::set(is_dir),
|
||||
],
|
||||
|
||||
@@ -45,7 +45,7 @@ use notify::{Event, EventKind};
|
||||
use prisma_client_rust::{raw, PrismaValue};
|
||||
use serde_json::json;
|
||||
use tokio::{fs, io::ErrorKind};
|
||||
use tracing::{error, info, trace, warn};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::INodeAndDevice;
|
||||
@@ -109,9 +109,10 @@ pub(super) async fn create_dir(
|
||||
let created_path = library
|
||||
.last_file_path_id_manager
|
||||
.create_file_path(
|
||||
&library,
|
||||
library,
|
||||
materialized_path,
|
||||
Some(parent_directory.id),
|
||||
None,
|
||||
inode,
|
||||
device,
|
||||
)
|
||||
@@ -167,12 +168,20 @@ pub(super) async fn create_file(
|
||||
return Ok(())
|
||||
};
|
||||
|
||||
// generate provisional object
|
||||
let FileMetadata {
|
||||
cas_id,
|
||||
kind,
|
||||
fs_metadata,
|
||||
} = FileMetadata::new(&location_path, &materialized_path).await?;
|
||||
|
||||
let created_file = library
|
||||
.last_file_path_id_manager
|
||||
.create_file_path(
|
||||
library,
|
||||
materialized_path,
|
||||
Some(parent_directory.id),
|
||||
Some(cas_id.clone()),
|
||||
inode,
|
||||
device,
|
||||
)
|
||||
@@ -180,21 +189,11 @@ pub(super) async fn create_file(
|
||||
|
||||
info!("Created path: {}", created_file.materialized_path);
|
||||
|
||||
// generate provisional object
|
||||
let FileMetadata {
|
||||
cas_id,
|
||||
kind,
|
||||
fs_metadata,
|
||||
} = FileMetadata::new(
|
||||
&location_path,
|
||||
&MaterializedPath::from((location_id, &created_file.materialized_path)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let existing_object = db
|
||||
.object()
|
||||
.find_first(vec![object::file_paths::some(vec![
|
||||
file_path::cas_id::equals(Some(cas_id.clone())),
|
||||
file_path::id::not(created_file.id),
|
||||
])])
|
||||
.select(object_just_id_has_thumbnail::select())
|
||||
.exec()
|
||||
@@ -227,7 +226,12 @@ pub(super) async fn create_file(
|
||||
.await?;
|
||||
|
||||
if !object.has_thumbnail && !created_file.extension.is_empty() {
|
||||
generate_thumbnail(&created_file.extension, &cas_id, path, library).await;
|
||||
// Running in a detached task as thumbnail generation can take a while and we don't want to block the watcher
|
||||
let path = path.to_path_buf();
|
||||
let library = library.clone();
|
||||
tokio::spawn(async move {
|
||||
generate_thumbnail(&created_file.extension, &cas_id, path, &library).await;
|
||||
});
|
||||
}
|
||||
|
||||
invalidate_query!(library, "locations.getExplorerData");
|
||||
@@ -663,6 +667,21 @@ async fn generate_thumbnail(
|
||||
.join(cas_id)
|
||||
.with_extension("webp");
|
||||
|
||||
if let Err(e) = fs::metadata(&output_path).await {
|
||||
if e.kind() != ErrorKind::NotFound {
|
||||
error!(
|
||||
"Failed to check if thumbnail exists, but we will try to generate it anyway: {e}"
|
||||
);
|
||||
}
|
||||
// Otherwise we good, thumbnail doesn't exist so we can generate it
|
||||
} else {
|
||||
debug!(
|
||||
"Skipping thumbnail generation for {} because it already exists",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(extension) = ImageExtension::from_str(extension) {
|
||||
if can_generate_thumbnail_for_image(&extension) {
|
||||
if let Err(e) = generate_image_thumbnail(path, &output_path).await {
|
||||
|
||||
@@ -13,18 +13,11 @@ import {
|
||||
TrashSimple
|
||||
} from 'phosphor-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import {
|
||||
ExplorerItem,
|
||||
isObject,
|
||||
useLibraryContext,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { ExplorerItem, isObject, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { ContextMenu, dialogManager } from '@sd/ui';
|
||||
import { useExplorerParams } from '~/app/$libraryId/location/$id';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import AssignTagMenuItems from '../AssignTagMenuItems';
|
||||
import { OpenInNativeExplorer } from '../ContextMenu';
|
||||
import DecryptDialog from './DecryptDialog';
|
||||
@@ -37,10 +30,8 @@ interface Props extends PropsWithChildren {
|
||||
}
|
||||
|
||||
export default ({ data, ...props }: Props) => {
|
||||
const { library } = useLibraryContext();
|
||||
const store = useExplorerStore();
|
||||
const params = useExplorerParams();
|
||||
const platform = usePlatform();
|
||||
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
|
||||
|
||||
const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
|
||||
@@ -55,14 +46,7 @@ export default ({ data, ...props }: Props) => {
|
||||
<ContextMenu.Item
|
||||
label="Open"
|
||||
keybind="⌘O"
|
||||
onClick={() => {
|
||||
// TODO: Replace this with a proper UI
|
||||
window.location.href = platform.getFileUrl(
|
||||
library.uuid,
|
||||
store.locationId!,
|
||||
data.item.id
|
||||
);
|
||||
}}
|
||||
onClick={() => (getExplorerStore().quickViewObject = data)}
|
||||
icon={Copy}
|
||||
/>
|
||||
<ContextMenu.Item label="Open with..." />
|
||||
|
||||
197
interface/app/$libraryId/Explorer/QuickPreview.tsx
Normal file
197
interface/app/$libraryId/Explorer/QuickPreview.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import clsx from 'clsx';
|
||||
import { XCircle } from 'phosphor-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTransition } from 'react-spring';
|
||||
import { animated } from 'react-spring';
|
||||
import { subscribeKey } from 'valtio/utils';
|
||||
import { ExplorerItem } from '~/../packages/client/src';
|
||||
import { showAlertDialog } from '~/components/AlertDialog';
|
||||
import { getExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import FileThumb from './File/Thumb';
|
||||
import { getExplorerItemData } from './util';
|
||||
|
||||
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
||||
const AnimatedDialogContent = animated(Dialog.Content);
|
||||
|
||||
export interface QuickPreviewProps extends Dialog.DialogProps {
|
||||
libraryUuid: string;
|
||||
transformOrigin?: string;
|
||||
}
|
||||
interface FilePreviewProps {
|
||||
src: string;
|
||||
kind: null | string;
|
||||
onError: () => void;
|
||||
explorerItem: ExplorerItem;
|
||||
}
|
||||
|
||||
function FilePreview({ explorerItem, kind, src, onError }: FilePreviewProps) {
|
||||
const className = clsx('relative inset-y-2/4 max-h-full max-w-full translate-y-[-50%]');
|
||||
const fileThumb = <FileThumb size={1} data={explorerItem} className={className} />;
|
||||
switch (kind) {
|
||||
case 'PDF':
|
||||
return <object data={src} type="application/pdf" className="h-full w-full border-0" />;
|
||||
case 'Image':
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="File preview"
|
||||
onError={onError}
|
||||
className={className}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
);
|
||||
case 'Audio':
|
||||
return (
|
||||
<>
|
||||
{fileThumb}
|
||||
<audio
|
||||
src={src}
|
||||
onError={onError}
|
||||
controls
|
||||
autoPlay
|
||||
className="absolute left-2/4 top-full w-full translate-y-[-150%] -translate-x-1/2"
|
||||
crossOrigin="anonymous"
|
||||
>
|
||||
<p>Audio preview is not supported.</p>
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
case 'Video':
|
||||
return (
|
||||
<video
|
||||
src={src}
|
||||
onError={onError}
|
||||
controls
|
||||
autoPlay
|
||||
className={className}
|
||||
crossOrigin="anonymous"
|
||||
playsInline
|
||||
>
|
||||
<p>Video preview is not supported.</p>
|
||||
</video>
|
||||
);
|
||||
default:
|
||||
return fileThumb;
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps) {
|
||||
const platform = usePlatform();
|
||||
const explorerItem = useRef<null | ExplorerItem>(null);
|
||||
const explorerStore = getExplorerStore();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* The useEffect hook with subscribe is used here, instead of useExplorerStore, because when
|
||||
* explorerStore.quickViewObject is set to null the component will not close immediately.
|
||||
* Instead, it will enter the beginning of the close transition and it must continue to display
|
||||
* content for a few more seconds due to the ongoing animation. To handle this, the open state
|
||||
* is decoupled from the store state, by assinging references to the required store properties
|
||||
* to render the component in the subscribe callback.
|
||||
*/
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeKey(explorerStore, 'quickViewObject', () => {
|
||||
const { quickViewObject } = explorerStore;
|
||||
if (quickViewObject != null) {
|
||||
setIsOpen(true);
|
||||
explorerItem.current = quickViewObject;
|
||||
}
|
||||
}),
|
||||
[explorerStore]
|
||||
);
|
||||
|
||||
const onPreviewError = () => {
|
||||
setIsOpen(false);
|
||||
explorerStore.quickViewObject = null;
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: 'Could not load file preview.'
|
||||
});
|
||||
};
|
||||
|
||||
const transitions = useTransition(isOpen, {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: `translateY(20px)`,
|
||||
transformOrigin: transformOrigin || 'bottom'
|
||||
},
|
||||
enter: { opacity: 1, transform: `translateY(0px)` },
|
||||
leave: { opacity: 0, transform: `translateY(20px)` },
|
||||
config: { mass: 0.4, tension: 200, friction: 10, bounce: 0 }
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) explorerStore.quickViewObject = null;
|
||||
}}
|
||||
>
|
||||
{transitions((styles, show) => {
|
||||
if (!show || explorerItem.current == null) return null;
|
||||
|
||||
const { item } = explorerItem.current;
|
||||
const locationId = 'location_id' in item ? item.location_id : explorerStore.locationId;
|
||||
if (locationId == null) {
|
||||
onPreviewError();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { kind, extension } = getExplorerItemData(explorerItem.current);
|
||||
const preview = (
|
||||
<FilePreview
|
||||
src={platform.getFileUrl(libraryUuid, locationId, item.id)}
|
||||
kind={extension === 'pdf' && navigator.pdfViewerEnabled ? 'PDF' : kind}
|
||||
onError={onPreviewError}
|
||||
explorerItem={explorerItem.current}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatedDialogOverlay
|
||||
style={{
|
||||
opacity: styles.opacity
|
||||
}}
|
||||
className="z-49 bg-app/50 absolute inset-0 m-[1px] grid place-items-center overflow-y-auto rounded-xl"
|
||||
forceMount
|
||||
/>
|
||||
<AnimatedDialogContent
|
||||
style={styles}
|
||||
className="!pointer-events-none absolute inset-0 z-50 grid place-items-center"
|
||||
forceMount
|
||||
>
|
||||
<div className="border-app-line bg-app-box text-ink shadow-app-shade !pointer-events-auto h-5/6 w-11/12 rounded-md border">
|
||||
<nav className="flex w-full flex-row">
|
||||
<Dialog.Close className="text-ink-dull ml-2" aria-label="Close">
|
||||
<XCircle size={16} />
|
||||
</Dialog.Close>
|
||||
<Dialog.Title className="mx-auto my-1 font-bold">
|
||||
Preview -{' '}
|
||||
<span className="text-ink-dull inline-block max-w-xs truncate align-sub text-sm">
|
||||
{'name' in item && item.name ? item.name : 'Unkown Object'}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
</nav>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative m-auto h-[calc(100%-2rem)] overflow-hidden',
|
||||
preview.props.kind === 'PDF' || 'w-fit'
|
||||
)}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedDialogContent>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ExplorerData, rspc, useLibraryContext } from '@sd/client';
|
||||
import { useExplorerStore } from '~/hooks/useExplorerStore';
|
||||
import { Inspector } from '../Explorer/Inspector';
|
||||
import ExplorerContextMenu from './ContextMenu';
|
||||
import { QuickPreview } from './QuickPreview';
|
||||
import TopBar from './TopBar';
|
||||
import { VirtualizedList } from './VirtualizedList';
|
||||
|
||||
@@ -76,6 +77,7 @@ export default function Explorer(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</ExplorerContextMenu>
|
||||
<QuickPreview libraryUuid={library!.uuid} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { resetStore } from '@sd/client/src/stores/util';
|
||||
|
||||
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
|
||||
@@ -29,7 +30,8 @@ const state = {
|
||||
sourcePathId: 0,
|
||||
actionType: 'Cut',
|
||||
active: false
|
||||
}
|
||||
},
|
||||
quickViewObject: null as ExplorerItem | null
|
||||
};
|
||||
|
||||
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
|
||||
|
||||
Reference in New Issue
Block a user