mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-06 23:43:32 +00:00
[ENG-528] QuickPreview isn't correctly handling errors for video/audio playback (#815)
* Centralize the file preview logic in `Thumb.tsx` * Fix useEffect * Fix Inspector thumb keeping internal state from previous selected files - Change video border to follow video aspect-ratio, just like Finder * Restore memo to Thumb - Only add borders to video thumb, not the video itself * Simplify and improve Thumb logic - Add A internal Thumbnail component - Rename main component to FileThumb to match mobile naming - Move getIcon utility function to assets/icons * Add new `useCallbackToWatchResize` hook - Replace `ResizeObserver` with new resize hook in `Thumb` - Simplify and improve `useIsDark` hook by replacing `react-responsive` with direct usage of WebAPI `matchMedia` - Fix `Thumb` src not updating correctly - Fix video extension incorrectly showing when size <= 80 in `Thumb` * Fix `Inspector` not updating thumb type - Remove superfluous `newThumb` from `getExplorerItemData` * Remove superfluous `ThumSize` * Forgot a `?` * Fix incorrect className check in `Thumb` - Updated changed files to use the hooks root import * Format * Fix PDF preview compleatly breaking the application - Allow Linux to access both the spacedrive:// custom protocol and the workaround webserver - On Linux only use the webserver for audio and video, which don't work when requested through a custom protocol - Configure tauri IPC to allow API access to the spacedrive://localhost domain, to avoid PDF previews from breaking the security scope and rendering the application unusable - Configure CSP to allow the pdf plugin custom protocol used by webkit - Fix race condition between Thumb error handler and thumbType useEffect, by using replacing it with a useLayoutEffect - Improve Thumb's error handling
This commit is contained in:
parent
fb2a4b0137
commit
30e7c9d709
|
@ -7,9 +7,8 @@ use axum::{
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::Response,
|
response::Response,
|
||||||
routing::get,
|
routing::get,
|
||||||
RequestPartsExt,
|
RequestPartsExt, Router,
|
||||||
};
|
};
|
||||||
use httpz::{Endpoint, HttpEndpoint};
|
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tauri::{async_runtime::Receiver, plugin::TauriPlugin, Builder, Runtime};
|
use tauri::{async_runtime::Receiver, plugin::TauriPlugin, Builder, Runtime};
|
||||||
|
@ -18,7 +17,7 @@ use tracing::debug;
|
||||||
pub(super) async fn setup<R: Runtime>(
|
pub(super) async fn setup<R: Runtime>(
|
||||||
app: Builder<R>,
|
app: Builder<R>,
|
||||||
mut rx: Receiver<()>,
|
mut rx: Receiver<()>,
|
||||||
endpoint: Endpoint<impl HttpEndpoint>,
|
router: Router<()>,
|
||||||
) -> Builder<R> {
|
) -> Builder<R> {
|
||||||
let auth_token: String = rand::thread_rng()
|
let auth_token: String = rand::thread_rng()
|
||||||
.sample_iter(&Alphanumeric)
|
.sample_iter(&Alphanumeric)
|
||||||
|
@ -28,7 +27,7 @@ pub(super) async fn setup<R: Runtime>(
|
||||||
|
|
||||||
let axum_app = axum::Router::new()
|
let axum_app = axum::Router::new()
|
||||||
.route("/", get(|| async { "Spacedrive Server!" }))
|
.route("/", get(|| async { "Spacedrive Server!" }))
|
||||||
.nest("/spacedrive", endpoint.axum())
|
.nest("/spacedrive", router)
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
auth_token.clone(),
|
auth_token.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
|
|
|
@ -7,7 +7,10 @@ use std::{path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use sd_core::{custom_uri::create_custom_uri_endpoint, Node, NodeError};
|
use sd_core::{custom_uri::create_custom_uri_endpoint, Node, NodeError};
|
||||||
|
|
||||||
use tauri::{api::path, async_runtime::block_on, plugin::TauriPlugin, Manager, RunEvent, Runtime};
|
use tauri::{
|
||||||
|
api::path, async_runtime::block_on, ipc::RemoteDomainAccessScope, plugin::TauriPlugin, Manager,
|
||||||
|
RunEvent, Runtime,
|
||||||
|
};
|
||||||
use tokio::{task::block_in_place, time::sleep};
|
use tokio::{task::block_in_place, time::sleep};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
|
@ -58,18 +61,13 @@ async fn main() -> tauri::Result<()> {
|
||||||
let (node, app) = match result {
|
let (node, app) = match result {
|
||||||
Ok((node, router)) => {
|
Ok((node, router)) => {
|
||||||
// This is a super cringe workaround for: https://github.com/tauri-apps/tauri/issues/3725 & https://bugs.webkit.org/show_bug.cgi?id=146351#c5
|
// This is a super cringe workaround for: https://github.com/tauri-apps/tauri/issues/3725 & https://bugs.webkit.org/show_bug.cgi?id=146351#c5
|
||||||
let endpoint = create_custom_uri_endpoint(node.clone());
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let app = app_linux::setup(app, rx, endpoint).await;
|
let app = app_linux::setup(app, rx, create_custom_uri_endpoint(node.clone()).axum()).await;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let app = app.register_uri_scheme_protocol(
|
|
||||||
"spacedrive",
|
|
||||||
endpoint.tauri_uri_scheme("spacedrive"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
|
.register_uri_scheme_protocol(
|
||||||
|
"spacedrive",
|
||||||
|
create_custom_uri_endpoint(node.clone()).tauri_uri_scheme("spacedrive"),
|
||||||
|
)
|
||||||
.plugin(rspc::integrations::tauri::plugin(router, {
|
.plugin(rspc::integrations::tauri::plugin(router, {
|
||||||
let node = node.clone();
|
let node = node.clone();
|
||||||
move || node.clone()
|
move || node.clone()
|
||||||
|
@ -118,6 +116,14 @@ async fn main() -> tauri::Result<()> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configure IPC for custom protocol
|
||||||
|
app.ipc_scope().configure_remote_access(
|
||||||
|
RemoteDomainAccessScope::new("localhost")
|
||||||
|
.allow_on_scheme("spacedrive")
|
||||||
|
.add_window("main")
|
||||||
|
.enable_tauri_api(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_menu_event(menu::handle_menu_event)
|
.on_menu_event(menu::handle_menu_event)
|
||||||
|
|
|
@ -89,7 +89,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
"csp": "default-src spacedrive: webkit-pdfjs-viewer: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,22 +50,20 @@ if (customUriServerUrl && !customUriServerUrl?.endsWith('/')) {
|
||||||
customUriServerUrl += '/';
|
customUriServerUrl += '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomUriURL(path: string): string {
|
|
||||||
if (customUriServerUrl) {
|
|
||||||
const queryParams = customUriAuthToken
|
|
||||||
? `?token=${encodeURIComponent(customUriAuthToken)}`
|
|
||||||
: '';
|
|
||||||
return `${customUriServerUrl}spacedrive/${path}${queryParams}`;
|
|
||||||
} else {
|
|
||||||
return convertFileSrc(path, 'spacedrive');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: 'tauri',
|
platform: 'tauri',
|
||||||
getThumbnailUrlById: (casId) => getCustomUriURL(`thumbnail/${casId}`),
|
getThumbnailUrlById: (casId) => convertFileSrc(`thumbnail/${casId}`, 'spacedrive'),
|
||||||
getFileUrl: (libraryId, locationLocalId, filePathId) =>
|
getFileUrl: (libraryId, locationLocalId, filePathId, _linux_workaround) => {
|
||||||
getCustomUriURL(`file/${libraryId}/${locationLocalId}/${filePathId}`),
|
const path = `file/${libraryId}/${locationLocalId}/${filePathId}`;
|
||||||
|
if (_linux_workaround && customUriServerUrl) {
|
||||||
|
const queryParams = customUriAuthToken
|
||||||
|
? `?token=${encodeURIComponent(customUriAuthToken)}`
|
||||||
|
: '';
|
||||||
|
return `${customUriServerUrl}spacedrive/${path}${queryParams}`;
|
||||||
|
} else {
|
||||||
|
return convertFileSrc(path, 'spacedrive');
|
||||||
|
}
|
||||||
|
},
|
||||||
openLink: shell.open,
|
openLink: shell.open,
|
||||||
getOs,
|
getOs,
|
||||||
openDirectoryPickerDialog: () => dialog.open({ directory: true }),
|
openDirectoryPickerDialog: () => dialog.open({ directory: true }),
|
||||||
|
|
|
@ -5,6 +5,9 @@ module.exports = {
|
||||||
project: './tsconfig.json'
|
project: './tsconfig.json'
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/exhaustive-deps': ['warn', { additionalHooks: 'useCallbackToWatchForm' }]
|
'react-hooks/exhaustive-deps': [
|
||||||
|
'warn',
|
||||||
|
{ additionalHooks: '(useCallbackToWatchForm|useCallbackToWatchResize)' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,36 +1,116 @@
|
||||||
import * as icons from '@sd/assets/icons';
|
import { getIcon } from '@sd/assets/icons/util';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
import { ExplorerItem, isKeyOf, useLibraryContext } from '@sd/client';
|
ImgHTMLAttributes,
|
||||||
import { useExplorerStore } from '~/hooks/useExplorerStore';
|
memo,
|
||||||
import { useIsDark, usePlatform } from '~/util/Platform';
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
|
import { ExplorerItem, useLibraryContext } from '@sd/client';
|
||||||
|
import { useCallbackToWatchResize, useExplorerStore, useIsDark } from '~/hooks';
|
||||||
|
import { usePlatform } from '~/util/Platform';
|
||||||
|
import { pdfViewerEnabled } from '~/util/pdfViewer';
|
||||||
import { getExplorerItemData } from '../util';
|
import { getExplorerItemData } from '../util';
|
||||||
import classes from './Thumb.module.scss';
|
import classes from './Thumb.module.scss';
|
||||||
|
|
||||||
export const getIcon = (
|
interface ThumbnailProps {
|
||||||
isDir: boolean,
|
src: string;
|
||||||
isDark: boolean,
|
cover?: boolean;
|
||||||
kind: string,
|
onLoad?: () => void;
|
||||||
extension?: string | null
|
onError?: () => void;
|
||||||
) => {
|
decoding?: ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
||||||
if (isDir) return icons[isDark ? 'Folder' : 'Folder_Light'];
|
className?: string;
|
||||||
|
crossOrigin?: ImgHTMLAttributes<HTMLImageElement>['crossOrigin'];
|
||||||
|
videoBarsSize?: number;
|
||||||
|
videoExtension?: string;
|
||||||
|
}
|
||||||
|
|
||||||
let document: Extract<keyof typeof icons, 'Document' | 'Document_Light'> = 'Document';
|
const Thumbnail = ({
|
||||||
if (extension) extension = `${kind}_${extension.toLowerCase()}`;
|
src,
|
||||||
if (!isDark) {
|
cover,
|
||||||
kind = kind + '_Light';
|
onLoad,
|
||||||
document = 'Document_Light';
|
onError,
|
||||||
if (extension) extension = extension + '_Light';
|
decoding,
|
||||||
}
|
className,
|
||||||
|
crossOrigin,
|
||||||
|
videoBarsSize,
|
||||||
|
videoExtension
|
||||||
|
}: ThumbnailProps) => {
|
||||||
|
const ref = useRef<HTMLImageElement>(null);
|
||||||
|
const [size, setSize] = useState<null | { width: number; height: number }>(null);
|
||||||
|
|
||||||
return icons[
|
useCallbackToWatchResize(
|
||||||
extension && isKeyOf(icons, extension) ? extension : isKeyOf(icons, kind) ? kind : document
|
(rect) => {
|
||||||
];
|
const { width, height } = rect;
|
||||||
|
setSize((width && height && { width, height }) || null);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
ref
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
// Order matter for crossOrigin attr
|
||||||
|
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
|
||||||
|
{...(crossOrigin ? { crossOrigin } : {})}
|
||||||
|
src={src}
|
||||||
|
ref={ref}
|
||||||
|
style={
|
||||||
|
videoBarsSize
|
||||||
|
? size && size.height >= size.width
|
||||||
|
? {
|
||||||
|
borderLeftWidth: videoBarsSize,
|
||||||
|
borderRightWidth: videoBarsSize
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
borderTopWidth: videoBarsSize,
|
||||||
|
borderBottomWidth: videoBarsSize
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
onLoad={onLoad}
|
||||||
|
onError={() => {
|
||||||
|
onError?.();
|
||||||
|
setSize(null);
|
||||||
|
}}
|
||||||
|
decoding={decoding}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
{videoExtension && (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
cover
|
||||||
|
? {}
|
||||||
|
: size
|
||||||
|
? {
|
||||||
|
marginTop: Math.floor(size.height / 2) - 2,
|
||||||
|
marginLeft: Math.floor(size.width / 2) - 2
|
||||||
|
}
|
||||||
|
: { display: 'none' }
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
cover
|
||||||
|
? 'bottom-1 right-1'
|
||||||
|
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
|
||||||
|
'absolute rounded',
|
||||||
|
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{videoExtension}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VideoThumbSize {
|
enum ThumbType {
|
||||||
width: number;
|
Icon,
|
||||||
height: number;
|
Original,
|
||||||
|
Thumbnail
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThumbProps {
|
export interface ThumbProps {
|
||||||
|
@ -39,156 +119,202 @@ export interface ThumbProps {
|
||||||
cover?: boolean;
|
cover?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
loadOriginal?: boolean;
|
loadOriginal?: boolean;
|
||||||
|
mediaControls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Thumb({ size, cover, ...props }: ThumbProps) {
|
function FileThumb({ size, cover, ...props }: ThumbProps) {
|
||||||
const isDark = useIsDark();
|
const isDark = useIsDark();
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const thumbImg = useRef<HTMLImageElement>(null);
|
|
||||||
const [thumbSize, setThumbSize] = useState<null | VideoThumbSize>(null);
|
|
||||||
const { library } = useLibraryContext();
|
const { library } = useLibraryContext();
|
||||||
const [thumbLoaded, setThumbLoaded] = useState<boolean>(false);
|
const [src, setSrc] = useState<string>('#');
|
||||||
|
const [thumbType, setThumbType] = useState(ThumbType.Icon);
|
||||||
const { locationId, newThumbnails } = useExplorerStore();
|
const { locationId, newThumbnails } = useExplorerStore();
|
||||||
|
const itemData = useMemo(
|
||||||
const { cas_id, isDir, kind, hasThumbnail, newThumb, extension } = getExplorerItemData(
|
() => getExplorerItemData(props.data, newThumbnails),
|
||||||
props.data,
|
[props.data, newThumbnails]
|
||||||
newThumbnails
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Allows disabling thumbnails when they fail to load
|
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
|
||||||
const [useThumb, setUseThumb] = useState<boolean>(hasThumbnail);
|
// thus avoiding improper thumb types changes
|
||||||
|
|
||||||
// When new thumbnails are generated, reset the useThumb state
|
|
||||||
// If it fails to load, it will be set back to false by the error handler in the img
|
|
||||||
useEffect(() => {
|
|
||||||
if (newThumb) setUseThumb(true);
|
|
||||||
}, [newThumb]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const img = thumbImg.current;
|
// Reset src when item changes, to allow detection of yet not updated src
|
||||||
if (cover || kind !== 'Video' || !img || !thumbLoaded) return;
|
setSrc('#');
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
if (props.loadOriginal) {
|
||||||
const { width, height } = img;
|
setThumbType(ThumbType.Original);
|
||||||
setThumbSize(width && height ? { width, height } : null);
|
} else if (itemData.hasThumbnail) {
|
||||||
|
setThumbType(ThumbType.Thumbnail);
|
||||||
|
} else {
|
||||||
|
setThumbType(ThumbType.Icon);
|
||||||
|
}
|
||||||
|
}, [props.loadOriginal, itemData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { cas_id, kind, isDir, extension } = itemData;
|
||||||
|
switch (thumbType) {
|
||||||
|
case ThumbType.Original:
|
||||||
|
if (locationId) {
|
||||||
|
setSrc(
|
||||||
|
platform.getFileUrl(
|
||||||
|
library.uuid,
|
||||||
|
locationId,
|
||||||
|
props.data.item.id,
|
||||||
|
// Workaround Linux webview not supporting playng video and audio through custom protocol urls
|
||||||
|
kind == 'Video' || kind == 'Audio'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setThumbType(ThumbType.Thumbnail);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ThumbType.Thumbnail:
|
||||||
|
if (cas_id) {
|
||||||
|
setSrc(platform.getThumbnailUrlById(cas_id));
|
||||||
|
} else {
|
||||||
|
setThumbType(ThumbType.Icon);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSrc(getIcon(kind, isDir, isDark, extension));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [props.data.item.id, isDark, library.uuid, itemData, platform, thumbType, locationId]);
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
if (src === '#') return;
|
||||||
|
setThumbType((prevThumbType) => {
|
||||||
|
return prevThumbType === ThumbType.Original && itemData.hasThumbnail
|
||||||
|
? ThumbType.Thumbnail
|
||||||
|
: ThumbType.Icon;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
resizeObserver.observe(img);
|
const { kind, extension } = itemData;
|
||||||
return () => resizeObserver.disconnect();
|
|
||||||
}, [kind, cover, thumbImg, thumbLoaded]);
|
|
||||||
|
|
||||||
// Only Videos and Images can show the original file
|
|
||||||
const loadOriginal = (kind === 'Video' || kind === 'Image') && props.loadOriginal;
|
|
||||||
const src = useThumb
|
|
||||||
? loadOriginal && locationId
|
|
||||||
? platform.getFileUrl(library.uuid, locationId, props.data.item.id)
|
|
||||||
: cas_id && platform.getThumbnailUrlById(cas_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let style = {};
|
|
||||||
if (size && kind === 'Video') {
|
|
||||||
const videoBarsHeight = Math.floor(size / 10);
|
|
||||||
style = {
|
|
||||||
borderTopWidth: videoBarsHeight,
|
|
||||||
borderBottomWidth: videoBarsHeight
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const childClassName = 'max-h-full max-w-full object-contain';
|
const childClassName = 'max-h-full max-w-full object-contain';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={size ? { maxWidth: size, width: size - 10, height: size } : {}}
|
style={size ? { maxWidth: size, width: size - 10, height: size } : {}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative flex shrink-0 items-center justify-center',
|
'relative flex shrink-0 items-center justify-center',
|
||||||
src && kind !== 'Video' && [size && 'border-2 border-transparent'],
|
size &&
|
||||||
|
kind !== 'Video' &&
|
||||||
|
thumbType !== ThumbType.Icon &&
|
||||||
|
'border-2 border-transparent',
|
||||||
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
|
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{src ? (
|
{(() => {
|
||||||
kind === 'Video' && loadOriginal ? (
|
switch (thumbType) {
|
||||||
<video
|
case ThumbType.Original:
|
||||||
src={src}
|
switch (extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind) {
|
||||||
onCanPlay={(e) => {
|
case 'PDF':
|
||||||
const video = e.target as HTMLVideoElement;
|
return (
|
||||||
// Why not use the element's attribute? Because React...
|
<object
|
||||||
// https://github.com/facebook/react/issues/10389
|
data={src}
|
||||||
video.loop = true;
|
type="application/pdf"
|
||||||
video.muted = true;
|
className={clsx(
|
||||||
}}
|
'h-full w-full border-0',
|
||||||
style={style}
|
childClassName,
|
||||||
autoPlay
|
props.className
|
||||||
className={clsx(
|
)}
|
||||||
childClassName,
|
/>
|
||||||
size && 'rounded border-x-0 border-black',
|
);
|
||||||
props.className
|
case 'Video':
|
||||||
)}
|
return (
|
||||||
playsInline
|
<video
|
||||||
/>
|
crossOrigin="anonymous"
|
||||||
) : (
|
src={src}
|
||||||
<>
|
onError={onError}
|
||||||
<img
|
autoPlay
|
||||||
src={src}
|
controls={props.mediaControls}
|
||||||
ref={thumbImg}
|
onCanPlay={(e) => {
|
||||||
style={style}
|
const video = e.target as HTMLVideoElement;
|
||||||
onLoad={() => {
|
// Why not use the element's attribute? Because React...
|
||||||
setUseThumb(true);
|
// https://github.com/facebook/react/issues/10389
|
||||||
setThumbLoaded(true);
|
video.loop = !props.mediaControls;
|
||||||
}}
|
video.muted = !props.mediaControls;
|
||||||
onError={() => {
|
}}
|
||||||
setUseThumb(false);
|
className={clsx(
|
||||||
setThumbSize(null);
|
childClassName,
|
||||||
setThumbLoaded(false);
|
size && 'rounded border-x-0 border-black',
|
||||||
}}
|
props.className
|
||||||
decoding="async"
|
)}
|
||||||
className={clsx(
|
playsInline
|
||||||
cover
|
>
|
||||||
? 'min-h-full min-w-full object-cover object-center'
|
<p>Video preview is not supported.</p>
|
||||||
: childClassName,
|
</video>
|
||||||
'shadow shadow-black/30',
|
);
|
||||||
kind === 'Video' ? 'rounded' : 'rounded-sm',
|
case 'Audio':
|
||||||
classes.checkers,
|
return (
|
||||||
size &&
|
<>
|
||||||
(kind === 'Video'
|
<img
|
||||||
? 'border-x-0 border-black'
|
src={getIcon('Audio', false, isDark, extension)}
|
||||||
: size > 60 && 'border-2 border-app-line'),
|
decoding={size ? 'async' : 'sync'}
|
||||||
props.className
|
className={clsx(childClassName, props.className)}
|
||||||
)}
|
/>
|
||||||
/>
|
{props.mediaControls && (
|
||||||
{kind === 'Video' && (!size || size > 80) && (
|
<audio
|
||||||
<div
|
crossOrigin="anonymous"
|
||||||
style={
|
src={src}
|
||||||
cover
|
onError={onError}
|
||||||
? {}
|
controls
|
||||||
: thumbSize
|
autoPlay
|
||||||
? {
|
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
|
||||||
marginTop: Math.floor(thumbSize.height / 2) - 2,
|
>
|
||||||
marginLeft: Math.floor(thumbSize.width / 2) - 2
|
<p>Audio preview is not supported.</p>
|
||||||
}
|
</audio>
|
||||||
: { display: 'none' }
|
)}
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-fallthrough
|
||||||
|
case ThumbType.Thumbnail:
|
||||||
|
return (
|
||||||
|
<Thumbnail
|
||||||
|
src={src}
|
||||||
|
cover={cover}
|
||||||
|
onError={onError}
|
||||||
|
decoding={size ? 'async' : 'sync'}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
cover
|
cover
|
||||||
? 'bottom-1 right-1'
|
? 'min-h-full min-w-full object-cover object-center'
|
||||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
|
: childClassName,
|
||||||
'absolute rounded',
|
kind === 'Video' ? 'rounded' : 'rounded-sm',
|
||||||
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
|
ThumbType.Original || [
|
||||||
|
classes.checkers,
|
||||||
|
'shadow shadow-black/30'
|
||||||
|
],
|
||||||
|
size &&
|
||||||
|
(kind === 'Video'
|
||||||
|
? 'border-x-0 border-black'
|
||||||
|
: size > 60 && 'border-2 border-app-line'),
|
||||||
|
props.className
|
||||||
)}
|
)}
|
||||||
>
|
crossOrigin={ThumbType.Original && 'anonymous'}
|
||||||
{extension}
|
videoBarsSize={
|
||||||
</div>
|
(kind === 'Video' && size && Math.floor(size / 10)) || 0
|
||||||
)}
|
}
|
||||||
</>
|
videoExtension={
|
||||||
)
|
(kind === 'Video' &&
|
||||||
) : (
|
(size == null || size > 80) &&
|
||||||
<img
|
extension) ||
|
||||||
src={getIcon(isDir, isDark, kind, extension)}
|
''
|
||||||
decoding="async"
|
}
|
||||||
className={clsx(childClassName, props.className)}
|
/>
|
||||||
/>
|
);
|
||||||
)}
|
default:
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
decoding={size ? 'async' : 'sync'}
|
||||||
|
className={clsx(childClassName, props.className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Thumb;
|
export default memo(FileThumb);
|
||||||
|
|
|
@ -3,9 +3,9 @@ import clsx from 'clsx';
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useKey, useOnWindowResize } from 'rooks';
|
import { useKey, useOnWindowResize } from 'rooks';
|
||||||
import { ExplorerItem, formatBytes } from '@sd/client';
|
import { ExplorerItem, formatBytes } from '@sd/client';
|
||||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
import { getExplorerStore, useExplorerStore } from '~/hooks';
|
||||||
import RenameTextBox from './File/RenameTextBox';
|
import RenameTextBox from './File/RenameTextBox';
|
||||||
import Thumb from './File/Thumb';
|
import FileThumb from './File/Thumb';
|
||||||
import { ViewItem } from './View';
|
import { ViewItem } from './View';
|
||||||
import { useExplorerViewContext } from './ViewContext';
|
import { useExplorerViewContext } from './ViewContext';
|
||||||
import { getItemFilePath } from './util';
|
import { getItemFilePath } from './util';
|
||||||
|
@ -40,7 +40,7 @@ const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProp
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb data={data} size={explorerStore.gridItemSize} />
|
<FileThumb data={data} size={explorerStore.gridItemSize} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center">
|
<div className="flex flex-col justify-center">
|
||||||
{filePathData && (
|
{filePathData && (
|
||||||
|
|
|
@ -16,11 +16,14 @@ import { CaretDown, CaretUp } from 'phosphor-react';
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useKey, useOnWindowResize } from 'rooks';
|
import { useKey, useOnWindowResize } from 'rooks';
|
||||||
import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client';
|
import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client';
|
||||||
import { useDismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
|
import {
|
||||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
getExplorerStore,
|
||||||
import { useScrolled } from '~/hooks/useScrolled';
|
useDismissibleNoticeStore,
|
||||||
|
useExplorerStore,
|
||||||
|
useScrolled
|
||||||
|
} from '~/hooks';
|
||||||
import RenameTextBox from './File/RenameTextBox';
|
import RenameTextBox from './File/RenameTextBox';
|
||||||
import Thumb from './File/Thumb';
|
import FileThumb from './File/Thumb';
|
||||||
import { InfoPill } from './Inspector';
|
import { InfoPill } from './Inspector';
|
||||||
import { ViewItem } from './View';
|
import { ViewItem } from './View';
|
||||||
import { useExplorerViewContext } from './ViewContext';
|
import { useExplorerViewContext } from './ViewContext';
|
||||||
|
@ -104,7 +107,7 @@ export default () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div className="mr-[10px] flex h-6 w-12 shrink-0 items-center justify-center">
|
<div className="mr-[10px] flex h-6 w-12 shrink-0 items-center justify-center">
|
||||||
<Thumb data={file} size={35} />
|
<FileThumb data={file} size={35} />
|
||||||
</div>
|
</div>
|
||||||
{filePathData && (
|
{filePathData && (
|
||||||
<RenameTextBox
|
<RenameTextBox
|
||||||
|
|
|
@ -6,9 +6,8 @@ import React from 'react';
|
||||||
import { useKey, useOnWindowResize } from 'rooks';
|
import { useKey, useOnWindowResize } from 'rooks';
|
||||||
import { ExplorerItem } from '@sd/client';
|
import { ExplorerItem } from '@sd/client';
|
||||||
import { Button } from '@sd/ui';
|
import { Button } from '@sd/ui';
|
||||||
import { useDismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
|
import { getExplorerStore, useDismissibleNoticeStore, useExplorerStore } from '~/hooks';
|
||||||
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
|
import FileThumb from './File/Thumb';
|
||||||
import Thumb from './File/Thumb';
|
|
||||||
import { ViewItem } from './View';
|
import { ViewItem } from './View';
|
||||||
import { useExplorerViewContext } from './ViewContext';
|
import { useExplorerViewContext } from './ViewContext';
|
||||||
|
|
||||||
|
@ -36,7 +35,7 @@ const MediaViewItem = memo(({ data, index }: MediaViewItemProps) => {
|
||||||
selected && 'bg-app-selected/20'
|
selected && 'bg-app-selected/20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb
|
<FileThumb
|
||||||
size={0}
|
size={0}
|
||||||
data={data}
|
data={data}
|
||||||
cover={explorerStore.mediaAspectSquare}
|
cover={explorerStore.mediaAspectSquare}
|
||||||
|
|
|
@ -1,108 +1,20 @@
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { animated, useTransition } from '@react-spring/web';
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { XCircle } from 'phosphor-react';
|
import { XCircle } from 'phosphor-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { subscribeKey } from 'valtio/utils';
|
import { subscribeKey } from 'valtio/utils';
|
||||||
import { ExplorerItem } from '~/../packages/client/src';
|
import { ExplorerItem } from '@sd/client';
|
||||||
import { showAlertDialog } from '~/components/AlertDialog';
|
import { getExplorerStore } from '~/hooks';
|
||||||
import { getExplorerStore } from '~/hooks/useExplorerStore';
|
|
||||||
import { usePlatform } from '~/util/Platform';
|
|
||||||
import FileThumb from './File/Thumb';
|
import FileThumb from './File/Thumb';
|
||||||
import { getExplorerItemData } from './util';
|
|
||||||
|
|
||||||
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
||||||
const AnimatedDialogContent = animated(Dialog.Content);
|
const AnimatedDialogContent = animated(Dialog.Content);
|
||||||
|
|
||||||
export interface QuickPreviewProps extends Dialog.DialogProps {
|
export interface QuickPreviewProps extends Dialog.DialogProps {
|
||||||
libraryUuid: string;
|
|
||||||
transformOrigin?: string;
|
transformOrigin?: string;
|
||||||
}
|
}
|
||||||
interface FilePreviewProps {
|
|
||||||
src: string;
|
|
||||||
kind: null | string;
|
|
||||||
onError: () => void;
|
|
||||||
explorerItem: ExplorerItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
|
||||||
* Check if webview can display PDFs
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/pdfViewerEnabled
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mimeTypes
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/plugins
|
|
||||||
*/
|
|
||||||
const pdfViewerEnabled = () => {
|
|
||||||
// pdfViewerEnabled is quite new, Safari only started supporting it in march 2023
|
|
||||||
// https://caniuse.com/?search=pdfViewerEnabled
|
|
||||||
if ('pdfViewerEnabled' in navigator && navigator.pdfViewerEnabled) return true;
|
|
||||||
|
|
||||||
// This is deprecated, but should be supported on all browsers/webviews
|
|
||||||
// https://caniuse.com/mdn-api_navigator_mimetypes
|
|
||||||
if (navigator.mimeTypes) {
|
|
||||||
if ('application/pdf' in navigator.mimeTypes)
|
|
||||||
return (navigator.mimeTypes['application/pdf'] as null | MimeType)?.enabledPlugin;
|
|
||||||
if ('text/pdf' in navigator.mimeTypes)
|
|
||||||
return (navigator.mimeTypes['text/pdf'] as null | MimeType)?.enabledPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last ditch effort
|
|
||||||
// https://caniuse.com/mdn-api_navigator_plugins
|
|
||||||
return 'PDF Viewer' in navigator.plugins;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FilePreview({ explorerItem, kind, src, onError }: FilePreviewProps) {
|
|
||||||
const className = clsx('w-full object-contain');
|
|
||||||
const fileThumb = <FileThumb size={0} data={explorerItem} cover 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-x-1/2 translate-y-[-150%]"
|
|
||||||
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 explorerItem = useRef<null | ExplorerItem>(null);
|
||||||
const explorerStore = getExplorerStore();
|
const explorerStore = getExplorerStore();
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
@ -127,15 +39,6 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
|
||||||
[explorerStore]
|
[explorerStore]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPreviewError = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
explorerStore.quickViewObject = null;
|
|
||||||
showAlertDialog({
|
|
||||||
title: 'Error',
|
|
||||||
value: 'Could not load file preview.'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const transitions = useTransition(isOpen, {
|
const transitions = useTransition(isOpen, {
|
||||||
from: {
|
from: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
|
@ -160,22 +63,6 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
|
||||||
if (!show || explorerItem.current == null) return null;
|
if (!show || explorerItem.current == null) return null;
|
||||||
|
|
||||||
const { item } = explorerItem.current;
|
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' && pdfViewerEnabled() ? 'PDF' : kind}
|
|
||||||
onError={onPreviewError}
|
|
||||||
explorerItem={explorerItem.current}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -207,7 +94,15 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
|
||||||
</span>
|
</span>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex shrink h-full overflow-hidden">{preview}</div>
|
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
|
||||||
|
<FileThumb
|
||||||
|
size={0}
|
||||||
|
data={explorerItem.current}
|
||||||
|
className="w-full"
|
||||||
|
loadOriginal
|
||||||
|
mediaControls
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedDialogContent>
|
</AnimatedDialogContent>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|
|
@ -10,8 +10,7 @@ export function getExplorerItemData(data: ExplorerItem, newThumbnails?: Record<s
|
||||||
cas_id: filePath?.cas_id || null,
|
cas_id: filePath?.cas_id || null,
|
||||||
isDir: isPath(data) && data.item.is_dir,
|
isDir: isPath(data) && data.item.is_dir,
|
||||||
kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null,
|
kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null,
|
||||||
newThumb: !!newThumbnails?.[filePath?.cas_id || ''],
|
hasThumbnail: data.has_thumbnail || newThumbnails?.[filePath?.cas_id || ''] || false,
|
||||||
hasThumbnail: data.has_thumbnail || !!newThumbnails?.[filePath?.cas_id || ''] || false,
|
|
||||||
extension: filePath?.extension || null
|
extension: filePath?.extension || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,7 @@ const Layout = () => {
|
||||||
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
|
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<QuickPreview libraryUuid={library.uuid} />
|
<QuickPreview />
|
||||||
</LibraryContextProvider>
|
</LibraryContextProvider>
|
||||||
) : (
|
) : (
|
||||||
<h1 className="p-4 text-white">
|
<h1 className="p-4 text-white">
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './useCallbackToWatchForm';
|
export * from './useCallbackToWatchForm';
|
||||||
|
export * from './useCallbackToWatchResize';
|
||||||
export * from './useClickOutside';
|
export * from './useClickOutside';
|
||||||
export * from './useCounter';
|
export * from './useCounter';
|
||||||
export * from './useDebouncedForm';
|
export * from './useDebouncedForm';
|
||||||
|
@ -7,10 +8,12 @@ export * from './useExplorerStore';
|
||||||
export * from './useExplorerTopBarOptions';
|
export * from './useExplorerTopBarOptions';
|
||||||
export * from './useFocusState';
|
export * from './useFocusState';
|
||||||
export * from './useInputState';
|
export * from './useInputState';
|
||||||
|
export * from './useIsDark';
|
||||||
export * from './useKeyboardHandler';
|
export * from './useKeyboardHandler';
|
||||||
export * from './useOperatingSystem';
|
export * from './useOperatingSystem';
|
||||||
export * from './useScrolled';
|
export * from './useScrolled';
|
||||||
export * from './useSearchStore';
|
export * from './useSearchStore';
|
||||||
|
export * from './useSpacedropState';
|
||||||
export * from './useToasts';
|
export * from './useToasts';
|
||||||
export * from './useZodRouteParams';
|
export * from './useZodRouteParams';
|
||||||
export * from './useZodSearchParams';
|
export * from './useZodSearchParams';
|
||||||
|
|
|
@ -3,25 +3,21 @@ import { EventType, FieldPath, FieldValues, UseFormReturn } from 'react-hook-for
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
|
type Cb<S extends FieldValues> = (
|
||||||
|
value: S,
|
||||||
|
info: {
|
||||||
|
name?: FieldPath<S>;
|
||||||
|
type?: EventType;
|
||||||
|
}
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
export function useCallbackToWatchForm<S extends FieldValues>(
|
export function useCallbackToWatchForm<S extends FieldValues>(
|
||||||
callback: (
|
callback: Cb<S>,
|
||||||
value: S,
|
|
||||||
info: {
|
|
||||||
name?: FieldPath<S>;
|
|
||||||
type?: EventType;
|
|
||||||
}
|
|
||||||
) => void | Promise<void>,
|
|
||||||
deps: [UseFormReturn<S, unknown>, ...React.DependencyList]
|
deps: [UseFormReturn<S, unknown>, ...React.DependencyList]
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
export function useCallbackToWatchForm<S extends FieldValues>(
|
export function useCallbackToWatchForm<S extends FieldValues>(
|
||||||
callback: (
|
callback: Cb<S>,
|
||||||
value: S,
|
|
||||||
info: {
|
|
||||||
name?: FieldPath<S>;
|
|
||||||
type?: EventType;
|
|
||||||
}
|
|
||||||
) => void | Promise<void>,
|
|
||||||
deps: React.DependencyList,
|
deps: React.DependencyList,
|
||||||
form: UseFormReturn<S, unknown>
|
form: UseFormReturn<S, unknown>
|
||||||
): void;
|
): void;
|
||||||
|
@ -39,13 +35,7 @@ export function useCallbackToWatchForm<S extends FieldValues>(
|
||||||
* @param form - Form to watch. If not provided, it will be taken from the first element of the dependency list
|
* @param form - Form to watch. If not provided, it will be taken from the first element of the dependency list
|
||||||
*/
|
*/
|
||||||
export function useCallbackToWatchForm<S extends FieldValues>(
|
export function useCallbackToWatchForm<S extends FieldValues>(
|
||||||
callback: (
|
callback: Cb<S>,
|
||||||
value: S,
|
|
||||||
info: {
|
|
||||||
name?: FieldPath<S>;
|
|
||||||
type?: EventType;
|
|
||||||
}
|
|
||||||
) => void | Promise<void>,
|
|
||||||
deps: React.DependencyList,
|
deps: React.DependencyList,
|
||||||
form?: UseFormReturn<S, unknown>
|
form?: UseFormReturn<S, unknown>
|
||||||
): void {
|
): void {
|
||||||
|
|
77
interface/hooks/useCallbackToWatchResize.ts
Normal file
77
interface/hooks/useCallbackToWatchResize.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { DependencyList, Dispatch, RefObject, SetStateAction, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type ResizeRect = Readonly<Omit<DOMRectReadOnly, 'toJSON'>>;
|
||||||
|
type Cb = (rect: ResizeRect) => void;
|
||||||
|
|
||||||
|
const defaultRect: ResizeRect = {
|
||||||
|
y: 0,
|
||||||
|
x: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
bottom: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const observedElementsCb = new WeakMap<Element, Set<Cb>>();
|
||||||
|
|
||||||
|
// Why use a single ResizeObserver instead of one per component?
|
||||||
|
// https://github.com/WICG/resize-observer/issues/59
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const elem = entry.target;
|
||||||
|
const cbs = observedElementsCb.get(elem);
|
||||||
|
if (cbs) {
|
||||||
|
// TODO: contentRect is included in the spec for web compat reasons, and may be deprecated one day
|
||||||
|
// Find a way to reconstruct contentRect from the other properties
|
||||||
|
// Do not use elem.getBoundingClientRect() as it is very CPU expensive
|
||||||
|
for (const cb of cbs) cb(entry.contentRect);
|
||||||
|
} else {
|
||||||
|
resizeObserver.unobserve(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useCallbackToWatchResize(
|
||||||
|
callback: Cb,
|
||||||
|
deps: [RefObject<Element>, ...React.DependencyList]
|
||||||
|
): void;
|
||||||
|
export function useCallbackToWatchResize(
|
||||||
|
callback: Cb,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
_ref: RefObject<Element>
|
||||||
|
): void;
|
||||||
|
export function useCallbackToWatchResize(
|
||||||
|
callback: Cb,
|
||||||
|
deps: DependencyList,
|
||||||
|
_ref?: RefObject<Element>
|
||||||
|
) {
|
||||||
|
const ref = _ref ?? (deps[0] as RefObject<Element> | undefined);
|
||||||
|
if (ref == null) throw new Error('Element not provided');
|
||||||
|
|
||||||
|
// Disable lint warning because this hook is a wrapper for useCallback
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const onResize = useCallback(callback, deps);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const elem = ref.current;
|
||||||
|
if (elem == null) {
|
||||||
|
onResize(defaultRect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStates =
|
||||||
|
observedElementsCb.get(elem) ?? new Set<Dispatch<SetStateAction<ResizeRect>>>();
|
||||||
|
observedElementsCb.set(elem, setStates);
|
||||||
|
setStates.add(onResize);
|
||||||
|
|
||||||
|
resizeObserver.observe(elem);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.unobserve(elem);
|
||||||
|
setStates.delete(onResize);
|
||||||
|
if (setStates.size === 0) observedElementsCb.delete(elem);
|
||||||
|
};
|
||||||
|
}, [ref, onResize]);
|
||||||
|
}
|
16
interface/hooks/useIsDark.ts
Normal file
16
interface/hooks/useIsDark.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Use a media query to detect light theme changes, because our default theme is dark
|
||||||
|
const lightMediaQuery = matchMedia('(prefers-color-scheme: light)');
|
||||||
|
|
||||||
|
export function useIsDark(): boolean {
|
||||||
|
const [isDark, setIsDark] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => setIsDark(!lightMediaQuery.matches);
|
||||||
|
lightMediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => lightMediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [setIsDark]);
|
||||||
|
|
||||||
|
return isDark;
|
||||||
|
}
|
|
@ -52,7 +52,6 @@
|
||||||
"react-json-view": "^1.21.3",
|
"react-json-view": "^1.21.3",
|
||||||
"react-loading-skeleton": "^3.1.0",
|
"react-loading-skeleton": "^3.1.0",
|
||||||
"react-qr-code": "^2.0.11",
|
"react-qr-code": "^2.0.11",
|
||||||
"react-responsive": "^9.0.2",
|
|
||||||
"react-router": "6.9.0",
|
"react-router": "6.9.0",
|
||||||
"react-router-dom": "6.9.0",
|
"react-router-dom": "6.9.0",
|
||||||
"remix-params-helper": "^0.4.10",
|
"remix-params-helper": "^0.4.10",
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { PropsWithChildren, createContext, useContext, useState } from 'react';
|
import { PropsWithChildren, createContext, useContext, useState } from 'react';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
|
||||||
|
|
||||||
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
|
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
|
||||||
|
|
||||||
|
@ -8,7 +7,12 @@ export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unkno
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
platform: 'web' | 'tauri'; // This represents the specific platform implementation
|
platform: 'web' | 'tauri'; // This represents the specific platform implementation
|
||||||
getThumbnailUrlById: (casId: string) => string;
|
getThumbnailUrlById: (casId: string) => string;
|
||||||
getFileUrl: (libraryId: string, locationLocalId: number, filePathId: number) => string;
|
getFileUrl: (
|
||||||
|
libraryId: string,
|
||||||
|
locationLocalId: number,
|
||||||
|
filePathId: number,
|
||||||
|
_linux_workaround?: boolean
|
||||||
|
) => string;
|
||||||
openLink: (url: string) => void;
|
openLink: (url: string) => void;
|
||||||
demoMode?: boolean; // TODO: Remove this in favour of demo mode being handled at the React Query level
|
demoMode?: boolean; // TODO: Remove this in favour of demo mode being handled at the React Query level
|
||||||
getOs?(): Promise<OperatingSystem>;
|
getOs?(): Promise<OperatingSystem>;
|
||||||
|
@ -45,18 +49,3 @@ export function PlatformProvider({
|
||||||
}: PropsWithChildren<{ platform: Platform }>) {
|
}: PropsWithChildren<{ platform: Platform }>) {
|
||||||
return <context.Provider value={platform}>{children}</context.Provider>;
|
return <context.Provider value={platform}>{children}</context.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useIsDark = () => {
|
|
||||||
const systemPrefersDark = useMediaQuery(
|
|
||||||
{
|
|
||||||
query: '(prefers-color-scheme: dark)'
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
(prefersDark: boolean) => {
|
|
||||||
setIsDark(prefersDark);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const [isDark, setIsDark] = useState(systemPrefersDark);
|
|
||||||
|
|
||||||
return isDark;
|
|
||||||
};
|
|
||||||
|
|
24
interface/util/pdfViewer.tsx
Normal file
24
interface/util/pdfViewer.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Check if webview can display PDFs
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/pdfViewerEnabled
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mimeTypes
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/plugins
|
||||||
|
*/
|
||||||
|
export const pdfViewerEnabled = () => {
|
||||||
|
// pdfViewerEnabled is quite new, Safari only started supporting it in march 2023
|
||||||
|
// https://caniuse.com/?search=pdfViewerEnabled
|
||||||
|
if ('pdfViewerEnabled' in navigator && navigator.pdfViewerEnabled) return true;
|
||||||
|
|
||||||
|
// This is deprecated, but should be supported on all browsers/webviews
|
||||||
|
// https://caniuse.com/mdn-api_navigator_mimetypes
|
||||||
|
if (navigator.mimeTypes) {
|
||||||
|
if ('application/pdf' in navigator.mimeTypes)
|
||||||
|
return !!(navigator.mimeTypes['application/pdf'] as null | MimeType)?.enabledPlugin;
|
||||||
|
if ('text/pdf' in navigator.mimeTypes)
|
||||||
|
return !!(navigator.mimeTypes['text/pdf'] as null | MimeType)?.enabledPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last ditch effort
|
||||||
|
// https://caniuse.com/mdn-api_navigator_plugins
|
||||||
|
return 'PDF Viewer' in navigator.plugins;
|
||||||
|
};
|
26
packages/assets/icons/util.tsx
Normal file
26
packages/assets/icons/util.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import * as icons from '.';
|
||||||
|
|
||||||
|
export const getIcon = (
|
||||||
|
kind: string,
|
||||||
|
isDir?: boolean,
|
||||||
|
isDark?: boolean,
|
||||||
|
extension?: string | null
|
||||||
|
) => {
|
||||||
|
if (isDir) return icons[isDark ? 'Folder' : 'Folder_Light'];
|
||||||
|
|
||||||
|
let document: Extract<keyof typeof icons, 'Document' | 'Document_Light'> = 'Document';
|
||||||
|
if (extension) extension = `${kind}_${extension.toLowerCase()}`;
|
||||||
|
if (!isDark) {
|
||||||
|
kind = kind + '_Light';
|
||||||
|
document = 'Document_Light';
|
||||||
|
if (extension) extension = extension + '_Light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons[
|
||||||
|
(extension && extension in icons
|
||||||
|
? extension
|
||||||
|
: kind in icons
|
||||||
|
? kind
|
||||||
|
: document) as keyof typeof icons
|
||||||
|
];
|
||||||
|
};
|
|
@ -743,9 +743,6 @@ importers:
|
||||||
react-qr-code:
|
react-qr-code:
|
||||||
specifier: ^2.0.11
|
specifier: ^2.0.11
|
||||||
version: 2.0.11(react@18.2.0)
|
version: 2.0.11(react@18.2.0)
|
||||||
react-responsive:
|
|
||||||
specifier: ^9.0.2
|
|
||||||
version: 9.0.2(react@18.2.0)
|
|
||||||
react-router:
|
react-router:
|
||||||
specifier: 6.9.0
|
specifier: 6.9.0
|
||||||
version: 6.9.0(react@18.2.0)
|
version: 6.9.0(react@18.2.0)
|
||||||
|
@ -9551,10 +9548,6 @@ packages:
|
||||||
webpack: 5.82.0(esbuild@0.17.18)
|
webpack: 5.82.0(esbuild@0.17.18)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/css-mediaquery@0.1.2:
|
|
||||||
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/css-select@4.3.0:
|
/css-select@4.3.0:
|
||||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -12005,10 +11998,6 @@ packages:
|
||||||
engines: {node: '>=14.18.0'}
|
engines: {node: '>=14.18.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/hyphenate-style-name@1.0.4:
|
|
||||||
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/iconv-lite@0.4.24:
|
/iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
@ -13336,12 +13325,6 @@ packages:
|
||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/matchmediaquery@0.3.1:
|
|
||||||
resolution: {integrity: sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==}
|
|
||||||
dependencies:
|
|
||||||
css-mediaquery: 0.1.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/md5-file@3.2.3:
|
/md5-file@3.2.3:
|
||||||
resolution: {integrity: sha512-3Tkp1piAHaworfcCgH0jKbTvj1jWWFgbvh2cXaNCgHwyTCBxxvD1Y04rmfpvdPm1P4oXMOpm6+2H7sr7v9v8Fw==}
|
resolution: {integrity: sha512-3Tkp1piAHaworfcCgH0jKbTvj1jWWFgbvh2cXaNCgHwyTCBxxvD1Y04rmfpvdPm1P4oXMOpm6+2H7sr7v9v8Fw==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
@ -16086,19 +16069,6 @@ packages:
|
||||||
use-sidecar: 1.1.2(@types/react@18.2.5)(react@18.2.0)
|
use-sidecar: 1.1.2(@types/react@18.2.5)(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/react-responsive@9.0.2(react@18.2.0):
|
|
||||||
resolution: {integrity: sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16.8.0'
|
|
||||||
dependencies:
|
|
||||||
hyphenate-style-name: 1.0.4
|
|
||||||
matchmediaquery: 0.3.1
|
|
||||||
prop-types: 15.8.1
|
|
||||||
react: 18.2.0
|
|
||||||
shallow-equal: 1.2.1
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/react-router-dom@6.9.0(react-dom@18.2.0)(react@18.2.0):
|
/react-router-dom@6.9.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==}
|
resolution: {integrity: sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
@ -16868,10 +16838,6 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
kind-of: 6.0.3
|
kind-of: 6.0.3
|
||||||
|
|
||||||
/shallow-equal@1.2.1:
|
|
||||||
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/shebang-command@1.2.0:
|
/shebang-command@1.2.0:
|
||||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
Loading…
Reference in a new issue