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:
Vítor Vasconcellos
2023-04-06 01:15:13 -03:00
committed by GitHub
parent 2c533c7599
commit 3304e8f6ce
9 changed files with 447 additions and 127 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -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"

View File

@@ -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()

View File

@@ -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),
],

View File

@@ -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 {

View File

@@ -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..." />

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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.