[ENG-1086, ENG-1087] Add error and loading indicator to thumbnail (#1448)

* Add onLoad and onError listeners to FileThumb
 - Show toast when Original file fails loading in QuickPreview
 - Formatting

* Fix close toast not working when QuickPreview was open

* Fix panic when serving unsupported files

* Formatting & lint
This commit is contained in:
Vítor Vasconcellos authored and GitHub committed 2023-10-11 00:52:21 +00:00
1 parent 01dbc2caf7
commit 9ca22733ad
12 files changed
+218 -128

No files matched your search

+10 -12
View File
@@ -1,16 +1,14 @@
<!doctype html>
<html lang="en" class="vanilla-theme">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spacedrive</title>
</head>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spacedrive</title>
</head>
<body style="overflow: hidden">
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
<body style="overflow: hidden">
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -475,8 +475,8 @@ async fn infer_the_mime_type(
"txt" => "text/plain",
_ => {
if charset.is_empty() {
todo!("file ext='{ext}' mime_type='{mime_type}' is not supported");
// "TODO: This filetype is not supported because of the missing mime type!",
return Err(not_implemented(()));
};
mime_type
}
+10 -1
View File
@@ -31,7 +31,7 @@ pub(crate) fn not_found(err: impl Debug) -> http::Response<BoxBody> {
#[track_caller]
pub(crate) fn internal_server_error(err: impl Debug) -> http::Response<BoxBody> {
debug!(
"500 - Internal Server Error at {}: {err:?}",
"500: Internal Server Error at {}: {err:?}",
Location::caller()
);
@@ -40,6 +40,15 @@ pub(crate) fn internal_server_error(err: impl Debug) -> http::Response<BoxBody>
.body(body::boxed(Full::from("")))
}
#[track_caller]
pub(crate) fn not_implemented(err: impl Debug) -> http::Response<BoxBody> {
debug!("501: Not Implemented at {}: {err:?}", Location::caller());
InfallibleResponse::builder()
.status(StatusCode::NOT_IMPLEMENTED)
.body(body::boxed(Full::from("")))
}
pub(crate) async fn cors_middleware<B>(req: Request<B>, next: Next<B>) -> Response<BoxBody> {
if req.method() == Method::OPTIONS {
return Response::builder()
@@ -2,6 +2,7 @@ import { getIcon, getIconByName, iconNames } from '@sd/assets/util';
import clsx from 'clsx';
import {
memo,
SyntheticEvent,
useCallback,
useEffect,
useLayoutEffect,
@@ -37,6 +38,8 @@ export interface ThumbProps {
size?: number;
cover?: boolean;
frame?: boolean;
onLoad?: (state: ThumbType) => void;
onError?: (state: ThumbType, error: Error) => void;
blackBars?: boolean;
blackBarsSize?: number;
extension?: boolean;
@@ -69,16 +72,31 @@ export const FileThumb = memo((props: ThumbProps) => {
isDark ? classes.checkers : classes.checkersLight
);
const onLoad = useCallback(() => setLoaded(true), []);
const _onLoad = props.onLoad;
const onLoad = useCallback(() => {
setLoaded(true);
_onLoad?.(thumbType);
}, [_onLoad, thumbType]);
const onError = useCallback(() => {
setLoaded(false);
setThumbType((prevThumbType) =>
prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
? ThumbType.Thumbnail
: ThumbType.Icon
);
}, [itemData.hasLocalThumbnail]);
const _onError = props.onError;
const onError = useCallback(
(event: ErrorEvent | SyntheticEvent<Element, Event>) => {
setLoaded(false);
setThumbType((prevThumbType) =>
prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
? ThumbType.Thumbnail
: ThumbType.Icon
);
const rawError =
('error' in event && event.error) ||
('message' in event && event.message) ||
'Filetype is not supported yet';
_onError?.(thumbType, rawError instanceof Error ? rawError : new Error(rawError));
},
[_onError, thumbType, itemData.hasLocalThumbnail]
);
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
// thus avoiding improper thumb types changes
@@ -251,6 +269,11 @@ export const FileThumb = memo((props: ThumbProps) => {
)}
</>
);
default:
setThumbType(ThumbType.Thumbnail);
return null;
case 'Image':
// Fallthrough as image and thumbnail use the same component
}
}
@@ -177,13 +177,13 @@ export default () => {
<Select
className="w-full"
value={
explorer.settingsStore.mediaViewWithDescendants ?
'withDescendants'
: 'withoutDescendants'
explorer.settingsStore.mediaViewWithDescendants
? 'withDescendants'
: 'withoutDescendants'
}
onChange={(value) => {
explorer.settingsStore.mediaViewWithDescendants = value === 'withDescendants';
explorer.settingsStore.mediaViewWithDescendants =
value === 'withDescendants';
}}
>
{mediaViewContextActions.options.map((option) => (
@@ -4,6 +4,7 @@ import clsx from 'clsx';
import {
ButtonHTMLAttributes,
createContext,
createRef,
useCallback,
useContext,
useEffect,
@@ -20,7 +21,16 @@ import {
useRspcLibraryContext,
useZodForm
} from '@sd/client';
import { dialogManager, DropdownMenu, Form, ModifierKeys, toast, Tooltip, z } from '@sd/ui';
import {
dialogManager,
DropdownMenu,
Form,
ModifierKeys,
toast,
ToastMessage,
Tooltip,
z
} from '@sd/ui';
import { useIsDark, useKeybind, useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@@ -61,6 +71,8 @@ export const QuickPreview = () => {
const explorer = useExplorerContext();
const { open, itemIndex } = useQuickPreviewStore();
const thumb = createRef<HTMLDivElement>();
const [thumbErrorToast, setThumbErrorToast] = useState<ToastMessage>();
const [showMetadata, setShowMetadata] = useState<boolean>(false);
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
const [isRenaming, setIsRenaming] = useState<boolean>(false);
@@ -82,9 +94,33 @@ export const QuickPreview = () => {
if (items[index]) getQuickPreviewStore().itemIndex = index;
};
// Error toast
useEffect(() => {
if (!thumbErrorToast) return;
let id: string | number | undefined;
toast.error(
(_id) => {
id = _id;
return thumbErrorToast;
},
{
ref: thumb,
duration: Infinity,
onClose() {
id = undefined;
setThumbErrorToast(undefined);
}
}
);
return () => void toast.dismiss(id);
}, [thumb, thumbErrorToast]);
// Reset state
useEffect(() => {
setNewName(null);
setThumbErrorToast(undefined);
if (open || item) return;
@@ -202,6 +238,14 @@ export const QuickPreview = () => {
onOpenAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => isRenaming && e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (
e.target &&
e.target instanceof Node &&
thumb.current?.contains(e.target)
)
e.preventDefault();
}}
>
<div
className={clsx(
@@ -220,7 +264,6 @@ export const QuickPreview = () => {
<div className="absolute inset-0 bg-black/25 backdrop-blur-3xl" />
</div>
)}
<div
className={clsx(
'z-50 flex items-center p-2',
@@ -396,6 +439,16 @@ export const QuickPreview = () => {
<FileThumb
data={item}
onLoad={(type) =>
type === 'ORIGINAL' && setThumbErrorToast(undefined)
}
onError={(type, error) =>
type === 'ORIGINAL' &&
setThumbErrorToast({
title: 'Error loading original file',
body: error.message
})
}
loadOriginal
mediaControls
className={clsx(
@@ -40,7 +40,7 @@ const GridViewItem = memo(({ data, selected, cut, isRenaming }: GridViewItemProp
};
return (
<ViewItem data={data} className="w-full h-full">
<ViewItem data={data} className="h-full w-full">
<div
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
>
@@ -102,7 +102,7 @@ export function useExplorerSettings<TOrder extends Ordering>({
useEffect(() => {
setStore(proxy(settings));
}, [location])
}, [location]);
useEffect(
() =>
@@ -46,7 +46,7 @@ export default function FeedbackDialog(props: UseDialogProps) {
ctaLabel="Submit"
closeLabel="Cancel"
buttonsSideContent={
<div className="flex items-center justify-center w-full gap-1">
<div className="flex w-full items-center justify-center gap-1">
{EMOJIS.map((emoji, i) => (
<div
onClick={() => emojiSelectHandler(i)}
@@ -206,7 +206,7 @@ export const AddLocationDialog = ({
: ''
}
>
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mt-2 mb-4" />
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" />
<LocationPathInputField {...form.register('path')} />
+1 -1
View File
@@ -63,7 +63,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) {
itemData.fullName = `${filePath.name}${filePath.extension ? `.${filePath.extension}` : ''}`;
itemData.size = byteSize(filePath.size_in_bytes_bytes);
itemData.isDir = filePath.is_dir ?? false;
itemData.extension = filePath.extension;
itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null;
if ('kind' in filePath) itemData.kind = ObjectKind[filePath.kind] ?? 'Unknown';
if ('cas_id' in filePath) itemData.casId = filePath.cas_id;
if ('location_id' in filePath) itemData.locationId = filePath.location_id;
+100 -93
View File
@@ -1,6 +1,6 @@
import { CheckCircle, Icon, Info, Warning, WarningCircle, X } from '@phosphor-icons/react';
import clsx from 'clsx';
import { CSSProperties, ReactNode, useEffect, useState } from 'react';
import { CSSProperties, ForwardedRef, forwardRef, ReactNode, useEffect, useState } from 'react';
import { toast as SonnerToast } from 'sonner';
import { Button } from './Button';
@@ -20,7 +20,7 @@ const toastClassName = clsx(
export type ToastId<T = string | number> = T;
type ToastType = 'info' | 'success' | 'error' | 'warning';
type ToastMessage = ReactNode | { title: ReactNode; body?: ReactNode };
export type ToastMessage = ReactNode | { title: ReactNode; body?: ReactNode };
type ToastPromiseData = unknown;
type ToastPromise<T = ToastPromiseData> = Promise<T> | (() => Promise<T>);
type ToastAction = { label: string; onClick: () => void; className?: string };
@@ -28,6 +28,7 @@ type ToastCloseEvent = 'on-dismiss' | 'on-auto-close' | 'on-action' | 'on-cancel
interface ToastOptions {
id?: ToastId;
ref?: React.Ref<HTMLDivElement>;
type?: ToastType;
icon?: ReactNode;
duration?: number;
@@ -42,7 +43,7 @@ interface ToastOptions {
}
interface PromiseToastOptions<T = ToastPromiseData>
extends Omit<ToastOptions, 'icon' | 'type' | 'action' | 'cancel' | 'onClose'> {
extends Omit<ToastOptions, 'ref' | 'icon' | 'type' | 'action' | 'cancel' | 'onClose'> {
loading: ToastMessage;
success: ToastMessage | ((data: T) => ToastMessage);
error: ToastMessage | ((error: unknown) => ToastMessage);
@@ -73,107 +74,111 @@ const icons: Record<ToastType, Icon> = {
warning: Warning
};
const Toast = ({ closable = true, action, cancel, ...props }: ToastProps) => {
const message = typeof props.message === 'function' ? props.message(props.id) : props.message;
const Toast = forwardRef<HTMLDivElement, ToastProps>(
({ closable = true, action, cancel, ...props }, ref) => {
const message =
typeof props.message === 'function' ? props.message(props.id) : props.message;
const title =
message && typeof message === 'object' && 'title' in message ? message.title : message;
const title =
message && typeof message === 'object' && 'title' in message ? message.title : message;
const body =
message && typeof message === 'object' && 'body' in message ? message.body : undefined;
const body =
message && typeof message === 'object' && 'body' in message ? message.body : undefined;
const typeIcon = (type: ToastType) => {
const Icon = icons[type];
return (
<Icon
size={16}
weight="fill"
className={clsx(
type === 'success' && 'text-green-500',
type === 'error' && 'text-red-500',
type === 'warning' && 'text-yellow-500'
)}
/>
);
};
const typeIcon = (type: ToastType) => {
const Icon = icons[type];
return (
<Icon
size={16}
weight="fill"
<div
ref={ref}
className={clsx(
type === 'success' && 'text-green-500',
type === 'error' && 'text-red-500',
type === 'warning' && 'text-yellow-500'
'pointer-events-auto flex gap-2',
body || action || cancel ? 'items-start' : 'items-center'
)}
>
{(props.icon || props.type) && (
<div className={clsx((body || action || cancel) && 'mt-px')}>
{props.icon || (props.type && typeIcon(props.type))}
</div>
)}
/>
);
};
return (
<div
className={clsx(
'flex gap-2',
body || action || cancel ? 'items-start' : 'items-center'
)}
>
{(props.icon || props.type) && (
<div className={clsx((body || action || cancel) && 'mt-px')}>
{props.icon || (props.type && typeIcon(props.type))}
<div className="flex grow flex-col">
{title && (
<span className="font-medium text-ink" style={{ wordBreak: 'break-word' }}>
{title}
</span>
)}
{body && (
<div className="mt-0.5" style={{ wordBreak: 'break-word' }}>
{body}
</div>
)}
{(action || cancel) && (
<div className="mt-2.5 flex gap-2">
{action && (
<Button
variant="accent"
onClick={() => {
action.onClick();
props.onClose?.({ id: props.id, event: 'on-action' });
toast.dismiss(props.id);
}}
className={clsx(actionButtonClassName, action.className)}
>
{action.label}
</Button>
)}
{cancel && (
<Button
variant="gray"
onClick={() => {
typeof cancel === 'object' && cancel.onClick?.();
props.onClose?.({ id: props.id, event: 'on-cancel' });
toast.dismiss(props.id);
}}
className={clsx(
actionButtonClassName,
typeof cancel === 'object' ? cancel.className : null
)}
>
{typeof cancel === 'object' ? cancel.label : cancel}
</Button>
)}
</div>
)}
</div>
)}
<div className="flex grow flex-col">
{title && (
<span className="font-medium text-ink" style={{ wordBreak: 'break-word' }}>
{title}
</span>
)}
{body && (
<div className="mt-0.5" style={{ wordBreak: 'break-word' }}>
{body}
</div>
)}
{(action || cancel) && (
<div className="mt-2.5 flex gap-2">
{action && (
<Button
variant="accent"
onClick={() => {
action.onClick();
props.onClose?.({ id: props.id, event: 'on-action' });
toast.dismiss(props.id);
}}
className={clsx(actionButtonClassName, action.className)}
>
{action.label}
</Button>
)}
{cancel && (
<Button
variant="gray"
onClick={() => {
typeof cancel === 'object' && cancel.onClick?.();
props.onClose?.({ id: props.id, event: 'on-cancel' });
toast.dismiss(props.id);
}}
className={clsx(
actionButtonClassName,
typeof cancel === 'object' ? cancel.className : null
)}
>
{typeof cancel === 'object' ? cancel.label : cancel}
</Button>
)}
</div>
{closable && (
<button
className="relative transition-colors before:absolute before:-inset-2 before:content-[''] hover:text-ink"
onClick={() => {
props.onDismiss?.(props.id);
props.onClose?.({ id: props.id, event: 'on-dismiss' });
toast.dismiss(props.id);
}}
>
<X weight="bold" />
</button>
)}
</div>
{closable && (
<button
className="relative transition-colors before:absolute before:-inset-2 before:content-[''] hover:text-ink"
onClick={() => {
props.onDismiss?.(props.id);
props.onClose?.({ id: props.id, event: 'on-dismiss' });
toast.dismiss(props.id);
}}
>
<X weight="bold" />
</button>
)}
</div>
);
};
);
}
);
interface PromiseToastProps<T = ToastPromiseData>
extends Pick<
@@ -244,6 +249,7 @@ const PromiseToast = <T extends ToastPromiseData>({
const renderToast = (
message: ToastMessage | ((id: ToastId) => ToastMessage),
{
ref,
type,
icon,
action,
@@ -259,6 +265,7 @@ const renderToast = (
(id) => (
<Toast
id={id}
ref={ref}
type={type}
icon={icon}
message={message}