mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-21 06:59:17 -04:00
* WIP * Some minor fixes for light theme - Fix `useIsDark` not reading the initial theme value (only reacting to theme changes) - Fix `Inspector` always showing a dark image when no item was selected - Fix `Thumb` video extension using black text on light theme * Improve form error messages - Fix `addLocationDialog` not registering the path input - Remove `@hookform/error-message` * Fix Dialog not respecting max-width - Fix ErrorMessage animation jumping * A lot of misc fixes - Implement an `useExplorerItemData` (cleaner fix for thumbnail flicker) - Fix broken image showing for `Thumb` due a rece condition when props are updated - Implement an `ExternalObject` component that hacks an alternative for `onLoad` and `onError` events for <object> - Fix `Overview` broken layout when `Inspector` is open and window is small - Improve `IndexerRuleEditor` UX in `AddLocationDialog` - Improve the way `IndexerRuleEditor` handles rules deletion - Fix `IndexerRuleEditor` closing the the new rule form even when the rule creation fails - Add an editable prop to `IndexerRuleEditor` to disable all editable functions - Fix `getIcon` fallbacking to Document instead of the dark version of an icon if it exists - Add some missing colors to white theme * Format * Fix Backup restore key dialog not resetting after error * Feedback * Format * Normalize imports * Fix ColorPicker export * Fix Thumb video ext not showing in MediaView with show square thumbnails - Fix AddLocationDialog Error resetting when changing IndexRules
325 lines
8.7 KiB
TypeScript
325 lines
8.7 KiB
TypeScript
import { getIcon, iconNames } from '@sd/assets/icons/util';
|
|
import clsx from 'clsx';
|
|
import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { ExplorerItem, useLibraryContext } from '@sd/client';
|
|
import { ExternalObject } from '~/components';
|
|
import {
|
|
useCallbackToWatchResize,
|
|
useExplorerItemData,
|
|
useExplorerStore,
|
|
useIsDark
|
|
} from '~/hooks';
|
|
import { usePlatform } from '~/util/Platform';
|
|
import { pdfViewerEnabled } from '~/util/pdfViewer';
|
|
import classes from './Thumb.module.scss';
|
|
|
|
interface ThumbnailProps {
|
|
src: string;
|
|
cover?: boolean;
|
|
onLoad?: () => void;
|
|
onError?: () => void;
|
|
decoding?: ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
|
className?: string;
|
|
crossOrigin?: ImgHTMLAttributes<HTMLImageElement>['crossOrigin'];
|
|
videoBarsSize?: number;
|
|
videoExtension?: string;
|
|
}
|
|
|
|
const Thumbnail = memo(
|
|
({ crossOrigin, videoBarsSize, videoExtension, ...props }: ThumbnailProps) => {
|
|
const ref = useRef<HTMLImageElement>(null);
|
|
const [size, setSize] = useState<null | { width: number; height: number }>(null);
|
|
|
|
useCallbackToWatchResize(
|
|
(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={props.src}
|
|
ref={ref}
|
|
style={
|
|
videoBarsSize
|
|
? size && size.height >= size.width
|
|
? {
|
|
borderLeftWidth: videoBarsSize,
|
|
borderRightWidth: videoBarsSize
|
|
}
|
|
: {
|
|
borderTopWidth: videoBarsSize,
|
|
borderBottomWidth: videoBarsSize
|
|
}
|
|
: {}
|
|
}
|
|
onLoad={props.onLoad}
|
|
onError={() => {
|
|
props.onError?.();
|
|
setSize(null);
|
|
}}
|
|
decoding={props.decoding}
|
|
className={props.className}
|
|
/>
|
|
{videoExtension && (
|
|
<div
|
|
style={
|
|
props.cover
|
|
? {}
|
|
: size
|
|
? {
|
|
marginTop: Math.floor(size.height / 2) - 2,
|
|
marginLeft: Math.floor(size.width / 2) - 2
|
|
}
|
|
: { display: 'none' }
|
|
}
|
|
className={clsx(
|
|
props.cover
|
|
? 'bottom-1 right-1'
|
|
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
|
|
'absolute rounded !text-white',
|
|
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
|
|
)}
|
|
>
|
|
{videoExtension}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
);
|
|
|
|
enum ThumbType {
|
|
Icon,
|
|
Original,
|
|
Thumbnail
|
|
}
|
|
|
|
export interface ThumbProps {
|
|
data: ExplorerItem;
|
|
size: null | number;
|
|
cover?: boolean;
|
|
className?: string;
|
|
loadOriginal?: boolean;
|
|
mediaControls?: boolean;
|
|
}
|
|
|
|
function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|
const isDark = useIsDark();
|
|
const platform = usePlatform();
|
|
const itemData = useExplorerItemData(props.data);
|
|
const { library } = useLibraryContext();
|
|
const [src, setSrc] = useState<string>('#');
|
|
const [loaded, setLoaded] = useState<boolean>(false);
|
|
const [thumbType, setThumbType] = useState(ThumbType.Icon);
|
|
const { locationId } = useExplorerStore();
|
|
|
|
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
|
|
// thus avoiding improper thumb types changes
|
|
useLayoutEffect(() => {
|
|
// Reset src when item changes, to allow detection of yet not updated src
|
|
setSrc('#');
|
|
setLoaded(false);
|
|
|
|
if (props.loadOriginal) {
|
|
setThumbType(ThumbType.Original);
|
|
} else if (itemData.hasThumbnail) {
|
|
setThumbType(ThumbType.Thumbnail);
|
|
} else {
|
|
setThumbType(ThumbType.Icon);
|
|
}
|
|
}, [props.loadOriginal, itemData]);
|
|
|
|
useEffect(() => {
|
|
const { casId, 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 (casId) {
|
|
setSrc(platform.getThumbnailUrlById(casId));
|
|
} else {
|
|
setThumbType(ThumbType.Icon);
|
|
}
|
|
break;
|
|
default:
|
|
setSrc(getIcon(kind, isDark, extension, isDir));
|
|
break;
|
|
}
|
|
}, [props.data.item.id, isDark, library.uuid, itemData, platform, thumbType, locationId]);
|
|
|
|
const onLoad = () => setLoaded(true);
|
|
|
|
const onError = () => {
|
|
setLoaded(false);
|
|
if (src !== '#')
|
|
setThumbType((prevThumbType) => {
|
|
return prevThumbType === ThumbType.Original && itemData.hasThumbnail
|
|
? ThumbType.Thumbnail
|
|
: ThumbType.Icon;
|
|
});
|
|
};
|
|
|
|
const { kind, extension } = itemData;
|
|
const childClassName = 'max-h-full max-w-full object-contain';
|
|
return (
|
|
<div
|
|
style={{
|
|
visibility: loaded ? 'visible' : 'hidden',
|
|
...(size ? { maxWidth: size, width: size - 10, height: size } : {})
|
|
}}
|
|
className={clsx(
|
|
'relative flex shrink-0 items-center justify-center',
|
|
size &&
|
|
kind !== 'Video' &&
|
|
thumbType !== ThumbType.Icon &&
|
|
'border-2 border-transparent',
|
|
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
|
|
props.className
|
|
)}
|
|
>
|
|
{(() => {
|
|
switch (thumbType) {
|
|
case ThumbType.Original:
|
|
switch (extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind) {
|
|
case 'PDF':
|
|
return (
|
|
<ExternalObject
|
|
data={src}
|
|
type="application/pdf"
|
|
onLoad={onLoad}
|
|
onError={onError}
|
|
className={clsx(
|
|
'h-full w-full border-0',
|
|
childClassName,
|
|
props.className
|
|
)}
|
|
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
|
|
/>
|
|
);
|
|
case 'Video':
|
|
return (
|
|
<video
|
|
// Order matter for crossOrigin attr
|
|
crossOrigin="anonymous"
|
|
src={src}
|
|
onError={onError}
|
|
autoPlay
|
|
controls={props.mediaControls}
|
|
onCanPlay={(e) => {
|
|
const video = e.target as HTMLVideoElement;
|
|
// Why not use the element's attribute? Because React...
|
|
// https://github.com/facebook/react/issues/10389
|
|
video.loop = !props.mediaControls;
|
|
video.muted = !props.mediaControls;
|
|
}}
|
|
className={clsx(
|
|
childClassName,
|
|
size && 'rounded border-x-0 border-black',
|
|
props.className
|
|
)}
|
|
playsInline
|
|
onLoadedData={onLoad}
|
|
>
|
|
<p>Video preview is not supported.</p>
|
|
</video>
|
|
);
|
|
case 'Audio':
|
|
return (
|
|
<>
|
|
<img
|
|
src={getIcon(iconNames.Audio, isDark, extension)}
|
|
onLoad={onLoad}
|
|
decoding={size ? 'async' : 'sync'}
|
|
className={clsx(childClassName, props.className)}
|
|
/>
|
|
{props.mediaControls && (
|
|
<audio
|
|
// Order matter for crossOrigin attr
|
|
crossOrigin="anonymous"
|
|
src={src}
|
|
onError={onError}
|
|
controls
|
|
autoPlay
|
|
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
|
|
>
|
|
<p>Audio preview is not supported.</p>
|
|
</audio>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
// eslint-disable-next-line no-fallthrough
|
|
case ThumbType.Thumbnail:
|
|
return (
|
|
<Thumbnail
|
|
src={src}
|
|
cover={cover}
|
|
onLoad={onLoad}
|
|
onError={onError}
|
|
decoding={size ? 'async' : 'sync'}
|
|
className={clsx(
|
|
cover
|
|
? 'min-h-full min-w-full object-cover object-center'
|
|
: childClassName,
|
|
kind === 'Video' ? 'rounded' : 'rounded-sm',
|
|
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'} // Here it is ok, because it is not a react attr
|
|
videoBarsSize={
|
|
(kind === 'Video' && size && Math.floor(size / 10)) || 0
|
|
}
|
|
videoExtension={
|
|
(kind === 'Video' &&
|
|
(cover || size == null || size > 80) &&
|
|
extension) ||
|
|
''
|
|
}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<img
|
|
src={src}
|
|
onLoad={onLoad}
|
|
onError={() => setLoaded(false)}
|
|
decoding={size ? 'async' : 'sync'}
|
|
className={clsx(childClassName, props.className)}
|
|
/>
|
|
);
|
|
}
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default memo(FileThumb);
|